Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions bot/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,42 @@ const onGoingTakeBuyMessage = async (
}
};

// Telegram rejects a MarkdownV2 message with 400 "can't parse entities" when a
// translation contains an unescaped reserved character.
const isMarkdownParseError = (error: unknown) =>
error instanceof TelegramError &&
error.response.error_code === 400 &&
/can't parse entities/i.test(error.response.description ?? '');

// MarkdownV2 escapes (`\.`, `\(`, …) are meaningless once parse_mode is
// dropped, so strip them before falling back or the reader sees the backslashes.
const unescapeMarkdownV2 = (text: string) => text.replace(/\\(.)/g, '$1');

// Sends a MarkdownV2 message, retrying once as plain text when Telegram rejects
// the formatting. A single unescaped reserved character makes sendMessage throw,
// and callers that send follow-up messages (e.g. the order action buttons) never
// reach them. Escaping locales fixes the known characters; this keeps the
// message deliverable when a new one slips through. Errors that aren't parse
// failures (rate limits, blocked bot, network) are re-thrown untouched so we
// don't duplicate a message that may already have been delivered.
// The failing locale is logged so the translation can be corrected.
const sendMarkdownV2WithPlainFallback = async (
bot: HasTelegram,
tgId: string,
text: string,
locale: string,
) => {
try {
await bot.telegram.sendMessage(tgId, text, { parse_mode: 'MarkdownV2' });
} catch (error) {
if (!isMarkdownParseError(error)) throw error;
logger.warning(
`MarkdownV2 send failed for locale "${locale}", retrying as plain text: ${error}`,
);
await bot.telegram.sendMessage(tgId, unescapeMarkdownV2(text));
}
};

const beginTakeSellMessage = async (
ctx: MainContext,
bot: HasTelegram,
Expand All @@ -570,10 +606,11 @@ const beginTakeSellMessage = async (
let expirationTime = time.hours + ' ' + ctx.i18n.t('hours');
expirationTime +=
time.minutes > 0 ? ' ' + time.minutes + ' ' + ctx.i18n.t('minutes') : '';
await bot.telegram.sendMessage(
await sendMarkdownV2WithPlainFallback(
bot,
buyer.tg_id,
ctx.i18n.t('you_took_someone_order', { expirationTime }),
{ parse_mode: 'MarkdownV2' },
ctx.i18n.locale(),
);
await bot.telegram.sendMessage(buyer.tg_id, order._id, {
reply_markup: {
Expand Down
118 changes: 118 additions & 0 deletions tests/bot/markdownV2Fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
export {};

const { expect } = require('chai');
const sinon = require('sinon');
const proxyquire = require('proxyquire').noCallThru();
const { TelegramError } = require('telegraf');

// Builds the error Telegram returns when a translation breaks MarkdownV2.
const parseError = () =>
new TelegramError({
error_code: 400,
description: "Bad Request: can't parse entities: character '.' is reserved",
});

// Issue #882: a single unescaped MarkdownV2 reserved character in a translation
// makes Telegram reject the "you took someone's order" message. Because the
// follow-up message carrying the Continue/Cancel buttons is sent right after,
// losing the first send also left the taker without any way to act on the order.
// The send now falls back to plain text so both messages get through.

const utilMock = {
secondsToTime: () => ({ hours: 23, minutes: 0 }),
holdInvoiceExpirationInSecs: () => ({
expirationTimeInSecs: 86400,
safetyWindowInSecs: 3600,
}),
getCurrency: () => ({ symbol_native: '$' }),
numberFormat: (_c: string, n: number) => String(n),
getDetailedOrder: sinon.stub().returns(''),
getOrderChannel: sinon.stub().resolves(''),
sanitizeMD: (x: string) => x,
getEmojiRate: () => '',
decimalRound: (x: number) => x,
getUserAge: () => 0,
getStars: () => '',
generateQRWithImage: sinon.stub().resolves(Buffer.from('')),
};

const { beginTakeSellMessage } = proxyquire('../../bot/messages', {
'../util': utilMock,
'../util/imageCache': { imageCache: { convertImageToBase64: sinon.stub() } },
});

const BUYER = { tg_id: '111', lang: 'fa' };
const ORDER = { _id: 'order123' };

// Mirrors the real translation shape: MarkdownV2 escapes reserved characters
// with a backslash (the locales carry `\\.` which compiles to `\.` at runtime).
const ESCAPED_TEXT = 'no risk of freezing funds\\. Press to continue \\(now\\)';
const PLAIN_TEXT = 'no risk of freezing funds. Press to continue (now)';

// ctx whose locale is Persian, mirroring the reported scenario.
const makeCtx = () => ({
i18n: { t: () => ESCAPED_TEXT, locale: () => 'fa' },
});

describe('MarkdownV2 send falls back to plain text (#882)', () => {
it('retries as plain text and still delivers the action buttons', async () => {
const sendMessage = sinon.stub();
// First call is the MarkdownV2 one: Telegram rejects the formatting.
sendMessage.onFirstCall().rejects(parseError());
sendMessage.resolves();
const bot: any = { telegram: { sendMessage } };

await beginTakeSellMessage(makeCtx() as any, bot, BUYER as any, ORDER);

// 1) MarkdownV2 attempt, 2) plain-text retry, 3) buttons
expect(sendMessage.callCount).to.equal(3);

// The retry drops parse_mode and strips the now-meaningless MarkdownV2
// escapes, so the reader doesn't see stray backslashes.
const [firstArgs, retryArgs] = [
sendMessage.getCall(0).args,
sendMessage.getCall(1).args,
];
expect(firstArgs[2]).to.deep.equal({ parse_mode: 'MarkdownV2' });
expect(firstArgs[1]).to.equal(ESCAPED_TEXT);
expect(retryArgs[1]).to.equal(PLAIN_TEXT);
expect(retryArgs[1]).to.not.include('\\');
expect(retryArgs[2]).to.equal(undefined);

// The taker still receives the Continue/Cancel buttons — without this the
// order could not be advanced or cancelled at all.
const buttonsCall = sendMessage.getCall(2).args;
expect(buttonsCall[1]).to.equal(ORDER._id);
const keyboard = buttonsCall[2].reply_markup.inline_keyboard[0];
expect(keyboard.map((b: any) => b.callback_data)).to.deep.equal([
'addInvoiceBtn',
'cancelAddInvoiceBtn',
]);
});

it('does not retry on non-formatting errors (no duplicate sends)', async () => {
// A network/rate-limit failure may mean the message was already delivered,
// so retrying it would send the risk warning twice.
const sendMessage = sinon.stub().rejects(new Error('socket hang up'));
const bot: any = { telegram: { sendMessage } };

await beginTakeSellMessage(makeCtx() as any, bot, BUYER as any, ORDER);

// Only the original attempt: no plain-text retry, and the outer handler
// logs the error as it did before.
expect(sendMessage.callCount).to.equal(1);
});

it('keeps MarkdownV2 formatting when the locale is safe', async () => {
const sendMessage = sinon.stub().resolves();
const bot: any = { telegram: { sendMessage } };

await beginTakeSellMessage(makeCtx() as any, bot, BUYER as any, ORDER);

// No retry needed: the MarkdownV2 send plus the buttons.
expect(sendMessage.callCount).to.equal(2);
expect(sendMessage.getCall(0).args[2]).to.deep.equal({
parse_mode: 'MarkdownV2',
});
});
});