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}! 👋`);