fix(telegram): answer someone who asks for help in their own language

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-23 18:06:58 +02:00
1 parent 4d77fe2a43
commit 2d26a6fdfe
2 files changed
+123 -7

No files matched your search

@@ -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({
+62 -7
View File
@@ -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 <name>` 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}! 👋`);