From 8155735066a235982884c6a27c7b69672a3a8d91 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Mon, 20 Jul 2026 17:09:46 -0300 Subject: [PATCH 1/6] feat: add optional [community] param to /buy and /sell commands --- bot/modules/orders/commands.ts | 54 +++++++++++++++++++++++++--------- bot/validations.ts | 34 +++++++++++++++++++-- locales/de.yaml | 12 +++++--- locales/en.yaml | 12 +++++--- locales/es.yaml | 12 +++++--- locales/fa.yaml | 12 +++++--- locales/fr.yaml | 12 +++++--- locales/it.yaml | 12 +++++--- locales/ko.yaml | 12 +++++--- locales/pt.yaml | 12 +++++--- locales/ru.yaml | 12 +++++--- locales/uk.yaml | 12 +++++--- tests/bot/validation.spec.ts | 34 ++++++++++++++++----- util/communityHelper.ts | 34 +++++++++++++++++++++ 14 files changed, 212 insertions(+), 64 deletions(-) diff --git a/bot/modules/orders/commands.ts b/bot/modules/orders/commands.ts index 74ba87d9..406d2d47 100644 --- a/bot/modules/orders/commands.ts +++ b/bot/modules/orders/commands.ts @@ -12,6 +12,7 @@ import * as ordersActions from '../../ordersActions'; import { deletedCommunityMessage } from './messages'; import { getCommunityInfo, + getCommunityByIdentifier, isCurrencySupported, } from '../../../util/communityHelper'; import { takebuy, takesell, takebuyValidation } from './takeOrder'; @@ -50,18 +51,15 @@ const sell = async (ctx: MainContext) => { const sellOrderParams = await validateSellOrder(ctx); if (!sellOrderParams) return; - const { amount, fiatAmount, fiatCode, paymentMethod } = sellOrderParams; + const { amount, fiatAmount, fiatCode, paymentMethod, communityName } = + sellOrderParams; let priceMargin = sellOrderParams.priceMargin; priceMargin = isFloat(priceMargin) ? parseFloat(priceMargin.toFixed(2)) : parseInt(priceMargin); - // Optimized community lookup - single database query instead of multiple - const communityInfo = await getCommunityInfo( - user, - ctx.message?.chat.type || 'private', - ctx.message?.chat as Chat.UserNameChat, - ); + const communityInfo = await resolveOrderCommunity(ctx, user, communityName); + if (!communityInfo) return; const { community, communityId, isBanned } = communityInfo; @@ -119,18 +117,15 @@ const buy = async (ctx: MainContext) => { const buyOrderParams = await validateBuyOrder(ctx); if (!buyOrderParams) return; - const { amount, fiatAmount, fiatCode, paymentMethod } = buyOrderParams; + const { amount, fiatAmount, fiatCode, paymentMethod, communityName } = + buyOrderParams; let priceMargin = buyOrderParams.priceMargin; priceMargin = isFloat(priceMargin) ? parseFloat(priceMargin.toFixed(2)) : parseInt(priceMargin); - // Optimized community lookup - single database query instead of multiple - const communityInfo = await getCommunityInfo( - user, - ctx.message?.chat.type || 'private', - ctx.message?.chat as Chat.UserNameChat, - ); + const communityInfo = await resolveOrderCommunity(ctx, user, communityName); + if (!communityInfo) return; const { community, communityId, isBanned } = communityInfo; @@ -182,6 +177,37 @@ const buy = async (ctx: MainContext) => { } }; +// Decides which community an order should be published to. +// When the user passes a community name in a private chat, the order is +// published to that community. Otherwise the default behaviour applies: the +// group's community when the command runs inside a group, or the user's +// default community in private. Returns null when the flow must stop because +// an error was already reported to the user. +async function resolveOrderCommunity( + ctx: MainContext, + user: UserDocument, + communityName?: string, +) { + const isPrivate = (ctx.message?.chat.type || 'private') === 'private'; + + // The community name is only honored in private chats; inside a group the + // group's own community always wins. + if (communityName && isPrivate) { + const communityInfo = await getCommunityByIdentifier(user, communityName); + if (!communityInfo.community) { + await ctx.reply(ctx.i18n.t('community_not_found')); + return null; + } + return communityInfo; + } + + return getCommunityInfo( + user, + ctx.message?.chat.type || 'private', + ctx.message?.chat as Chat.UserNameChat, + ); +} + async function enterWizard( ctx: CommunityContext, user: UserDocument, diff --git a/bot/validations.ts b/bot/validations.ts index 045eb3f4..cf948a0e 100644 --- a/bot/validations.ts +++ b/bot/validations.ts @@ -161,6 +161,28 @@ const processParameters = (args: string[]) => { return correctedArgs; }; +// The two optional trailing params of /buy and /sell are the price margin and +// the community name. They are told apart by type, not position: the numeric +// one is the price margin, the non-numeric one is the community name. This +// works whether the user passes both, only one, or none of them. +const parseOptionalOrderParams = (optionalArgs: string[]) => { + // priceMargin is kept loosely typed because downstream callers feed it + // through isFloat/parseInt/isNaN, matching the previous untyped behaviour. + let priceMargin: any; + let communityName: string | undefined; + + for (const arg of optionalArgs) { + if (arg === '') continue; + if (isNaN(Number(arg))) { + communityName = arg; + } else { + priceMargin = arg; + } + } + + return { priceMargin, communityName }; +}; + const validateSellOrder = async (ctx: MainContext) => { try { let args = ctx.state.command.args; @@ -170,7 +192,10 @@ const validateSellOrder = async (ctx: MainContext) => { } args = processParameters(args); - let [amount, fiatAmount, fiatCode, paymentMethod, priceMargin] = args; + let [amount, fiatAmount, fiatCode, paymentMethod] = args; + const { priceMargin, communityName } = parseOptionalOrderParams( + args.slice(4), + ); if (priceMargin && isNaN(priceMargin)) { await ctx.reply( @@ -244,6 +269,7 @@ const validateSellOrder = async (ctx: MainContext) => { fiatCode: fiatCode.toUpperCase(), paymentMethod, priceMargin, + communityName, }; } catch (error) { logger.error(error); @@ -260,7 +286,10 @@ const validateBuyOrder = async (ctx: MainContext) => { } args = processParameters(args); - let [amount, fiatAmount, fiatCode, paymentMethod, priceMargin] = args; + let [amount, fiatAmount, fiatCode, paymentMethod] = args; + const { priceMargin, communityName } = parseOptionalOrderParams( + args.slice(4), + ); if (priceMargin && isNaN(priceMargin)) { await ctx.reply( @@ -332,6 +361,7 @@ const validateBuyOrder = async (ctx: MainContext) => { fiatCode: fiatCode.toUpperCase(), paymentMethod, priceMargin, + communityName, }; } catch (error) { logger.error(error); diff --git a/locales/de.yaml b/locales/de.yaml index 65474515..58f3cd1a 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -71,7 +71,9 @@ must_be_numeric: ${fieldName} muss numerisch sein sats_amount: sats Wert fiat_amount: fiat Wert sell_correct_format: | - /sell \<_Anzahl sats_\> \<_fiat Wert_\> \<_fiat code_\> \<_Zahlungsmethode_\> \[_Zuschlag/Rabatt_\] + /sell \<_Anzahl sats_\> \<_fiat Wert_\> \<_fiat code_\> \<_Zahlungsmethode_\> \[_Zuschlag/Rabatt_\] \[_Community_\] + + Du kannst den Namen einer deiner Communities als letzten Parameter hinzufügen, um die Order dort statt in deiner Standard\-Community zu veröffentlichen\. Um einen Verkaufsauftrag von 1000 Sats für 2 Euro \(EUR\) zu erstellen und anzugeben, dass die Zahlung in FIAT durch Überweisung oder Einzahlung am Geldautomaten erfolgt, musst du \<\> und \[\] weglassen\. @@ -85,7 +87,9 @@ sell_correct_format: | `/sell 0 100\-500 EUR "Zahlungsmethode" 3` buy_correct_format: | - /buy \<_Anzahl sats_\> \<_fiat Wert_\> \<_fiat code_\> \<_Zahlungsmethode_\> \[_Zuschlag/Rabatt_\] + /buy \<_Anzahl sats_\> \<_fiat Wert_\> \<_fiat code_\> \<_Zahlungsmethode_\> \[_Zuschlag/Rabatt_\] \[_Community_\] + + Du kannst den Namen einer deiner Communities als letzten Parameter hinzufügen, um die Order dort statt in deiner Standard\-Community zu veröffentlichen\. Um einen Auftrag über 1000 Sats für 2 \(EUR\) zu erstellen und anzugeben, dass die Zahlung per Überweisung erfolgt, musst du sowohl \<\> als auch \[\] weglassen\. @@ -241,8 +245,8 @@ must_be_number_or_range: 'Fiat_amount muss eine Zahl oder ein numerischer Bereic invalid_lightning_address: Ungültige Lightning-Adresse unavailable_lightning_address: Nicht verfügbare Lightning-Adresse ${la} help: | - /sell <_Anzahl sats_> <_fiat Wert_> <_fiat code_> <_Zahlungsmethode_> \[_Zuschlag/Rabatt_] - Erstellt einen Bitcoin Verkaufsauftrag - /buy <_Anzahl sats_> <_fiat Wert_> <_fiat code_> <_Zahlungsmethode_> \[_Zuschlag/Rabatt_] - Erstellt eine Bitcoin Kaufauftrag + /sell <_Anzahl sats_> <_fiat Wert_> <_fiat code_> <_Zahlungsmethode_> \[_Zuschlag/Rabatt_] \[_Community_] - Erstellt einen Bitcoin Verkaufsauftrag + /buy <_Anzahl sats_> <_fiat Wert_> <_fiat code_> <_Zahlungsmethode_> \[_Zuschlag/Rabatt_] \[_Community_] - Erstellt eine Bitcoin Kaufauftrag /takeorder <_order id_> - Ermöglicht Ihnen, eine Bestellung aus dem Chat mit dem Bot anzunehmen, ohne zu dem Kanal zu gehen, in dem sie veröffentlicht wurde /info - Zeigt zusätzliche Informationen über das Bot an /showusername - Schaltet die Anzeige des Benutzernamens in jedem neu erstellten Auftrag aus. Der Standardwert ist *nein* (false) diff --git a/locales/en.yaml b/locales/en.yaml index fe6c5aec..6680f6d5 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -73,7 +73,9 @@ must_be_numeric: ${fieldName} must be numeric sats_amount: sats amount fiat_amount: fiat amount sell_correct_format: | - /sell \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] + /sell \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_community_\] + + You can add the name of one of your communities as the last parameter to publish the order there instead of your default community\. In order to create a sell order of 1000 satoshis for 2 US dollars \(USD\) and indicate that the fiat payment is through transfer or ATM deposit, you must avoid \<\> and \[\]\. @@ -87,7 +89,9 @@ sell_correct_format: | `/sell 0 100\-500 USD "payment method" 3` buy_correct_format: | - /buy \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] + /buy \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_community_\] + + You can add the name of one of your communities as the last parameter to publish the order there instead of your default community\. In order to create an order for 1000 satoshis for 2 \(USD\) and indicate that fiat payment is through transfer, you must to omit both \<\> and \[\]\. @@ -245,8 +249,8 @@ invalid_lightning_address: Invalid lightning address unavailable_lightning_address: Unavailable lightning address ${la} ln_address_temporarily_disabled: ⚠️ Lightning Address payouts are temporarily disabled for maintenance. Please send a regular Lightning invoice (BOLT11) for the exact order amount to receive your sats. help: | - /sell <_sats amount_> <_fiat amount_> <_fiat code_> <_payment method_> [premium/discount] - Creates a Sell order - /buy <_sats amount_> <_fiat amount_> <_Fiat code_> <_payment method_> [premium/discount] - Creates a Purchase Order + /sell <_sats amount_> <_fiat amount_> <_fiat code_> <_payment method_> [premium/discount] [community] - Creates a Sell order + /buy <_sats amount_> <_fiat amount_> <_Fiat code_> <_payment method_> [premium/discount] [community] - Creates a Purchase Order /takeorder <_order id_> - Allows the user to take an order from the chat with the bot without going to the channel where it was published /info - Shows additional info about the bot /showusername - Toggles off the username display in each new order created. Default value is set to false diff --git a/locales/es.yaml b/locales/es.yaml index 62cd4029..54c0714c 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -71,7 +71,9 @@ must_be_numeric: ${fieldName} debe ser numérico sats_amount: monto en sats fiat_amount: monto en fiat sell_correct_format: | - /sell \<_monto en sats_\> \<_monto en fiat_\> \<_código fiat_\> \<_método de pago_\> \[_prima/descuento_\] + /sell \<_monto en sats_\> \<_monto en fiat_\> \<_código fiat_\> \<_método de pago_\> \[_prima/descuento_\] \[_comunidad_\] + + Puedes agregar el nombre de una de tus comunidades como último parámetro para publicar la orden ahí en lugar de en tu comunidad por defecto\. Para crear una venta de 1000 satoshis por 2 bolívares \(VES\) e indicar que el método de pago fiat es pago móvil, debes omitir los \<\> y los \[\]\. @@ -85,7 +87,9 @@ sell_correct_format: | `/sell 0 100\-500 ves "pago móvil" 3` buy_correct_format: | - /buy \<_monto en sats_\> \<_monto en fiat_\> \<_código fiat_\> \<_método de pago_\> \[_prima/descuento_\] + /buy \<_monto en sats_\> \<_monto en fiat_\> \<_código fiat_\> \<_método de pago_\> \[_prima/descuento_\] \[_comunidad_\] + + Puedes agregar el nombre de una de tus comunidades como último parámetro para publicar la orden ahí en lugar de en tu comunidad por defecto\. Para crear una compra de 1000 satoshis por 2 bolívares \(VES\) e indicar que el método de pago fiat es pago móvil, debes omitir los \<\> y los \[\]\. @@ -242,8 +246,8 @@ invalid_lightning_address: Dirección lightning no válida unavailable_lightning_address: Dirección lightning ${la} no disponible ln_address_temporarily_disabled: ⚠️ Los pagos a Lightning Address están temporalmente deshabilitados por mantenimiento. Por favor envía una factura Lightning normal (BOLT11) por el monto exacto de la orden para recibir tus sats. help: | - /sell <_monto en sats_> <_monto en fiat_> <_código fiat_> <_método de pago_> [prima/descuento] - Crea una orden de venta - /buy <_monto en sats_> <_monto en fiat_> <_código fiat_> <_método de pago_> [prima/descuento] - Crea una orden de compra + /sell <_monto en sats_> <_monto en fiat_> <_código fiat_> <_método de pago_> [prima/descuento] [comunidad] - Crea una orden de venta + /buy <_monto en sats_> <_monto en fiat_> <_código fiat_> <_método de pago_> [prima/descuento] [comunidad] - Crea una orden de compra /takeorder <_order id_> - Permite tomar una orden desde el chat con el bot sin ir al canal donde fue publicada /info - Muestra información sobre el bot /showusername - Permite mostrar u ocultar el username en cada nueva orden creada, el valor predeterminado es no (falso) diff --git a/locales/fa.yaml b/locales/fa.yaml index acf196be..f94fc25b 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -74,7 +74,9 @@ must_be_numeric: '${fieldName} باید عدد باشد.' sats_amount: تعداد ساتوشی fiat_amount: مقدار فیات sell_correct_format: | - /sell \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] + /sell \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_community_\] + + می‌توانید نام یکی از اجتماع‌های خود را به عنوان آخرین پارامتر اضافه کنید تا سفارش به جای اجتماع پیش‌فرض شما در آنجا منتشر شود\. جهت ایجاد سفارش فروش 1000 ساتوشی در ازای 300 هزار تومان \(IRT\) و اعلام «کارت به کارت» به عنوان روش پرداخت، باید از به کار بردن \<\> و \[\]\ اجتناب کنید\. @@ -88,7 +90,9 @@ sell_correct_format: | `/sell 0 300000\-500000 IRT "payment method" 3` buy_correct_format: | - /buy \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] + /buy \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_community_\] + + می‌توانید نام یکی از اجتماع‌های خود را به عنوان آخرین پارامتر اضافه کنید تا سفارش به جای اجتماع پیش‌فرض شما در آنجا منتشر شود\. جهت ایجاد سفارش خرید 1000 ساتوشی در ازای 300 هزار تومان \(IRT\) و اعلام «کارت به کارت» به عنوان روش پرداخت، باید از به کار بردن \<\> و \[\]\ اجتناب کنید\. @@ -254,10 +258,10 @@ must_be_number_or_range: | invalid_lightning_address: نشانی لایتنینگ نامعتبر است. unavailable_lightning_address: 'نشانی لایتنینگ ${la} در دسترس نیست.' help: | - /sell <_sats amount_> <_fiat amount_> <_fiat code_> <_payment method_> [premium/discount] + /sell <_sats amount_> <_fiat amount_> <_fiat code_> <_payment method_> [premium/discount] [community] سفارش فروش ایجاد می‌کند. - /buy <_sats amount_> <_fiat amount_> <_Fiat code_> <_payment method_> [premium/discount] + /buy <_sats amount_> <_fiat amount_> <_Fiat code_> <_payment method_> [premium/discount] [community] سفارش خرید ایجاد می‌کند. /takeorder <_order id_> diff --git a/locales/fr.yaml b/locales/fr.yaml index df82ee9c..9b11b21e 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -73,7 +73,9 @@ must_be_numeric: ${fieldName} doit être numérique sats_amount: montant en sats fiat_amount: montant en fiat sell_correct_format: | - /sell \<_montant en sats_\> \<_montant en fiat_\> \<_code monnaie fiat_\> \<_méthode de paiement_\> \[_plus-value/remise_\] + /sell \<_montant en sats_\> \<_montant en fiat_\> \<_code monnaie fiat_\> \<_méthode de paiement_\> \[_plus-value/remise_\] \[_communauté_\] + + Vous pouvez ajouter le nom d'une de vos communautés comme dernier paramètre pour y publier l'ordre au lieu de votre communauté par défaut\. Pour créer une offre de vente de 1000 satoshis pour 6000 Francs CFA \(XOF\) et indiquer que le paiement fiat doit se faire via Wave ou Mobile Money, il faut omettre les caractères \<\> and \[\]\. @@ -87,7 +89,9 @@ sell_correct_format: | `/sell 0 50000\-100000 XOF "Wave ou Mobile Money" 3` buy_correct_format: | - /buy \<_montant en sats_\> \<_montant en fiat_\> \<_code monnaie fiat_\> \<_méthode de paiement_\> \[_plus-value/remise_\] + /buy \<_montant en sats_\> \<_montant en fiat_\> \<_code monnaie fiat_\> \<_méthode de paiement_\> \[_plus-value/remise_\] \[_communauté_\] + + Vous pouvez ajouter le nom d'une de vos communautés comme dernier paramètre pour y publier l'ordre au lieu de votre communauté par défaut\. Pour créer une offre d'achat de 1000 satoshis pour 6000 Francs CFA \(XOF\) et indiquer que le paiement fiat doit se faire via transfert en ligne, il faut omettre les caractères \<\> and \[\]\. @@ -243,8 +247,8 @@ must_be_number_or_range: 'Le montant fiat doit être un nombre entier ou une pla invalid_lightning_address: Adresse Lightning invalide ! unavailable_lightning_address: Adresse Lightning non disponible ${la} help: | - /sell <_montant en sats_> <_montant en fiat_> <_code fiat_> <_méthode de paiement_> [plus-value/remise] - Pour créer une offre de vente - /buy <_montant en sats_> <_montant en fiat_> <_code fiat_> <_méthode de paiement_> [plus-value/remise] - Pour créer une offre d'achat + /sell <_montant en sats_> <_montant en fiat_> <_code fiat_> <_méthode de paiement_> [plus-value/remise] [communauté] - Pour créer une offre de vente + /buy <_montant en sats_> <_montant en fiat_> <_code fiat_> <_méthode de paiement_> [plus-value/remise] [communauté] - Pour créer une offre d'achat /takeorder <_order id_> - Vous permet de prendre une commande depuis le chat avec le bot sans accéder au canal où elle a été publiée /info - Affiche des informations additionnelles à propos du bot /showusername - Désactive l'affichage du nom d'utilisateur dans chaque offre nouvellement créée. La valeur par défaut est définie sur false diff --git a/locales/it.yaml b/locales/it.yaml index 85359643..deb5a886 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -71,7 +71,9 @@ must_be_numeric: ${fieldName} deve essere numerico sats_amount: importo in sats fiat_amount: importo in fiat sell_correct_format: | - /sell \<_importo in sats_\> \<_importo in fiat_\> \<_fiat code_\> \<_metodo di pagamento_\> \[_surplus/sconto_\] + /sell \<_importo in sats_\> \<_importo in fiat_\> \<_fiat code_\> \<_metodo di pagamento_\> \[_surplus/sconto_\] \[_community_\] + + Puoi aggiungere il nome di una delle tue community come ultimo parametro per pubblicare l'ordine lì invece che nella tua community predefinita\. Per creare un ordine di vendita di 1000 satoshi per 2 euro \(EUR\) e indicare che il pagamento in fiat avviene tramite bonifico o deposito ATM, occorre evitare di \<\> e \[\]\. @@ -85,7 +87,9 @@ sell_correct_format: | `/sell 0 100\-500 EUR "metodo di pagamento" 3` buy_correct_format: | - /buy \<_importo in sats_\> \<_importo in fiat_\> \<_fiat code_\> \<_metodo di pagamento_\> \[_surplus/discount_\] + /buy \<_importo in sats_\> \<_importo in fiat_\> \<_fiat code_\> \<_metodo di pagamento_\> \[_surplus/discount_\] \[_community_\] + + Puoi aggiungere il nome di una delle tue community come ultimo parametro per pubblicare l'ordine lì invece che nella tua community predefinita\. Per creare un ordine di 1000 satoshi per 2 euro \(EUR\) e indicare che il pagamento in fiat avviene tramite bonifico, è necessario omettere entrambe le diciture \<\> e \[\]\. @@ -241,8 +245,8 @@ must_be_number_or_range: 'Importo in fiat deve essere un numero o un range numer invalid_lightning_address: Invalid lightning adress unavailable_lightning_address: Lightning adress non disponibile ${la} help: | - /sell <_importo in satoshi_> <_importo in fiat_> <_fiat code_> <_metodo di pagamento_> [surplus/sconto] - Crea un ordine di vendita - /buy <_importo in satoshi_> <_importo in fiat_> <_Fiat code_> <_metodo di pagamento_> [premium/discount] - Crea un ordine di acquisto + /sell <_importo in satoshi_> <_importo in fiat_> <_fiat code_> <_metodo di pagamento_> [surplus/sconto] [community] - Crea un ordine di vendita + /buy <_importo in satoshi_> <_importo in fiat_> <_Fiat code_> <_metodo di pagamento_> [premium/discount] [community] - Crea un ordine di acquisto /takeorder <_order id_> - Permette di prendere un ordine dalla chat con il bot senza passare al canale dove è stato pubblicato /info - Mostra indo aggiuntive sul Bot /showusername - Disattiva la visualizzazione del nome utente in ogni nuovo ordine creato. Il valore predefinito è settato su no (false) diff --git a/locales/ko.yaml b/locales/ko.yaml index 24fe08a5..9e0388c3 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -72,7 +72,9 @@ must_be_numeric: ${fieldName}은 반드시 숫자형이어야만 합니다. sats_amount: 비트코인 액수 (sats) fiat_amount: 법정화폐 액수 sell_correct_format: | - /sell \<_비트코인 액수_\> \<_법정화폐 액수_\> \<_거래 통화_\> \<_결제 방법_\> \[_프리미엄/할인_\] + /sell \<_비트코인 액수_\> \<_법정화폐 액수_\> \<_거래 통화_\> \<_결제 방법_\> \[_프리미엄/할인_\] \[_커뮤니티_\] + + 기본 커뮤니티 대신 특정 커뮤니티에 주문을 게시하려면 마지막 매개변수로 커뮤니티 이름을 추가할 수 있습니다\. 1000 \(KRW\_)에 500 사토시를 판매하는 주문을 생성하고 법정화폐 결제 방법을 명시할 때, 위의 예시에서 \<\>와 \[\]는 빼는 것을 잊지 마세요. @@ -86,7 +88,9 @@ sell_correct_format: | `/sell 0 10000\-50000 KRW "결제 방법" 3` buy_correct_format: | - /buy \<_비트코인 액수_\> \<_법정화폐 액수_\> \<_거래 통화_\> \<_결제 방법_\> \[_프리미엄/할인_\] + /buy \<_비트코인 액수_\> \<_법정화폐 액수_\> \<_거래 통화_\> \<_결제 방법_\> \[_프리미엄/할인_\] \[_커뮤니티_\] + + 기본 커뮤니티 대신 특정 커뮤니티에 주문을 게시하려면 마지막 매개변수로 커뮤니티 이름을 추가할 수 있습니다\. 500 사토시를 1000 \(KRW\)에 구매하는 주문을 생성하고 법정화폐 결제 방법을 명시할 때, 위의 예시에서 \<\>와 \[\]는 빼는 것을 잊지 마세요. @@ -242,8 +246,8 @@ must_be_number_or_range: 'Fiat_amount는 반드시 숫자나 <최소>-<최대> invalid_lightning_address: 유효하지 않은 라이트닝 주소입니다. unavailable_lightning_address: 사용할 수 없는 라이트닝 주소입니다 ${la} help: | - /sell <_사토시 금액_> <_법정화폐 금액_> <_법정화폐 코드_> <_결제 방법_> [프리미엄/할인율] - 판매 주문을 생성합니다. - /buy <_사토시 금액_> <_법정화폐 금액_> <_법정화폐 코드_> <_결제 방법_> [프리미엄/할인율] - 구매 주문을 생성합니다. + /sell <_사토시 금액_> <_법정화폐 금액_> <_법정화폐 코드_> <_결제 방법_> [프리미엄/할인율] [커뮤니티] - 판매 주문을 생성합니다. + /buy <_사토시 금액_> <_법정화폐 금액_> <_법정화폐 코드_> <_결제 방법_> [프리미엄/할인율] [커뮤니티] - 구매 주문을 생성합니다. /takeorder <_주문 ID_> - 따로 주문이 등록된 마켓 채널에 가지 않고, 봇 채널에서 주문 ID로 주문을 수락할 수 있게 해 줍니다. /info - 봇에 대한 추가적인 정보를 보여줍니다. /showusername - 새로운 주문이 생성될 때마다 사용자명을 표시할지 여부를 설정합니다. 기본값은 false입니다. diff --git a/locales/pt.yaml b/locales/pt.yaml index 274ea54e..b5c6602c 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -72,7 +72,9 @@ must_be_numeric: ${fieldName} deve ser numérico sats_amount: quantidade de sats fiat_amount: quantidade fiduciária sell_correct_format: | - /sell \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] + /sell \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_comunidade_\] + + Você pode adicionar o nome de uma de suas comunidades como último parâmetro para publicar a ordem lá em vez de na sua comunidade padrão\. Para criar uma oferta de venda de 1000 satoshis por 2 dólares americanos \(USD\) e indicar que o pagamento fiduciário é por transferência ou depósito no caixa eletrônico, você deve evitar \<\> e \[\]\. @@ -86,7 +88,9 @@ sell_correct_format: | `/sell 0 100\-500 USD "forma de pagamento" 3` buy_correct_format: | - /buy \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] + /buy \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_comunidade_\] + + Você pode adicionar o nome de uma de suas comunidades como último parâmetro para publicar a ordem lá em vez de na sua comunidade padrão\. Para criar uma oferta e pedir 1000 Satoshis por 2 \(USD\) e indicar que o pagamento fiduciário é através de transferência, você deve omitir os dois \<\> e \[\]\. @@ -242,8 +246,8 @@ must_be_number_or_range: 'Fiat_amount deve ser um número ou intervalo numérico invalid_lightning_address: Inválida lightning address unavailable_lightning_address: Indisponível lightning address ${la} help: | - /sell premium/desconto - Criar uma ordem de venda - /buy premium/desconto - Cria um pedido de compra + /sell premium/desconto comunidade - Criar uma ordem de venda + /buy premium/desconto comunidade - Cria um pedido de compra /takeorder <_order id_> - Permite ao usuário fazer um pedido dentro do chat com o bot, sem precisar ir até o canal onde foi publicado /info - Mostra informações sobre o bot /showusername - Permite mostrar ou ocultar o nome de usuário em cada novo pedido criado, o valor padrão é não (falso) diff --git a/locales/ru.yaml b/locales/ru.yaml index 49b61552..d411f9bd 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -69,7 +69,9 @@ must_be_numeric: ${fieldName} must be numeric sats_amount: сумма в сатоши fiat_amount: сумма в валюте sell_correct_format: | - /sell \<_сумма в сатоши_\> \<_сумма в валюте_\> \<_код валюты_\> \<_метод платежа_\> \[_премия/дисконт_\] + /sell \<_сумма в сатоши_\> \<_сумма в валюте_\> \<_код валюты_\> \<_метод платежа_\> \[_премия/дисконт_\] \[_сообщество_\] + + Вы можете добавить название одного из ваших сообществ последним параметром, чтобы опубликовать ордер там вместо вашего сообщества по умолчанию\. Чтобы создать заявку на продажу 1000 сатоши за 50 рублей \(RUB\) с оплатой по номеру телефона: @@ -83,7 +85,9 @@ sell_correct_format: | `/sell 0 100\-500 rub "на мобильный" 3` buy_correct_format: | - /buy \<_сумма в сатоши_\> \<_сумма в валюте_\> \<_код валюты_\> \<_метод платежа_\> \[_премия/дисконт_\] + /buy \<_сумма в сатоши_\> \<_сумма в валюте_\> \<_код валюты_\> \<_метод платежа_\> \[_премия/дисконт_\] \[_сообщество_\] + + Вы можете добавить название одного из ваших сообществ последним параметром, чтобы опубликовать ордер там вместо вашего сообщества по умолчанию\. Чтобы создать заявку на покупку 1000 сатоши за 50 рублей \(RUB\) с оплатой по номеру телефона: @@ -240,8 +244,8 @@ must_be_number_or_range: 'сумма в валюте должно быть чи invalid_lightning_address: неверный адрес LN unavailable_lightning_address: адрес LN ${la} недоступен help: | - /sell <_сумма в сатоши_> <_сумма в валюте_> <_код валюты_> <_метод платежа_> [_премия/дисконт_] - Создать заявку на продажу - /buy <_сумма в сатоши_> <_сумма в валюте_> <_код валюты_> <_метод платежа_> [_премия/дисконт_] - Создать заявку на покупку + /sell <_сумма в сатоши_> <_сумма в валюте_> <_код валюты_> <_метод платежа_> [_премия/дисконт_] [_сообщество_] - Создать заявку на продажу + /buy <_сумма в сатоши_> <_сумма в валюте_> <_код валюты_> <_метод платежа_> [_премия/дисконт_] [_сообщество_] - Создать заявку на покупку /takeorder <_order id_> - Позволяет пользователю принять заказ из чата с ботом, не заходя на канал, где он был опубликован /info - Показать информацию о Боте /showusername - Позволяет показывать или скрывать имя пользователя в каждой новой созданной заявке, значение по умолчанию — нет (false) diff --git a/locales/uk.yaml b/locales/uk.yaml index bda2aa09..6c6becee 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -70,7 +70,9 @@ must_be_numeric: ${fieldName} повинен бути числовим sats_amount: сума у сатоші fiat_amount: сума у валюті sell_correct_format: | - /sell \<_сума у сатоші_\> \<_сума у валюті_\> \<_код валюти_\> \<_метод платежу\> \[_премія/дисконт_\] + /sell \<_сума у сатоші_\> \<_сума у валюті_\> \<_код валюти_\> \<_метод платежу\> \[_премія/дисконт_\] \[_спільнота_\] + + Ви можете додати назву однієї зі своїх спільнот останнім параметром, щоб опублікувати ордер там замість вашої спільноти за замовчуванням\. Щоб створити заявку на продаж 1000 сатоші за 50 гривень (UAH) з оплатою за номером телефону: @@ -84,7 +86,9 @@ sell_correct_format: | `/sell 0 100\-500 uah "на мобільний" 3` buy_correct_format: | - /buy \<_сума у сатоші_\> \<_сума у валюті_\> \<_код валюти_\> \<_метод платежу_\> \[_премія/дисконт_\] + /buy \<_сума у сатоші_\> \<_сума у валюті_\> \<_код валюти_\> \<_метод платежу_\> \[_премія/дисконт_\] \[_спільнота_\] + + Ви можете додати назву однієї зі своїх спільнот останнім параметром, щоб опублікувати ордер там замість вашої спільноти за замовчуванням\. Щоб створити заявку на купівлю 1000 сатоші за 50 гривень (UAH) з оплатою за номером телефону: @@ -240,8 +244,8 @@ must_be_number_or_range: 'Сума у валюті має бути число а invalid_lightning_address: Невірна адреса LN unavailable_lightning_address: Адреса LN ${la} недоступна help: | - /sell <_сума у сатоші_> <_сума у валюті_> <_код валюти_> <_метод платежу_> [_премія/дисконт_] - Створити заявку на продаж - /buy <_сума у сатоші_> <_сума у валюті_> <_код валюти_> <_метод платежу_> [_премія/дисконт_] - Створити заявку на купівлю + /sell <_сума у сатоші_> <_сума у валюті_> <_код валюти_> <_метод платежу_> [_премія/дисконт_] [_спільнота_] - Створити заявку на продаж + /buy <_сума у сатоші_> <_сума у валюті_> <_код валюти_> <_метод платежу_> [_премія/дисконт_] [_спільнота_] - Створити заявку на купівлю /takeorder <_order id_> - дозволяє користувачеві приймати замовлення з чату з ботом, не переходячи на канал, де воно було опубліковано /info - Показати інформацію про Бот /showusername - Дозволяє показувати або приховувати ім'я користувача в кожній новій заявці, значення за замовчуванням — ні (false) diff --git a/tests/bot/validation.spec.ts b/tests/bot/validation.spec.ts index 63a5f3a6..7a463e11 100644 --- a/tests/bot/validation.spec.ts +++ b/tests/bot/validation.spec.ts @@ -156,11 +156,20 @@ describe('Validations', () => { expect(result.priceMargin).to.equal('5'); }); - it('should return false if price margin is not a number', async () => { - ctx.state.command.args = ['10000', '100', 'USD', 'zelle', 'test']; + it('should treat a non-numeric trailing param as the community name', async () => { + ctx.state.command.args = ['10000', '100', 'USD', 'zelle', 'mycommunity']; const result = await validateSellOrder(ctx); - expect(result).to.equal(false); - expect(replyStub.calledOnce).to.be.equal(true); + if (result === false) throw new Error('object expected'); + expect(result.communityName).to.equal('mycommunity'); + expect(result.priceMargin).to.equal(undefined); + }); + + it('should parse both price margin and community name', async () => { + ctx.state.command.args = ['0', '100', 'USD', 'zelle', '5', 'mycommunity']; + const result = await validateSellOrder(ctx); + if (result === false) throw new Error('object expected'); + expect(result.priceMargin).to.equal('5'); + expect(result.communityName).to.equal('mycommunity'); }); it('should work with ranges', async () => { @@ -230,11 +239,20 @@ describe('Validations', () => { expect(result.priceMargin).to.equal('5'); }); - it('should return false if price margin is not a number', async () => { - ctx.state.command.args = ['10000', '100', 'USD', 'zelle', 'test']; + it('should treat a non-numeric trailing param as the community name', async () => { + ctx.state.command.args = ['10000', '100', 'USD', 'zelle', 'mycommunity']; const result = await validateBuyOrder(ctx); - expect(result).to.equal(false); - expect(replyStub.calledOnce).to.be.equal(true); + if (result === false) throw new Error('object expected'); + expect(result.communityName).to.equal('mycommunity'); + expect(result.priceMargin).to.equal(undefined); + }); + + it('should parse both price margin and community name', async () => { + ctx.state.command.args = ['0', '100', 'USD', 'zelle', '5', 'mycommunity']; + const result = await validateBuyOrder(ctx); + if (result === false) throw new Error('object expected'); + expect(result.priceMargin).to.equal('5'); + expect(result.communityName).to.equal('mycommunity'); }); it('should work with ranges', async () => { diff --git a/util/communityHelper.ts b/util/communityHelper.ts index 60926e79..53cdcee1 100644 --- a/util/communityHelper.ts +++ b/util/communityHelper.ts @@ -72,6 +72,40 @@ export const getCommunityInfo = async ( } }; +/** + * Look up an enabled community by the identifier the user typed, ignoring + * uppercase/lowercase. It first tries the community group (the @handle or + * telegram group id, the same identifier used by /setcomm) and falls back to + * the display name. Also reports whether the given user is banned from it. + */ +export const getCommunityByIdentifier = async ( + user: UserDocument, + identifier: string, +): Promise => { + try { + // Escape regex metacharacters so the identifier is matched literally + const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`^${escaped}$`, 'i'); + + const community = + (await Community.findOne({ group: regex, enabled: { $ne: false } })) || + (await Community.findOne({ name: regex, enabled: { $ne: false } })); + + if (!community) { + return { community: null, communityId: undefined, isBanned: false }; + } + + const isBanned = community.banned_users.some( + (buser: any) => String(buser.id) === String(user._id), + ); + + return { community, communityId: community._id, isBanned }; + } catch (error) { + logger.error(`Error in getCommunityByIdentifier: ${error}`); + return { community: null, communityId: undefined, isBanned: false }; + } +}; + /** * Check if a currency is supported by a community */ From 15406a952f703c48758a11724399c229937deaf6 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Tue, 21 Jul 2026 12:40:43 -0300 Subject: [PATCH 2/6] fix: positional order params and secure community name lookup --- bot/modules/orders/commands.ts | 9 ++-- bot/validations.ts | 25 +++++------ locales/de.yaml | 4 +- locales/en.yaml | 4 +- locales/es.yaml | 4 +- locales/fa.yaml | 4 +- locales/fr.yaml | 4 +- locales/it.yaml | 4 +- locales/ko.yaml | 4 +- locales/pt.yaml | 4 +- locales/ru.yaml | 4 +- locales/uk.yaml | 4 +- tests/bot/validation.spec.ts | 40 +++++++++++++++--- tests/util/communityHelper.spec.ts | 67 ++++++++++++++++++++++++++++++ util/communityHelper.ts | 25 +++++++---- 15 files changed, 153 insertions(+), 53 deletions(-) create mode 100644 tests/util/communityHelper.spec.ts diff --git a/bot/modules/orders/commands.ts b/bot/modules/orders/commands.ts index 406d2d47..7b55c8be 100644 --- a/bot/modules/orders/commands.ts +++ b/bot/modules/orders/commands.ts @@ -188,11 +188,10 @@ async function resolveOrderCommunity( user: UserDocument, communityName?: string, ) { - const isPrivate = (ctx.message?.chat.type || 'private') === 'private'; - - // The community name is only honored in private chats; inside a group the - // group's own community always wins. - if (communityName && isPrivate) { + // An explicit community always wins, in private chats and in groups alike. + // Only when none is passed do we fall back to the group's community (in a + // group) or the user's default community (in private). + if (communityName) { const communityInfo = await getCommunityByIdentifier(user, communityName); if (!communityInfo.community) { await ctx.reply(ctx.i18n.t('community_not_found')); diff --git a/bot/validations.ts b/bot/validations.ts index cf948a0e..4636ae69 100644 --- a/bot/validations.ts +++ b/bot/validations.ts @@ -161,24 +161,19 @@ const processParameters = (args: string[]) => { return correctedArgs; }; -// The two optional trailing params of /buy and /sell are the price margin and -// the community name. They are told apart by type, not position: the numeric -// one is the price margin, the non-numeric one is the community name. This -// works whether the user passes both, only one, or none of them. +// The two optional trailing params of /buy and /sell are, in order, the price +// margin and the community. They are told apart by position, not by type: the +// first optional arg is always the price margin, the second is always the +// community. Positional parsing keeps numeric community identifiers (e.g. a +// Telegram group id) from being mistaken for a price margin, and makes any +// leftover non-numeric token fail the price-margin check instead of being +// silently swallowed as a community. const parseOptionalOrderParams = (optionalArgs: string[]) => { // priceMargin is kept loosely typed because downstream callers feed it // through isFloat/parseInt/isNaN, matching the previous untyped behaviour. - let priceMargin: any; - let communityName: string | undefined; - - for (const arg of optionalArgs) { - if (arg === '') continue; - if (isNaN(Number(arg))) { - communityName = arg; - } else { - priceMargin = arg; - } - } + const priceMargin: any = optionalArgs[0] === '' ? undefined : optionalArgs[0]; + const communityName: string | undefined = + optionalArgs[1] === '' ? undefined : optionalArgs[1]; return { priceMargin, communityName }; }; diff --git a/locales/de.yaml b/locales/de.yaml index 58f3cd1a..1eff9c6e 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -73,7 +73,7 @@ fiat_amount: fiat Wert sell_correct_format: | /sell \<_Anzahl sats_\> \<_fiat Wert_\> \<_fiat code_\> \<_Zahlungsmethode_\> \[_Zuschlag/Rabatt_\] \[_Community_\] - Du kannst den Namen einer deiner Communities als letzten Parameter hinzufügen, um die Order dort statt in deiner Standard\-Community zu veröffentlichen\. + Du kannst den Namen einer deiner Communities als letzten Parameter hinzufügen, um die Order dort statt in deiner Standard\-Community zu veröffentlichen\. Du musst den Zuschlag/Rabatt davor angeben \(nutze 0, wenn du keinen möchtest\)\. Um einen Verkaufsauftrag von 1000 Sats für 2 Euro \(EUR\) zu erstellen und anzugeben, dass die Zahlung in FIAT durch Überweisung oder Einzahlung am Geldautomaten erfolgt, musst du \<\> und \[\] weglassen\. @@ -89,7 +89,7 @@ sell_correct_format: | buy_correct_format: | /buy \<_Anzahl sats_\> \<_fiat Wert_\> \<_fiat code_\> \<_Zahlungsmethode_\> \[_Zuschlag/Rabatt_\] \[_Community_\] - Du kannst den Namen einer deiner Communities als letzten Parameter hinzufügen, um die Order dort statt in deiner Standard\-Community zu veröffentlichen\. + Du kannst den Namen einer deiner Communities als letzten Parameter hinzufügen, um die Order dort statt in deiner Standard\-Community zu veröffentlichen\. Du musst den Zuschlag/Rabatt davor angeben \(nutze 0, wenn du keinen möchtest\)\. Um einen Auftrag über 1000 Sats für 2 \(EUR\) zu erstellen und anzugeben, dass die Zahlung per Überweisung erfolgt, musst du sowohl \<\> als auch \[\] weglassen\. diff --git a/locales/en.yaml b/locales/en.yaml index 6680f6d5..f0e22937 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -75,7 +75,7 @@ fiat_amount: fiat amount sell_correct_format: | /sell \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_community_\] - You can add the name of one of your communities as the last parameter to publish the order there instead of your default community\. + You can add the name of one of your communities as the last parameter to publish the order there instead of your default community\. You must include the premium/discount before it \(use 0 if you don't want any\)\. In order to create a sell order of 1000 satoshis for 2 US dollars \(USD\) and indicate that the fiat payment is through transfer or ATM deposit, you must avoid \<\> and \[\]\. @@ -91,7 +91,7 @@ sell_correct_format: | buy_correct_format: | /buy \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_community_\] - You can add the name of one of your communities as the last parameter to publish the order there instead of your default community\. + You can add the name of one of your communities as the last parameter to publish the order there instead of your default community\. You must include the premium/discount before it \(use 0 if you don't want any\)\. In order to create an order for 1000 satoshis for 2 \(USD\) and indicate that fiat payment is through transfer, you must to omit both \<\> and \[\]\. diff --git a/locales/es.yaml b/locales/es.yaml index 54c0714c..69896877 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -73,7 +73,7 @@ fiat_amount: monto en fiat sell_correct_format: | /sell \<_monto en sats_\> \<_monto en fiat_\> \<_código fiat_\> \<_método de pago_\> \[_prima/descuento_\] \[_comunidad_\] - Puedes agregar el nombre de una de tus comunidades como último parámetro para publicar la orden ahí en lugar de en tu comunidad por defecto\. + Puedes agregar el nombre de una de tus comunidades como último parámetro para publicar la orden ahí en lugar de en tu comunidad por defecto\. Debes incluir la prima/descuento antes \(usa 0 si no quieres ninguno\)\. Para crear una venta de 1000 satoshis por 2 bolívares \(VES\) e indicar que el método de pago fiat es pago móvil, debes omitir los \<\> y los \[\]\. @@ -89,7 +89,7 @@ sell_correct_format: | buy_correct_format: | /buy \<_monto en sats_\> \<_monto en fiat_\> \<_código fiat_\> \<_método de pago_\> \[_prima/descuento_\] \[_comunidad_\] - Puedes agregar el nombre de una de tus comunidades como último parámetro para publicar la orden ahí en lugar de en tu comunidad por defecto\. + Puedes agregar el nombre de una de tus comunidades como último parámetro para publicar la orden ahí en lugar de en tu comunidad por defecto\. Debes incluir la prima/descuento antes \(usa 0 si no quieres ninguno\)\. Para crear una compra de 1000 satoshis por 2 bolívares \(VES\) e indicar que el método de pago fiat es pago móvil, debes omitir los \<\> y los \[\]\. diff --git a/locales/fa.yaml b/locales/fa.yaml index f94fc25b..09bf4eed 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -76,7 +76,7 @@ fiat_amount: مقدار فیات sell_correct_format: | /sell \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_community_\] - می‌توانید نام یکی از اجتماع‌های خود را به عنوان آخرین پارامتر اضافه کنید تا سفارش به جای اجتماع پیش‌فرض شما در آنجا منتشر شود\. + می‌توانید نام یکی از اجتماع‌های خود را به عنوان آخرین پارامتر اضافه کنید تا سفارش به جای اجتماع پیش‌فرض شما در آنجا منتشر شود\. باید پیش از آن، حباب/تخفیف را وارد کنید \(اگر نمی‌خواهید 0 بگذارید\)\. جهت ایجاد سفارش فروش 1000 ساتوشی در ازای 300 هزار تومان \(IRT\) و اعلام «کارت به کارت» به عنوان روش پرداخت، باید از به کار بردن \<\> و \[\]\ اجتناب کنید\. @@ -92,7 +92,7 @@ sell_correct_format: | buy_correct_format: | /buy \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_community_\] - می‌توانید نام یکی از اجتماع‌های خود را به عنوان آخرین پارامتر اضافه کنید تا سفارش به جای اجتماع پیش‌فرض شما در آنجا منتشر شود\. + می‌توانید نام یکی از اجتماع‌های خود را به عنوان آخرین پارامتر اضافه کنید تا سفارش به جای اجتماع پیش‌فرض شما در آنجا منتشر شود\. باید پیش از آن، حباب/تخفیف را وارد کنید \(اگر نمی‌خواهید 0 بگذارید\)\. جهت ایجاد سفارش خرید 1000 ساتوشی در ازای 300 هزار تومان \(IRT\) و اعلام «کارت به کارت» به عنوان روش پرداخت، باید از به کار بردن \<\> و \[\]\ اجتناب کنید\. diff --git a/locales/fr.yaml b/locales/fr.yaml index 9b11b21e..b201262a 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -75,7 +75,7 @@ fiat_amount: montant en fiat sell_correct_format: | /sell \<_montant en sats_\> \<_montant en fiat_\> \<_code monnaie fiat_\> \<_méthode de paiement_\> \[_plus-value/remise_\] \[_communauté_\] - Vous pouvez ajouter le nom d'une de vos communautés comme dernier paramètre pour y publier l'ordre au lieu de votre communauté par défaut\. + Vous pouvez ajouter le nom d'une de vos communautés comme dernier paramètre pour y publier l'ordre au lieu de votre communauté par défaut\. Vous devez indiquer la plus\-value/remise avant \(utilisez 0 si vous n'en voulez pas\)\. Pour créer une offre de vente de 1000 satoshis pour 6000 Francs CFA \(XOF\) et indiquer que le paiement fiat doit se faire via Wave ou Mobile Money, il faut omettre les caractères \<\> and \[\]\. @@ -91,7 +91,7 @@ sell_correct_format: | buy_correct_format: | /buy \<_montant en sats_\> \<_montant en fiat_\> \<_code monnaie fiat_\> \<_méthode de paiement_\> \[_plus-value/remise_\] \[_communauté_\] - Vous pouvez ajouter le nom d'une de vos communautés comme dernier paramètre pour y publier l'ordre au lieu de votre communauté par défaut\. + Vous pouvez ajouter le nom d'une de vos communautés comme dernier paramètre pour y publier l'ordre au lieu de votre communauté par défaut\. Vous devez indiquer la plus\-value/remise avant \(utilisez 0 si vous n'en voulez pas\)\. Pour créer une offre d'achat de 1000 satoshis pour 6000 Francs CFA \(XOF\) et indiquer que le paiement fiat doit se faire via transfert en ligne, il faut omettre les caractères \<\> and \[\]\. diff --git a/locales/it.yaml b/locales/it.yaml index deb5a886..f50496b9 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -73,7 +73,7 @@ fiat_amount: importo in fiat sell_correct_format: | /sell \<_importo in sats_\> \<_importo in fiat_\> \<_fiat code_\> \<_metodo di pagamento_\> \[_surplus/sconto_\] \[_community_\] - Puoi aggiungere il nome di una delle tue community come ultimo parametro per pubblicare l'ordine lì invece che nella tua community predefinita\. + Puoi aggiungere il nome di una delle tue community come ultimo parametro per pubblicare l'ordine lì invece che nella tua community predefinita\. Devi indicare il surplus/sconto prima \(usa 0 se non ne vuoi\)\. Per creare un ordine di vendita di 1000 satoshi per 2 euro \(EUR\) e indicare che il pagamento in fiat avviene tramite bonifico o deposito ATM, occorre evitare di \<\> e \[\]\. @@ -89,7 +89,7 @@ sell_correct_format: | buy_correct_format: | /buy \<_importo in sats_\> \<_importo in fiat_\> \<_fiat code_\> \<_metodo di pagamento_\> \[_surplus/discount_\] \[_community_\] - Puoi aggiungere il nome di una delle tue community come ultimo parametro per pubblicare l'ordine lì invece che nella tua community predefinita\. + Puoi aggiungere il nome di una delle tue community come ultimo parametro per pubblicare l'ordine lì invece che nella tua community predefinita\. Devi indicare il surplus/sconto prima \(usa 0 se non ne vuoi\)\. Per creare un ordine di 1000 satoshi per 2 euro \(EUR\) e indicare che il pagamento in fiat avviene tramite bonifico, è necessario omettere entrambe le diciture \<\> e \[\]\. diff --git a/locales/ko.yaml b/locales/ko.yaml index 9e0388c3..3a2a1891 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -74,7 +74,7 @@ fiat_amount: 법정화폐 액수 sell_correct_format: | /sell \<_비트코인 액수_\> \<_법정화폐 액수_\> \<_거래 통화_\> \<_결제 방법_\> \[_프리미엄/할인_\] \[_커뮤니티_\] - 기본 커뮤니티 대신 특정 커뮤니티에 주문을 게시하려면 마지막 매개변수로 커뮤니티 이름을 추가할 수 있습니다\. + 기본 커뮤니티 대신 특정 커뮤니티에 주문을 게시하려면 마지막 매개변수로 커뮤니티 이름을 추가할 수 있습니다\. 그 앞에 프리미엄/할인을 반드시 포함해야 합니다 \(원하지 않으면 0을 사용하세요\)\. 1000 \(KRW\_)에 500 사토시를 판매하는 주문을 생성하고 법정화폐 결제 방법을 명시할 때, 위의 예시에서 \<\>와 \[\]는 빼는 것을 잊지 마세요. @@ -90,7 +90,7 @@ sell_correct_format: | buy_correct_format: | /buy \<_비트코인 액수_\> \<_법정화폐 액수_\> \<_거래 통화_\> \<_결제 방법_\> \[_프리미엄/할인_\] \[_커뮤니티_\] - 기본 커뮤니티 대신 특정 커뮤니티에 주문을 게시하려면 마지막 매개변수로 커뮤니티 이름을 추가할 수 있습니다\. + 기본 커뮤니티 대신 특정 커뮤니티에 주문을 게시하려면 마지막 매개변수로 커뮤니티 이름을 추가할 수 있습니다\. 그 앞에 프리미엄/할인을 반드시 포함해야 합니다 \(원하지 않으면 0을 사용하세요\)\. 500 사토시를 1000 \(KRW\)에 구매하는 주문을 생성하고 법정화폐 결제 방법을 명시할 때, 위의 예시에서 \<\>와 \[\]는 빼는 것을 잊지 마세요. diff --git a/locales/pt.yaml b/locales/pt.yaml index b5c6602c..e15a87d6 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -74,7 +74,7 @@ fiat_amount: quantidade fiduciária sell_correct_format: | /sell \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_comunidade_\] - Você pode adicionar o nome de uma de suas comunidades como último parâmetro para publicar a ordem lá em vez de na sua comunidade padrão\. + Você pode adicionar o nome de uma de suas comunidades como último parâmetro para publicar a ordem lá em vez de na sua comunidade padrão\. Você deve incluir o premium/desconto antes \(use 0 se não quiser nenhum\)\. Para criar uma oferta de venda de 1000 satoshis por 2 dólares americanos \(USD\) e indicar que o pagamento fiduciário é por transferência ou depósito no caixa eletrônico, você deve evitar \<\> e \[\]\. @@ -90,7 +90,7 @@ sell_correct_format: | buy_correct_format: | /buy \<_sats amount_\> \<_fiat amount_\> \<_fiat code_\> \<_payment method_\> \[_premium/discount_\] \[_comunidade_\] - Você pode adicionar o nome de uma de suas comunidades como último parâmetro para publicar a ordem lá em vez de na sua comunidade padrão\. + Você pode adicionar o nome de uma de suas comunidades como último parâmetro para publicar a ordem lá em vez de na sua comunidade padrão\. Você deve incluir o premium/desconto antes \(use 0 se não quiser nenhum\)\. Para criar uma oferta e pedir 1000 Satoshis por 2 \(USD\) e indicar que o pagamento fiduciário é através de transferência, você deve omitir os dois \<\> e \[\]\. diff --git a/locales/ru.yaml b/locales/ru.yaml index d411f9bd..2b472f9a 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -71,7 +71,7 @@ fiat_amount: сумма в валюте sell_correct_format: | /sell \<_сумма в сатоши_\> \<_сумма в валюте_\> \<_код валюты_\> \<_метод платежа_\> \[_премия/дисконт_\] \[_сообщество_\] - Вы можете добавить название одного из ваших сообществ последним параметром, чтобы опубликовать ордер там вместо вашего сообщества по умолчанию\. + Вы можете добавить название одного из ваших сообществ последним параметром, чтобы опубликовать ордер там вместо вашего сообщества по умолчанию\. Перед ним необходимо указать премию/дисконт \(используйте 0, если он не нужен\)\. Чтобы создать заявку на продажу 1000 сатоши за 50 рублей \(RUB\) с оплатой по номеру телефона: @@ -87,7 +87,7 @@ sell_correct_format: | buy_correct_format: | /buy \<_сумма в сатоши_\> \<_сумма в валюте_\> \<_код валюты_\> \<_метод платежа_\> \[_премия/дисконт_\] \[_сообщество_\] - Вы можете добавить название одного из ваших сообществ последним параметром, чтобы опубликовать ордер там вместо вашего сообщества по умолчанию\. + Вы можете добавить название одного из ваших сообществ последним параметром, чтобы опубликовать ордер там вместо вашего сообщества по умолчанию\. Перед ним необходимо указать премию/дисконт \(используйте 0, если он не нужен\)\. Чтобы создать заявку на покупку 1000 сатоши за 50 рублей \(RUB\) с оплатой по номеру телефона: diff --git a/locales/uk.yaml b/locales/uk.yaml index 6c6becee..288b945c 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -72,7 +72,7 @@ fiat_amount: сума у валюті sell_correct_format: | /sell \<_сума у сатоші_\> \<_сума у валюті_\> \<_код валюти_\> \<_метод платежу\> \[_премія/дисконт_\] \[_спільнота_\] - Ви можете додати назву однієї зі своїх спільнот останнім параметром, щоб опублікувати ордер там замість вашої спільноти за замовчуванням\. + Ви можете додати назву однієї зі своїх спільнот останнім параметром, щоб опублікувати ордер там замість вашої спільноти за замовчуванням\. Перед ним потрібно вказати премію/дисконт \(використовуйте 0, якщо він не потрібен\)\. Щоб створити заявку на продаж 1000 сатоші за 50 гривень (UAH) з оплатою за номером телефону: @@ -88,7 +88,7 @@ sell_correct_format: | buy_correct_format: | /buy \<_сума у сатоші_\> \<_сума у валюті_\> \<_код валюти_\> \<_метод платежу_\> \[_премія/дисконт_\] \[_спільнота_\] - Ви можете додати назву однієї зі своїх спільнот останнім параметром, щоб опублікувати ордер там замість вашої спільноти за замовчуванням\. + Ви можете додати назву однієї зі своїх спільнот останнім параметром, щоб опублікувати ордер там замість вашої спільноти за замовчуванням\. Перед ним потрібно вказати премію/дисконт \(використовуйте 0, якщо він не потрібен\)\. Щоб створити заявку на купівлю 1000 сатоші за 50 гривень (UAH) з оплатою за номером телефону: diff --git a/tests/bot/validation.spec.ts b/tests/bot/validation.spec.ts index 7a463e11..80ad4907 100644 --- a/tests/bot/validation.spec.ts +++ b/tests/bot/validation.spec.ts @@ -156,12 +156,26 @@ describe('Validations', () => { expect(result.priceMargin).to.equal('5'); }); - it('should treat a non-numeric trailing param as the community name', async () => { + it('should reject a non-numeric price margin (positional)', async () => { ctx.state.command.args = ['10000', '100', 'USD', 'zelle', 'mycommunity']; const result = await validateSellOrder(ctx); + expect(result).to.equal(false); + expect(replyStub.calledOnce).to.equal(true); + }); + + it('should keep numeric community identifiers as the community', async () => { + ctx.state.command.args = [ + '10000', + '100', + 'USD', + 'zelle', + '5', + '-1001234567890', + ]; + const result = await validateSellOrder(ctx); if (result === false) throw new Error('object expected'); - expect(result.communityName).to.equal('mycommunity'); - expect(result.priceMargin).to.equal(undefined); + expect(result.priceMargin).to.equal('5'); + expect(result.communityName).to.equal('-1001234567890'); }); it('should parse both price margin and community name', async () => { @@ -239,12 +253,26 @@ describe('Validations', () => { expect(result.priceMargin).to.equal('5'); }); - it('should treat a non-numeric trailing param as the community name', async () => { + it('should reject a non-numeric price margin (positional)', async () => { ctx.state.command.args = ['10000', '100', 'USD', 'zelle', 'mycommunity']; const result = await validateBuyOrder(ctx); + expect(result).to.equal(false); + expect(replyStub.calledOnce).to.equal(true); + }); + + it('should keep numeric community identifiers as the community', async () => { + ctx.state.command.args = [ + '10000', + '100', + 'USD', + 'zelle', + '5', + '-1001234567890', + ]; + const result = await validateBuyOrder(ctx); if (result === false) throw new Error('object expected'); - expect(result.communityName).to.equal('mycommunity'); - expect(result.priceMargin).to.equal(undefined); + expect(result.priceMargin).to.equal('5'); + expect(result.communityName).to.equal('-1001234567890'); }); it('should parse both price margin and community name', async () => { diff --git a/tests/util/communityHelper.spec.ts b/tests/util/communityHelper.spec.ts new file mode 100644 index 00000000..2bd07012 --- /dev/null +++ b/tests/util/communityHelper.spec.ts @@ -0,0 +1,67 @@ +export {}; + +const { expect } = require('chai'); +const sinon = require('sinon'); +const { Community } = require('../../models'); +const { getCommunityByIdentifier } = require('../../util/communityHelper'); + +/** + * Routing tests for getCommunityByIdentifier. They assert the resolved + * community_id (not just parsed arguments) and, most importantly, the query + * shape used for the display-name fallback: + * - the name is matched exactly (case-sensitive), so "Foo" and "foo" cannot be + * confused now that name has only a case-sensitive unique index; + * - the name fallback is restricted to public communities, so a private + * community can only be reached through its canonical group identifier. + */ +describe('getCommunityByIdentifier routing', () => { + const user: any = { _id: 'user-1' }; + + afterEach(() => sinon.restore()); + + it('resolves by canonical group id (case-insensitive) for private communities', async () => { + const community = { + _id: 'community-private', + public: false, + banned_users: [], + }; + const findOne = sinon.stub(Community, 'findOne'); + findOne.onFirstCall().resolves(community); + + const result = await getCommunityByIdentifier(user, '-1001234567890'); + + expect(String(result.communityId)).to.equal('community-private'); + const groupQuery = findOne.firstCall.args[0]; + expect(groupQuery.group).to.be.an.instanceOf(RegExp); + expect(groupQuery.group.flags).to.contain('i'); + }); + + it('matches the display name exactly and only among public communities', async () => { + const community = { _id: 'community-foo', public: true, banned_users: [] }; + const findOne = sinon.stub(Community, 'findOne'); + findOne.onFirstCall().resolves(null); // no group match + findOne.onSecondCall().resolves(community); // name match + + const result = await getCommunityByIdentifier(user, 'foo'); + + expect(String(result.communityId)).to.equal('community-foo'); + const nameQuery = findOne.secondCall.args[0]; + // case-sensitive exact string, not a case-insensitive regex + expect(nameQuery.name).to.equal('foo'); + expect(nameQuery.name).to.not.be.an.instanceOf(RegExp); + // never falls back into private communities by name + expect(nameQuery.public).to.equal(true); + }); + + it('does not resolve a private community by its display name', async () => { + const findOne = sinon.stub(Community, 'findOne'); + findOne.onFirstCall().resolves(null); // no group match + // name fallback filters on public: true, so a private one is not returned + findOne.onSecondCall().resolves(null); + + const result = await getCommunityByIdentifier(user, 'PrivateName'); + + expect(result.community).to.equal(null); + expect(result.communityId).to.equal(undefined); + }); +}); diff --git a/util/communityHelper.ts b/util/communityHelper.ts index 53cdcee1..3c9f7db7 100644 --- a/util/communityHelper.ts +++ b/util/communityHelper.ts @@ -73,10 +73,14 @@ export const getCommunityInfo = async ( }; /** - * Look up an enabled community by the identifier the user typed, ignoring - * uppercase/lowercase. It first tries the community group (the @handle or - * telegram group id, the same identifier used by /setcomm) and falls back to - * the display name. Also reports whether the given user is banned from it. + * Look up an enabled community by the identifier the user typed. It first tries + * the community group (the @handle or telegram group id, the same identifier + * used by /setcomm), matched ignoring uppercase/lowercase since the group is + * stored lowercase and unique. It then falls back to the display name, matched + * exactly (case-sensitive) and restricted to public communities: the name is + * only case-sensitively unique, so an exact match stays unambiguous, and + * private communities can only be reached through their canonical group id. + * Also reports whether the given user is banned from it. */ export const getCommunityByIdentifier = async ( user: UserDocument, @@ -85,11 +89,18 @@ export const getCommunityByIdentifier = async ( try { // Escape regex metacharacters so the identifier is matched literally const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(`^${escaped}$`, 'i'); + const groupRegex = new RegExp(`^${escaped}$`, 'i'); const community = - (await Community.findOne({ group: regex, enabled: { $ne: false } })) || - (await Community.findOne({ name: regex, enabled: { $ne: false } })); + (await Community.findOne({ + group: groupRegex, + enabled: { $ne: false }, + })) || + (await Community.findOne({ + name: identifier, + public: true, + enabled: { $ne: false }, + })); if (!community) { return { community: null, communityId: undefined, isBanned: false }; From c8371680779bfdd4809dd1fe46eeb9264fba72b4 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Thu, 23 Jul 2026 16:12:45 -0300 Subject: [PATCH 3/6] test: cover /buy and /sell community routing on the command path --- tests/bot/orders-commands.spec.ts | 228 ++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 tests/bot/orders-commands.spec.ts diff --git a/tests/bot/orders-commands.spec.ts b/tests/bot/orders-commands.spec.ts new file mode 100644 index 00000000..916b8c33 --- /dev/null +++ b/tests/bot/orders-commands.spec.ts @@ -0,0 +1,228 @@ +export {}; + +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { Order } = require('../../models'); +const validations = require('../../bot/validations'); +const ordersActions = require('../../bot/ordersActions'); +const messages = require('../../bot/messages'); +const communityHelper = require('../../util/communityHelper'); +const { buy, sell } = require('../../bot/modules/orders/commands'); + +/** + * Command-path integration tests for /buy and /sell. + * + * These exercise the whole handler through resolveOrderCommunity and assert the + * community_id actually handed to createOrder. That is the integration where the + * previous private-vs-group routing bug lived: the helper-only tests would pass + * even with that bug present, because they never run the command handler. + */ +describe('/buy and /sell community routing (command path)', () => { + const user: any = { _id: 'user-1', default_community_id: undefined }; + + let createOrder: any; + + const buildCtx = (chatType: string, params: any) => { + const ctx: any = { + user, + i18n: { t: (k: string) => k }, + message: { chat: { type: chatType, id: -1001234567890 } }, + reply: sinon.stub().resolves(), + deleteMessage: sinon.stub().resolves(), + }; + // validateBuyOrder / validateSellOrder return the parsed params + validations.validateBuyOrder.resolves(params); + validations.validateSellOrder.resolves(params); + return ctx; + }; + + beforeEach(() => { + // isMaxPending -> not maxed out + sinon.stub(Order, 'countDocuments').resolves(0); + process.env.MAX_PENDING_ORDERS = process.env.MAX_PENDING_ORDERS || '5'; + + // Sellers are always allowed to continue + sinon.stub(validations, 'validateSeller').resolves(true); + sinon.stub(validations, 'validateBuyOrder'); + sinon.stub(validations, 'validateSellOrder'); + + // Currency supported by default; overridden per-test when needed + sinon.stub(communityHelper, 'isCurrencySupported').returns(true); + sinon.stub(communityHelper, 'getCommunityByIdentifier'); + sinon.stub(communityHelper, 'getCommunityInfo'); + + // Capture the payload sent to createOrder and return a truthy order + createOrder = sinon + .stub(ordersActions, 'createOrder') + .resolves({ _id: 'order-1' }); + + // Silence publishing / error messages + sinon.stub(messages, 'publishBuyOrderMessage').resolves(); + sinon.stub(messages, 'publishSellOrderMessage').resolves(); + sinon.stub(messages, 'bannedUserErrorMessage').resolves(); + sinon.stub(messages, 'currencyNotSupportedMessage').resolves(); + }); + + afterEach(() => sinon.restore()); + + const baseParams = { + amount: 1000, + fiatAmount: [100], + fiatCode: 'USD', + paymentMethod: 'cash', + priceMargin: 0, + }; + + it('routes /buy to an explicit community by numeric group id in a group chat', async () => { + const community = { _id: 'community-A', public: true, banned_users: [] }; + communityHelper.getCommunityByIdentifier.resolves({ + community, + communityId: 'community-A', + isBanned: false, + }); + + const ctx = buildCtx('supergroup', { + ...baseParams, + communityName: '-1009999999999', + }); + await buy(ctx); + + expect(communityHelper.getCommunityByIdentifier.calledOnce).to.equal(true); + expect(communityHelper.getCommunityByIdentifier.firstCall.args[1]).to.equal( + '-1009999999999', + ); + expect(createOrder.calledOnce).to.equal(true); + expect(createOrder.firstCall.args[3].community_id).to.equal('community-A'); + expect(createOrder.firstCall.args[3].type).to.equal('buy'); + }); + + it('explicit community takes precedence over the group community for /sell', async () => { + const explicit = { + _id: 'community-explicit', + public: true, + banned_users: [], + }; + communityHelper.getCommunityByIdentifier.resolves({ + community: explicit, + communityId: 'community-explicit', + isBanned: false, + }); + + const ctx = buildCtx('supergroup', { + ...baseParams, + communityName: 'explicitName', + }); + await sell(ctx); + + // The explicit destination wins; the group fallback is never consulted + expect(communityHelper.getCommunityInfo.called).to.equal(false); + expect(createOrder.firstCall.args[3].community_id).to.equal( + 'community-explicit', + ); + }); + + it('falls back to the group community when no explicit community is passed', async () => { + const groupCommunity = { + _id: 'community-group', + public: true, + banned_users: [], + }; + communityHelper.getCommunityInfo.resolves({ + community: groupCommunity, + communityId: 'community-group', + isBanned: false, + }); + + const ctx = buildCtx('supergroup', { + ...baseParams, + communityName: undefined, + }); + await buy(ctx); + + expect(communityHelper.getCommunityByIdentifier.called).to.equal(false); + expect(communityHelper.getCommunityInfo.calledOnce).to.equal(true); + expect(createOrder.firstCall.args[3].community_id).to.equal( + 'community-group', + ); + }); + + it('publishes to the explicit community from a private chat', async () => { + const community = { + _id: 'community-priv', + public: false, + banned_users: [], + }; + communityHelper.getCommunityByIdentifier.resolves({ + community, + communityId: 'community-priv', + isBanned: false, + }); + + const ctx = buildCtx('private', { + ...baseParams, + communityName: '-1001234567890', + }); + await buy(ctx); + + expect(createOrder.firstCall.args[3].community_id).to.equal( + 'community-priv', + ); + }); + + it('rejects and does not create an order when the user is banned', async () => { + const community = { _id: 'community-A', public: true, banned_users: [] }; + communityHelper.getCommunityByIdentifier.resolves({ + community, + communityId: 'community-A', + isBanned: true, + }); + + const ctx = buildCtx('private', { + ...baseParams, + communityName: 'someName', + }); + await sell(ctx); + + expect(messages.bannedUserErrorMessage.calledOnce).to.equal(true); + expect(createOrder.called).to.equal(false); + }); + + it('rejects and does not create an order when the currency is unsupported', async () => { + const community = { + _id: 'community-A', + public: true, + banned_users: [], + currencies: ['EUR'], + }; + communityHelper.getCommunityByIdentifier.resolves({ + community, + communityId: 'community-A', + isBanned: false, + }); + communityHelper.isCurrencySupported.returns(false); + + const ctx = buildCtx('private', { + ...baseParams, + communityName: 'someName', + }); + await buy(ctx); + + expect(messages.currencyNotSupportedMessage.calledOnce).to.equal(true); + expect(createOrder.called).to.equal(false); + }); + + it('stops without creating an order when an explicit community is not found', async () => { + communityHelper.getCommunityByIdentifier.resolves({ + community: null, + communityId: undefined, + isBanned: false, + }); + + const ctx = buildCtx('private', { ...baseParams, communityName: 'ghost' }); + await buy(ctx); + + expect(ctx.reply.calledWith('community_not_found')).to.equal(true); + expect(createOrder.called).to.equal(false); + }); +}); From 5270075e9e5c4e6bc2994b43493037804a6d48d4 Mon Sep 17 00:00:00 2001 From: lucas Date: Mon, 27 Jul 2026 08:21:30 -0300 Subject: [PATCH 4/6] Add test coverage and handle community_not_found inside groups --- bot/modules/orders/commands.ts | 6 ++- .../orders/commands.spec.ts} | 39 +++++++++++++------ 2 files changed, 32 insertions(+), 13 deletions(-) rename tests/bot/{orders-commands.spec.ts => modules/orders/commands.spec.ts} (85%) diff --git a/bot/modules/orders/commands.ts b/bot/modules/orders/commands.ts index 7b55c8be..b36b6484 100644 --- a/bot/modules/orders/commands.ts +++ b/bot/modules/orders/commands.ts @@ -194,7 +194,11 @@ async function resolveOrderCommunity( if (communityName) { const communityInfo = await getCommunityByIdentifier(user, communityName); if (!communityInfo.community) { - await ctx.reply(ctx.i18n.t('community_not_found')); + if ((ctx.message?.chat.type || 'private') !== 'private') { + await ctx.deleteMessage(); + } else { + await ctx.reply(ctx.i18n.t('community_not_found')); + } return null; } return communityInfo; diff --git a/tests/bot/orders-commands.spec.ts b/tests/bot/modules/orders/commands.spec.ts similarity index 85% rename from tests/bot/orders-commands.spec.ts rename to tests/bot/modules/orders/commands.spec.ts index 916b8c33..2c0d77a5 100644 --- a/tests/bot/orders-commands.spec.ts +++ b/tests/bot/modules/orders/commands.spec.ts @@ -3,12 +3,12 @@ export {}; const { expect } = require('chai'); const sinon = require('sinon'); -const { Order } = require('../../models'); -const validations = require('../../bot/validations'); -const ordersActions = require('../../bot/ordersActions'); -const messages = require('../../bot/messages'); -const communityHelper = require('../../util/communityHelper'); -const { buy, sell } = require('../../bot/modules/orders/commands'); +const { Order } = require('../../../../models'); +const validations = require('../../../../bot/validations'); +const ordersActions = require('../../../../bot/ordersActions'); +const messages = require('../../../../bot/messages'); +const communityHelper = require('../../../../util/communityHelper'); +const { buy, sell } = require('../../../../bot/modules/orders/commands'); /** * Command-path integration tests for /buy and /sell. @@ -117,9 +117,9 @@ describe('/buy and /sell community routing (command path)', () => { // The explicit destination wins; the group fallback is never consulted expect(communityHelper.getCommunityInfo.called).to.equal(false); - expect(createOrder.firstCall.args[3].community_id).to.equal( - 'community-explicit', - ); + expect(createOrder.calledOnce).to.equal(true); + expect(createOrder.firstCall.args[3].community_id).to.equal('community-explicit'); + expect(createOrder.firstCall.args[3].type).to.equal('sell'); }); it('falls back to the group community when no explicit community is passed', async () => { @@ -165,9 +165,9 @@ describe('/buy and /sell community routing (command path)', () => { }); await buy(ctx); - expect(createOrder.firstCall.args[3].community_id).to.equal( - 'community-priv', - ); + expect(createOrder.calledOnce).to.equal(true); + expect(createOrder.firstCall.args[3].community_id).to.equal('community-priv'); + expect(createOrder.firstCall.args[3].type).to.equal('buy'); }); it('rejects and does not create an order when the user is banned', async () => { @@ -225,4 +225,19 @@ describe('/buy and /sell community routing (command path)', () => { expect(ctx.reply.calledWith('community_not_found')).to.equal(true); expect(createOrder.called).to.equal(false); }); + + it('deletes the command and does not reply when an explicit community is not found in a group chat', async () => { + communityHelper.getCommunityByIdentifier.resolves({ + community: null, + communityId: undefined, + isBanned: false, + }); + + const ctx = buildCtx('supergroup', { ...baseParams, communityName: 'ghost' }); + await buy(ctx); + + expect(ctx.deleteMessage.calledOnce).to.equal(true); + expect(ctx.reply.called).to.equal(false); + expect(createOrder.called).to.equal(false); + }); }); From 064695d4d3e151a192c54ec9c99885780168cf1a Mon Sep 17 00:00:00 2001 From: lucas Date: Mon, 27 Jul 2026 20:54:33 -0300 Subject: [PATCH 5/6] Fix CI, locales and validate number of parameters in validateSellOrder and validateBuyOrder --- bot/validations.ts | 16 ++++++++++ locales/it.yaml | 4 +-- locales/pt.yaml | 4 +-- locales/uk.yaml | 2 +- tests/bot/modules/orders/commands.spec.ts | 13 ++++++-- tests/bot/validation.spec.ts | 36 +++++++++++++++++++++++ 6 files changed, 67 insertions(+), 8 deletions(-) diff --git a/bot/validations.ts b/bot/validations.ts index 4636ae69..55a3726c 100644 --- a/bot/validations.ts +++ b/bot/validations.ts @@ -187,6 +187,14 @@ const validateSellOrder = async (ctx: MainContext) => { } args = processParameters(args); + // Only two optional trailing params exist (price margin and community), so + // a third token means the command is malformed. Rejecting it here keeps a + // typo from being silently dropped and the order published anyway. + if (args.length > 6) { + await messages.sellOrderCorrectFormatMessage(ctx); + return false; + } + let [amount, fiatAmount, fiatCode, paymentMethod] = args; const { priceMargin, communityName } = parseOptionalOrderParams( args.slice(4), @@ -281,6 +289,14 @@ const validateBuyOrder = async (ctx: MainContext) => { } args = processParameters(args); + // Only two optional trailing params exist (price margin and community), so + // a third token means the command is malformed. Rejecting it here keeps a + // typo from being silently dropped and the order published anyway. + if (args.length > 6) { + await messages.buyOrderCorrectFormatMessage(ctx); + return false; + } + let [amount, fiatAmount, fiatCode, paymentMethod] = args; const { priceMargin, communityName } = parseOptionalOrderParams( args.slice(4), diff --git a/locales/it.yaml b/locales/it.yaml index f50496b9..1b83d979 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -87,7 +87,7 @@ sell_correct_format: | `/sell 0 100\-500 EUR "metodo di pagamento" 3` buy_correct_format: | - /buy \<_importo in sats_\> \<_importo in fiat_\> \<_fiat code_\> \<_metodo di pagamento_\> \[_surplus/discount_\] \[_community_\] + /buy \<_importo in sats_\> \<_importo in fiat_\> \<_fiat code_\> \<_metodo di pagamento_\> \[_surplus/sconto_\] \[_community_\] Puoi aggiungere il nome di una delle tue community come ultimo parametro per pubblicare l'ordine lì invece che nella tua community predefinita\. Devi indicare il surplus/sconto prima \(usa 0 se non ne vuoi\)\. @@ -246,7 +246,7 @@ invalid_lightning_address: Invalid lightning adress unavailable_lightning_address: Lightning adress non disponibile ${la} help: | /sell <_importo in satoshi_> <_importo in fiat_> <_fiat code_> <_metodo di pagamento_> [surplus/sconto] [community] - Crea un ordine di vendita - /buy <_importo in satoshi_> <_importo in fiat_> <_Fiat code_> <_metodo di pagamento_> [premium/discount] [community] - Crea un ordine di acquisto + /buy <_importo in satoshi_> <_importo in fiat_> <_Fiat code_> <_metodo di pagamento_> [surplus/sconto] [community] - Crea un ordine di acquisto /takeorder <_order id_> - Permette di prendere un ordine dalla chat con il bot senza passare al canale dove è stato pubblicato /info - Mostra indo aggiuntive sul Bot /showusername - Disattiva la visualizzazione del nome utente in ogni nuovo ordine creato. Il valore predefinito è settato su no (false) diff --git a/locales/pt.yaml b/locales/pt.yaml index e15a87d6..3b33452c 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -246,8 +246,8 @@ must_be_number_or_range: 'Fiat_amount deve ser um número ou intervalo numérico invalid_lightning_address: Inválida lightning address unavailable_lightning_address: Indisponível lightning address ${la} help: | - /sell premium/desconto comunidade - Criar uma ordem de venda - /buy premium/desconto comunidade - Cria um pedido de compra + /sell [premium/desconto] [comunidade] - Criar uma ordem de venda + /buy [premium/desconto] [comunidade] - Cria um pedido de compra /takeorder <_order id_> - Permite ao usuário fazer um pedido dentro do chat com o bot, sem precisar ir até o canal onde foi publicado /info - Mostra informações sobre o bot /showusername - Permite mostrar ou ocultar o nome de usuário em cada novo pedido criado, o valor padrão é não (falso) diff --git a/locales/uk.yaml b/locales/uk.yaml index 288b945c..b25f0f89 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -70,7 +70,7 @@ must_be_numeric: ${fieldName} повинен бути числовим sats_amount: сума у сатоші fiat_amount: сума у валюті sell_correct_format: | - /sell \<_сума у сатоші_\> \<_сума у валюті_\> \<_код валюти_\> \<_метод платежу\> \[_премія/дисконт_\] \[_спільнота_\] + /sell \<_сума у сатоші_\> \<_сума у валюті_\> \<_код валюти_\> \<_метод платежу_\> \[_премія/дисконт_\] \[_спільнота_\] Ви можете додати назву однієї зі своїх спільнот останнім параметром, щоб опублікувати ордер там замість вашої спільноти за замовчуванням\. Перед ним потрібно вказати премію/дисконт \(використовуйте 0, якщо він не потрібен\)\. diff --git a/tests/bot/modules/orders/commands.spec.ts b/tests/bot/modules/orders/commands.spec.ts index 2c0d77a5..f9cd32a0 100644 --- a/tests/bot/modules/orders/commands.spec.ts +++ b/tests/bot/modules/orders/commands.spec.ts @@ -118,7 +118,9 @@ describe('/buy and /sell community routing (command path)', () => { // The explicit destination wins; the group fallback is never consulted expect(communityHelper.getCommunityInfo.called).to.equal(false); expect(createOrder.calledOnce).to.equal(true); - expect(createOrder.firstCall.args[3].community_id).to.equal('community-explicit'); + expect(createOrder.firstCall.args[3].community_id).to.equal( + 'community-explicit', + ); expect(createOrder.firstCall.args[3].type).to.equal('sell'); }); @@ -166,7 +168,9 @@ describe('/buy and /sell community routing (command path)', () => { await buy(ctx); expect(createOrder.calledOnce).to.equal(true); - expect(createOrder.firstCall.args[3].community_id).to.equal('community-priv'); + expect(createOrder.firstCall.args[3].community_id).to.equal( + 'community-priv', + ); expect(createOrder.firstCall.args[3].type).to.equal('buy'); }); @@ -233,7 +237,10 @@ describe('/buy and /sell community routing (command path)', () => { isBanned: false, }); - const ctx = buildCtx('supergroup', { ...baseParams, communityName: 'ghost' }); + const ctx = buildCtx('supergroup', { + ...baseParams, + communityName: 'ghost', + }); await buy(ctx); expect(ctx.deleteMessage.calledOnce).to.equal(true); diff --git a/tests/bot/validation.spec.ts b/tests/bot/validation.spec.ts index 80ad4907..48dfcbfb 100644 --- a/tests/bot/validation.spec.ts +++ b/tests/bot/validation.spec.ts @@ -186,6 +186,24 @@ describe('Validations', () => { expect(result.communityName).to.equal('mycommunity'); }); + it('should reject a surplus argument after the community name', async () => { + // Only two optional params exist, so a third token means the user + // mistyped. It must be reported instead of silently dropped, which would + // publish an order the user did not ask for. + ctx.state.command.args = [ + '0', + '100', + 'USD', + 'zelle', + '5', + 'mycommunity', + 'unexpected', + ]; + const result = await validateSellOrder(ctx); + expect(result).to.equal(false); + expect(replyStub.calledOnce).to.equal(true); + }); + it('should work with ranges', async () => { ctx.state.command.args = ['0', '100-200', 'USD', 'zelle', '5']; const result = await validateSellOrder(ctx); @@ -283,6 +301,24 @@ describe('Validations', () => { expect(result.communityName).to.equal('mycommunity'); }); + it('should reject a surplus argument after the community name', async () => { + // Only two optional params exist, so a third token means the user + // mistyped. It must be reported instead of silently dropped, which would + // publish an order the user did not ask for. + ctx.state.command.args = [ + '0', + '100', + 'USD', + 'zelle', + '5', + 'mycommunity', + 'unexpected', + ]; + const result = await validateBuyOrder(ctx); + expect(result).to.equal(false); + expect(replyStub.calledOnce).to.equal(true); + }); + it('should work with ranges', async () => { ctx.state.command.args = ['0', '100-200', 'USD', 'zelle', '5']; const result = await validateBuyOrder(ctx); From 2a3f725722e1e2300401b0706992071bf2755bf7 Mon Sep 17 00:00:00 2001 From: lucas Date: Tue, 28 Jul 2026 00:34:42 -0300 Subject: [PATCH 6/6] Match group of a community by literal string instead of using a regex --- tests/util/communityHelper.spec.ts | 5 +++-- util/communityHelper.ts | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/util/communityHelper.spec.ts b/tests/util/communityHelper.spec.ts index 2bd07012..c4c45b81 100644 --- a/tests/util/communityHelper.spec.ts +++ b/tests/util/communityHelper.spec.ts @@ -32,8 +32,9 @@ describe('getCommunityByIdentifier routing', () => { expect(String(result.communityId)).to.equal('community-private'); const groupQuery = findOne.firstCall.args[0]; - expect(groupQuery.group).to.be.an.instanceOf(RegExp); - expect(groupQuery.group.flags).to.contain('i'); + // Now matches literally (and case-insensitively via toLowerCase() in the impl) + expect(groupQuery.group).to.not.be.an.instanceOf(RegExp); + expect(groupQuery.group).to.equal('-1001234567890'.toLowerCase()); }); it('matches the display name exactly and only among public communities', async () => { diff --git a/util/communityHelper.ts b/util/communityHelper.ts index 3c9f7db7..9d9731ba 100644 --- a/util/communityHelper.ts +++ b/util/communityHelper.ts @@ -89,11 +89,10 @@ export const getCommunityByIdentifier = async ( try { // Escape regex metacharacters so the identifier is matched literally const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const groupRegex = new RegExp(`^${escaped}$`, 'i'); const community = (await Community.findOne({ - group: groupRegex, + group: escaped.toLowerCase(), enabled: { $ne: false }, })) || (await Community.findOne({