diff --git a/bot/commands.ts b/bot/commands.ts index b3d26640..37ac5947 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -352,12 +352,19 @@ const cancelAddInvoice = async ( } else if (order.creator_id === order.seller_id && userTgId == sellerTgId) { order.status = 'CLOSED'; await order.save(); - await messages.successCancelOrderMessage(ctx, sellerUser, order, i18nCtx); + const i18nCtxSeller = await getUserI18nContext(sellerUser); + await messages.successCancelOrderMessage( + ctx, + sellerUser, + order, + i18nCtxSeller, + ); + const i18nCtxBuyer = await getUserI18nContext(buyerUser); await messages.counterPartyCancelOrderMessage( ctx, buyerUser, order, - i18nCtx, + i18nCtxBuyer, ); } else { // Re-publish order diff --git a/bot/messages.ts b/bot/messages.ts index 83d08e84..7736d6d7 100644 --- a/bot/messages.ts +++ b/bot/messages.ts @@ -1323,11 +1323,12 @@ const successCancelOrderByAdminMessage = async ( bot: Telegraf, user: UserDocument, order: IOrder, + i18n: I18nContext, ) => { try { await bot.telegram.sendMessage( user.tg_id, - ctx.i18n.t('order_cancelled_by_admin', { orderId: order._id }), + i18n.t('order_cancelled_by_admin', { orderId: order._id }), ); } catch (error) { logger.error(error); diff --git a/bot/start.ts b/bot/start.ts index dc45f265..7623a1ce 100644 --- a/bot/start.ts +++ b/bot/start.ts @@ -19,6 +19,7 @@ import { getCurrenciesWithPrice, deleteOrderFromChannel, removeAtSymbol, + getUserI18nContext, } from '../util'; import { commandArgsMiddleware, @@ -463,9 +464,23 @@ const initialize = ( // we sent a private message to the admin await messages.successCancelOrderMessage(ctx, ctx.admin, order, ctx.i18n); // we sent a private message to the seller - await messages.successCancelOrderByAdminMessage(ctx, bot, seller, order); + const i18nCtxSeller = await getUserI18nContext(seller); + await messages.successCancelOrderByAdminMessage( + ctx, + bot, + seller, + order, + i18nCtxSeller, + ); // we sent a private message to the buyer - await messages.successCancelOrderByAdminMessage(ctx, bot, buyer, order); + const i18nCtxBuyer = await getUserI18nContext(buyer); + await messages.successCancelOrderByAdminMessage( + ctx, + bot, + buyer, + order, + i18nCtxBuyer, + ); } catch (error) { logger.error(error); } diff --git a/locales/fa.yaml b/locales/fa.yaml index 908a1be3..bde31953 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -411,8 +411,10 @@ ok_cooperativecancel: '👍 طرف مقابل موافقت کرد و سفارش refund_cooperativecancel: 💰 پرداخت لایتنینگتان به شما بازگردانده شد، نیاز به انجام هیچ کار دیگری نیست. init_cooperativecancel: | 🕒 شما فرآیند لغو سفارش با شناسه ${orderId} را آغاز کردید؛ طرف مقابل نیز باید با لغو موافقت کند. اگر او پاسخ نداد، می‌توانید یک مشاجره ثبت کنید. - هیچ مدیری برای لغو سفارشتان با شما تماس نخواهد گرفت مگر آنکه ابتدا یک مشاجره ثبت کرده باشید. - counterparty_wants_cooperativecancel: | + + هیچ مدیری برای لغو سفارشتان با شما تماس نخواهد گرفت مگر آنکه ابتدا یک مشاجره ثبت کرده باشید. + +counterparty_wants_cooperativecancel: | 😳 طرف مقابل شما می‌خواهد سفارش با شناسه ${orderId} را لغو کند. هیچ مدیری برای لغو سفارشتان با شما تماس نخواهد گرفت مگر آنکه ابتدا یک مشاجره ثبت کرده باشید. diff --git a/tests/bot/cancelLocale.spec.ts b/tests/bot/cancelLocale.spec.ts new file mode 100644 index 00000000..c732520e --- /dev/null +++ b/tests/bot/cancelLocale.spec.ts @@ -0,0 +1,300 @@ +export {}; + +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +/** + * These tests lock in the fix for the bug where cancellation messages were + * delivered in the language of the user who ran /cancel (or of the admin), + * instead of in each recipient's own language. + * + * Root cause: getUserI18nContext read a non-existent `user.language` field + * (the User model uses `lang`), so it always fell back to the default locale. + * + * The cancel flow (/cancel, /cancelall and the inline cancel buttons) funnels + * through cancelAddInvoice, cancelShowHoldInvoice and cancelOrder. We exercise + * every buyer/seller x taker/creator combination and assert, generically, that + * whenever a message is sent to a specific recipient with an explicit i18n + * context, that context's language matches the recipient's language. + */ + +// Distinct languages so any mismatch is caught immediately. +const BUYER = { _id: 'BUYER', tg_id: '100', lang: 'fa' }; +const SELLER = { _id: 'SELLER', tg_id: '200', lang: 'es' }; + +// Records every messages.* call so we can audit recipient vs. i18n language. +const messageCalls: Array<{ name: string; args: any[] }> = []; +const messagesMock: any = new Proxy( + {}, + { + get: (_target, prop: string) => { + return (...args: any[]) => { + messageCalls.push({ name: prop, args }); + return Promise.resolve(); + }; + }, + }, +); + +// getUserI18nContext returns a context tagged with the user's language. +const taggedI18n = (lang: string) => ({ __lang: lang, t: (k: string) => k }); + +const utilMock = { + getUserI18nContext: (user: any) => Promise.resolve(taggedI18n(user.lang)), + deleteOrderFromChannel: sinon.stub().resolves(), + getBtcFiatPrice: sinon.stub().resolves(1000), + getFee: sinon.stub().resolves(0), + removeLightningPrefix: (x: string) => x, + PerOrderIdMutex: { + instance: { runExclusive: (_id: string, fn: () => any) => fn() }, + }, +}; + +const lnMock = { + createHoldInvoice: sinon + .stub() + .resolves({ request: 'r', hash: 'h', secret: 's' }), + subscribeInvoice: sinon.stub().resolves(), + cancelHoldInvoice: sinon.stub().resolves(), + settleHoldInvoice: sinon.stub().resolves(), + getInvoice: sinon.stub().resolves({ request: 'r' }), +}; + +const UserMock = { + findOne: (query: any) => { + const id = query && query._id; + if (id === 'BUYER') return Promise.resolve({ ...BUYER }); + if (id === 'SELLER') return Promise.resolve({ ...SELLER }); + return Promise.resolve(null); + }, + findById: (id: any) => { + if (id === 'BUYER') return Promise.resolve({ ...BUYER }); + if (id === 'SELLER') return Promise.resolve({ ...SELLER }); + return Promise.resolve(null); + }, +}; + +let currentOrder: any; +let cooperativeUpdateResult: any; +const OrderMock = { + findOne: () => Promise.resolve(currentOrder), + findOneAndUpdate: () => Promise.resolve(cooperativeUpdateResult), +}; + +const ordersActionsMock = { + getOrder: () => Promise.resolve(currentOrder), + getOrders: () => Promise.resolve([]), +}; + +const commands = proxyquire('../../bot/commands', { + '../models': { Order: OrderMock, User: UserMock, Dispute: {} }, + '../ln': lnMock, + '../util': utilMock, + './messages': messagesMock, + './ordersActions': ordersActionsMock, + './modules/events/orders': { orderUpdated: sinon.stub() }, +}); + +// Builds a minimal ctx whose implicit i18n (ctx.i18n) belongs to `caller`. +const makeCtx = (callerTgId: string, callerLang: string) => ({ + from: { id: Number(callerTgId) }, + i18n: taggedI18n(callerLang), + deleteMessage: sinon.stub(), + scene: { leave: sinon.stub() }, + telegram: { sendMessage: sinon.stub().resolves() }, + reply: sinon.stub().resolves(), + botInfo: { username: 'bot' }, + update: {}, +}); + +const baseOrder = (over: any) => ({ + _id: 'ORDER1', + hash: null, + buyer_id: 'BUYER', + seller_id: 'SELLER', + min_amount: null, + max_amount: null, + amount: 1000, + fee: 1, + fiat_amount: 100, + fiat_code: 'usd', + price_from_api: false, + buyer_cooperativecancel: false, + seller_cooperativecancel: false, + save: sinon.stub().resolves(), + ...over, +}); + +// The generic guard: for every recorded message call, if it targets a concrete +// recipient (a user object) AND carries an explicit i18n context, they must +// speak the same language. +const assertRecipientLanguages = () => { + for (const call of messageCalls) { + const recipient = call.args.find( + a => a && typeof a === 'object' && 'tg_id' in a && 'lang' in a, + ); + const i18n = call.args.find( + a => a && typeof a === 'object' && '__lang' in a, + ); + if (recipient && i18n) { + expect( + i18n.__lang, + `${call.name}: message sent to ${recipient.tg_id} used locale ` + + `"${i18n.__lang}" but recipient language is "${recipient.lang}"`, + ).to.equal(recipient.lang); + } + } +}; + +describe('Cancel flow: messages must use each recipient language', () => { + beforeEach(() => { + messageCalls.length = 0; + currentOrder = null; + cooperativeUpdateResult = null; + }); + + describe('cancelAddInvoice (sell order taken, WAITING_BUYER_INVOICE)', () => { + it('seller is the creator and cancels', async () => { + currentOrder = baseOrder({ + type: 'sell', + status: 'WAITING_BUYER_INVOICE', + creator_id: 'SELLER', + }); + const ctx = makeCtx(SELLER.tg_id, SELLER.lang); + await commands.cancelAddInvoice(ctx, currentOrder); + assertRecipientLanguages(); + expect(messageCalls.map(c => c.name)).to.include.members([ + 'successCancelOrderMessage', + 'counterPartyCancelOrderMessage', + ]); + }); + + it('buyer is the creator and cancels', async () => { + currentOrder = baseOrder({ + type: 'buy', + status: 'WAITING_BUYER_INVOICE', + creator_id: 'BUYER', + }); + const ctx = makeCtx(BUYER.tg_id, BUYER.lang); + await commands.cancelAddInvoice(ctx, currentOrder); + assertRecipientLanguages(); + expect(messageCalls.map(c => c.name)).to.include.members([ + 'toBuyerDidntAddInvoiceMessage', + 'toSellerBuyerDidntAddInvoiceMessage', + ]); + }); + + it('buyer is the taker and cancels (order republished)', async () => { + currentOrder = baseOrder({ + type: 'sell', + status: 'WAITING_BUYER_INVOICE', + creator_id: 'SELLER', + }); + const ctx = makeCtx(BUYER.tg_id, BUYER.lang); + await commands.cancelAddInvoice(ctx, currentOrder); + assertRecipientLanguages(); + expect(messageCalls.map(c => c.name)).to.include( + 'publishSellOrderMessage', + ); + }); + }); + + describe('cancelShowHoldInvoice (WAITING_PAYMENT)', () => { + it('seller is the creator and cancels', async () => { + currentOrder = baseOrder({ + type: 'sell', + status: 'WAITING_PAYMENT', + creator_id: 'SELLER', + }); + const ctx = makeCtx(SELLER.tg_id, SELLER.lang); + await commands.cancelShowHoldInvoice(ctx, currentOrder); + assertRecipientLanguages(); + expect(messageCalls.map(c => c.name)).to.include.members([ + 'toSellerDidntPayInvoiceMessage', + 'toBuyerSellerDidntPayInvoiceMessage', + ]); + }); + + it('buyer is the creator and cancels', async () => { + currentOrder = baseOrder({ + type: 'buy', + status: 'WAITING_PAYMENT', + creator_id: 'BUYER', + }); + const ctx = makeCtx(BUYER.tg_id, BUYER.lang); + await commands.cancelShowHoldInvoice(ctx, currentOrder); + assertRecipientLanguages(); + expect(messageCalls.map(c => c.name)).to.include.members([ + 'successCancelOrderMessage', + 'counterPartyCancelOrderMessage', + ]); + }); + + it('seller is the taker and cancels (order republished)', async () => { + currentOrder = baseOrder({ + type: 'buy', + status: 'WAITING_PAYMENT', + creator_id: 'BUYER', + }); + const ctx = makeCtx(SELLER.tg_id, SELLER.lang); + await commands.cancelShowHoldInvoice(ctx, currentOrder); + assertRecipientLanguages(); + expect(messageCalls.map(c => c.name)).to.include( + 'publishBuyOrderMessage', + ); + }); + }); + + describe('cancelOrder cooperative cancellation (ACTIVE)', () => { + it('buyer initiates and both parties agree', async () => { + currentOrder = baseOrder({ type: 'sell', status: 'ACTIVE' }); + cooperativeUpdateResult = baseOrder({ + type: 'sell', + status: 'ACTIVE', + buyer_cooperativecancel: true, + seller_cooperativecancel: true, + }); + const ctx: any = makeCtx(BUYER.tg_id, BUYER.lang); + await commands.cancelOrder(ctx, 'ORDER1', { ...BUYER }); + assertRecipientLanguages(); + expect(messageCalls.map(c => c.name)).to.include.members([ + 'okCooperativeCancelMessage', + 'refundCooperativeCancelMessage', + ]); + }); + + it('seller initiates and both parties agree', async () => { + currentOrder = baseOrder({ type: 'sell', status: 'ACTIVE' }); + cooperativeUpdateResult = baseOrder({ + type: 'sell', + status: 'ACTIVE', + buyer_cooperativecancel: true, + seller_cooperativecancel: true, + }); + const ctx: any = makeCtx(SELLER.tg_id, SELLER.lang); + await commands.cancelOrder(ctx, 'ORDER1', { ...SELLER }); + assertRecipientLanguages(); + expect(messageCalls.map(c => c.name)).to.include.members([ + 'okCooperativeCancelMessage', + 'refundCooperativeCancelMessage', + ]); + }); + + it('buyer initiates and waits for the counterparty', async () => { + currentOrder = baseOrder({ type: 'sell', status: 'ACTIVE' }); + cooperativeUpdateResult = baseOrder({ + type: 'sell', + status: 'ACTIVE', + buyer_cooperativecancel: true, + seller_cooperativecancel: false, + }); + const ctx: any = makeCtx(BUYER.tg_id, BUYER.lang); + await commands.cancelOrder(ctx, 'ORDER1', { ...BUYER }); + assertRecipientLanguages(); + expect(messageCalls.map(c => c.name)).to.include( + 'counterPartyWantsCooperativeCancelMessage', + ); + }); + }); +}); diff --git a/tests/util/index.spec.ts b/tests/util/index.spec.ts index f490e50f..52b321ea 100644 --- a/tests/util/index.spec.ts +++ b/tests/util/index.spec.ts @@ -7,6 +7,7 @@ import { isFloat, toKebabCase, getDetailedOrder, + getUserI18nContext, } from '../../util/index'; const { expect } = require('chai'); @@ -206,4 +207,19 @@ describe('Utility Functions', () => { expect(result).to.equal('Settled by admin: No'); }); }); + + describe('getUserI18nContext', () => { + // Guards the root cause of the wrong-language bug: the User model stores + // the language in `lang`, so the context must be built from that field + // (a previous version read a non-existent `user.language`). + it("builds the context from the user's lang field", async () => { + const ctx = await getUserI18nContext({ lang: 'es' } as any); + expect(ctx.locale()).to.equal('es'); + }); + + it('falls back to English when lang is missing', async () => { + const ctx = await getUserI18nContext({} as any); + expect(ctx.locale()).to.equal('en'); + }); + }); }); diff --git a/util/index.ts b/util/index.ts index a990ce58..787121bb 100644 --- a/util/index.ts +++ b/util/index.ts @@ -385,19 +385,14 @@ const getDisputeChannel = async (order: IOrder) => { * @returns i18n context */ const getUserI18nContext = async (user: UserDocument) => { - let language = null; - if (!('language' in user)) { - language = 'en'; - } else { - language = user.language; - } + const language = user.lang || 'en'; const i18n = new I18n({ locale: language, defaultLanguageOnMissing: true, directory: 'locales', }); - return i18n.createContext(user.lang || language || 'en'); + return i18n.createContext(language); }; const getDetailedOrder = async (