From 2d26a6fdfe448218a49c48c4da22a304146ce7ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 18:06:58 +0200 Subject: [PATCH] fix(telegram): answer someone who asks for help in their own language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bot's real commands are slash commands, and `telegram.helpBody` documents exactly those in all six bundles, so nothing a user is TOLD to type depended on their language. The undocumented courtesies did: the bare word "help" and "hilfe" opened the help text and five greetings were answered, everything else fell through to the intake keyword, failed it, and returned without sending anything. Someone typing "aide" or "pomoc" got silence, which is the worst possible answer to a request for help. Both lists now cover the six shipped languages and are compared against the folded message, so "cześć" and "czesc" are one entry and a capital cannot miss. The match stays anchored to the whole message: "help me log my weight" is prose the later paths should still see. These are ordinary words rather than app labels, so there is nothing in the bundles to derive them from. --- .../telegram/webhook/__tests__/route.test.ts | 61 ++++++++++++++++ src/lib/telegram-webhook-handlers.ts | 69 +++++++++++++++++-- 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index ac4e3b012..ecb653d87 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -546,6 +546,67 @@ describe("Telegram webhook — text-message dispatch", () => { expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); }); + /** + * The bare word is an alias for `/help`. It knew "help" and "hilfe", so the + * four other shipped languages asking for help fell through to the intake + * keyword, failed it, and the handler returned without sending anything — + * silence, not an error. These pin one spelling per shipped language. + */ + it.each([ + ["help", 210], + ["Hilfe", 211], + ["aide", 212], + ["ayuda", 213], + ["aiuto", 214], + ["Pomoc", 215], + ])("answers the bare help word %s", async (word, updateId) => { + const res = await POST( + tgRequest({ + update_id: updateId, + message: { message_id: 20, text: word, chat: { id: 7777 } }, + }), + ); + expect(res.status).toBe(200); + expect(sendTelegramMessage).toHaveBeenCalledTimes(1); + expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch( + /available commands/i, + ); + }); + + it("does not swallow a sentence that merely starts with a help word", async () => { + // The alias matches the WHOLE message; "help me log my weight" is prose + // the later paths should still see, not a command. + const res = await POST( + tgRequest({ + update_id: 216, + message: { + message_id: 21, + text: "help me log my weight", + chat: { id: 7777 }, + }, + }), + ); + expect(res.status).toBe(200); + expect(sendTelegramMessage).not.toHaveBeenCalled(); + }); + + it.each([ + ["bonjour", 220], + ["Hola", 221], + ["ciao", 222], + ["cześć", 223], + ])("answers the greeting %s", async (word, updateId) => { + const res = await POST( + tgRequest({ + update_id: updateId, + message: { message_id: 22, text: word, chat: { id: 7777 } }, + }), + ); + expect(res.status).toBe(200); + expect(sendTelegramMessage).toHaveBeenCalledTimes(1); + expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(/👋/); + }); + it("greeting like 'hi' echoes a friendly reply", async () => { const res = await POST( tgRequest({ diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 5d05f0239..f458bf6cc 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -8,6 +8,7 @@ import { } from "@/lib/telegram"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { locales, type Locale } from "@/lib/i18n/config"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { applyCanonicalSlotWrite, @@ -43,6 +44,60 @@ import { * from the linked chat via `findTelegramUser` — never from the payload. */ +/* ────────────────────────────────────────────────────────────────────────── + * Bare-word aliases. + * + * The bot's real commands are slash commands (`/help`, `/start`, `/add`) plus + * the `taken ` text command, and `telegram.helpBody` documents exactly + * those in all six bundles — none of that depends on the reader's language. + * What did depend on it were the two undocumented courtesies below, which knew + * English and German: "aide", "ayuda", "aiuto" and "pomoc" reached the intake + * keyword instead, failed it, and the handler returned without replying at all. + * Silence is the worst answer to someone asking for help. + * + * These are ordinary words, not app labels, so there is nothing in the bundles + * to derive them from. They are matched on the FOLDED text so "cześć" and + * "czesc" are one entry and a stray capital or accent cannot miss. + * ────────────────────────────────────────────────────────────────────────── */ + +/** "Help", in every language the app ships. */ +const HELP_WORDS: readonly string[] = [ + "help", + "hilfe", + "aide", + "ayuda", + "aiuto", + "pomoc", +]; + +/** Greetings the bot answers with a wave, in every language the app ships. */ +const GREETING_WORDS: readonly string[] = [ + "hi", + "hello", + "hey", + "hallo", + "moin", + "bonjour", + "salut", + "hola", + "buenas", + "ciao", + "salve", + "czesc", + "witaj", + "dzien dobry", +]; + +/** + * True when the whole message is one of `words`. Folded on both sides, so the + * comparison is case-, accent- and punctuation-insensitive; anchored to the + * whole message, so "help me log my weight" is not swallowed as a command. + */ +function isBareCommandWord(text: string, words: readonly string[]): boolean { + const folded = foldForMatch(text); + return folded !== "" && words.includes(folded); +} + export interface TelegramUpdate { update_id: number; message?: { @@ -1106,12 +1161,15 @@ export async function handleTextMessage(update: TelegramUpdate) { } } - // "Help" / start: accept English + German keyword aliases independent of locale + // Help / start. The slash commands are locale-independent by construction and + // are what `telegram.helpBody` documents in every bundle. The BARE word is an + // undocumented convenience that used to know English and German only, so a + // French user typing "aide" got silence — the handler falls through to the + // intake keyword and returns without a reply. if ( /^\/help\b/i.test(text) || /^\/start\b/i.test(text) || - /^hilfe$/i.test(text) || - /^help$/i.test(text) + isBareCommandWord(text, HELP_WORDS) ) { const userMsgId = message?.message_id; const resp = await sendTelegramMessage( @@ -1212,10 +1270,7 @@ export async function handleTextMessage(update: TelegramUpdate) { } // Greeting responses (kept locale-independent) - const greetings = ["hi", "hallo", "hey", "moin", "hello"]; - const lowerText = text.toLowerCase(); - const matchedGreeting = greetings.find((g) => lowerText === g); - if (matchedGreeting) { + if (isBareCommandWord(text, GREETING_WORDS)) { const userMsgId = message?.message_id; const reply = text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); const resp = await sendTelegramMessage(botToken, chatId, `${reply}! 👋`);