From d28b61dc2d3c776b711ff17234e5fa2d5b3ae075 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 21 Feb 2026 08:51:40 +0530 Subject: [PATCH] fix: preselect Telegram-supported status reaction variants --- src/telegram/bot-message-context.ts | 25 ++- src/telegram/status-reaction-variants.test.ts | 107 +++++++++++ src/telegram/status-reaction-variants.ts | 179 ++++++++++++++++++ 3 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 src/telegram/status-reaction-variants.test.ts create mode 100644 src/telegram/status-reaction-variants.ts diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 416e1f8fcb3..ff07682ed8f 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -62,6 +62,11 @@ import { } from "./bot/helpers.js"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; +import { + buildTelegramStatusReactionVariants, + resolveTelegramReactionVariant, + resolveTelegramStatusReactionEmojis, +} from "./status-reaction-variants.js"; export type TelegramMediaRef = { path: string; @@ -530,6 +535,13 @@ export const buildTelegramMessageContext = async ({ const statusReactionsConfig = cfg.messages?.statusReactions; const statusReactionsEnabled = statusReactionsConfig?.enabled === true && Boolean(reactionApi) && shouldAckReaction(); + const resolvedStatusReactionEmojis = resolveTelegramStatusReactionEmojis({ + initialEmoji: ackReaction, + overrides: statusReactionsConfig?.emojis, + }); + const statusReactionVariantsByEmoji = buildTelegramStatusReactionVariants( + resolvedStatusReactionEmojis, + ); const statusReactionController: StatusReactionController | null = statusReactionsEnabled && msg.message_id ? createStatusReactionController({ @@ -537,13 +549,22 @@ export const buildTelegramMessageContext = async ({ adapter: { setReaction: async (emoji: string) => { if (reactionApi) { - await reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji }]); + const resolvedEmoji = resolveTelegramReactionVariant({ + requestedEmoji: emoji, + variantsByRequestedEmoji: statusReactionVariantsByEmoji, + }); + if (!resolvedEmoji) { + return; + } + await reactionApi(chatId, msg.message_id, [ + { type: "emoji", emoji: resolvedEmoji }, + ]); } }, // Telegram replaces atomically โ€” no removeReaction needed }, initialEmoji: ackReaction, - emojis: statusReactionsConfig?.emojis, + emojis: resolvedStatusReactionEmojis, timing: statusReactionsConfig?.timing, onError: (err) => { logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`); diff --git a/src/telegram/status-reaction-variants.test.ts b/src/telegram/status-reaction-variants.test.ts new file mode 100644 index 00000000000..3bac083080d --- /dev/null +++ b/src/telegram/status-reaction-variants.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_EMOJIS } from "../channels/status-reactions.js"; +import { + buildTelegramStatusReactionVariants, + isTelegramSupportedReactionEmoji, + resolveTelegramReactionVariant, + resolveTelegramStatusReactionEmojis, +} from "./status-reaction-variants.js"; + +describe("resolveTelegramStatusReactionEmojis", () => { + it("falls back to Telegram-safe defaults for empty overrides", () => { + const result = resolveTelegramStatusReactionEmojis({ + initialEmoji: "๐Ÿ‘€", + overrides: { + thinking: " ", + done: "\n", + }, + }); + + expect(result.queued).toBe("๐Ÿ‘€"); + expect(result.thinking).toBe(DEFAULT_EMOJIS.thinking); + expect(result.done).toBe(DEFAULT_EMOJIS.done); + }); + + it("preserves explicit non-empty overrides", () => { + const result = resolveTelegramStatusReactionEmojis({ + initialEmoji: "๐Ÿ‘€", + overrides: { + thinking: "๐Ÿซก", + done: "๐ŸŽ‰", + }, + }); + + expect(result.thinking).toBe("๐Ÿซก"); + expect(result.done).toBe("๐ŸŽ‰"); + }); +}); + +describe("buildTelegramStatusReactionVariants", () => { + it("puts requested emoji first and appends Telegram fallbacks", () => { + const variants = buildTelegramStatusReactionVariants({ + ...DEFAULT_EMOJIS, + coding: "๐Ÿ› ๏ธ", + }); + + expect(variants.get("๐Ÿ› ๏ธ")).toEqual(["๐Ÿ› ๏ธ", "๐Ÿ‘จโ€๐Ÿ’ป", "๐Ÿ”ฅ", "โšก"]); + }); +}); + +describe("isTelegramSupportedReactionEmoji", () => { + it("accepts Telegram-supported reaction emojis", () => { + expect(isTelegramSupportedReactionEmoji("๐Ÿ‘€")).toBe(true); + expect(isTelegramSupportedReactionEmoji("๐Ÿ‘จโ€๐Ÿ’ป")).toBe(true); + }); + + it("rejects unsupported emojis", () => { + expect(isTelegramSupportedReactionEmoji("๐Ÿซ ")).toBe(false); + }); +}); + +describe("resolveTelegramReactionVariant", () => { + it("returns requested emoji when already Telegram-supported", () => { + const variantsByEmoji = buildTelegramStatusReactionVariants({ + ...DEFAULT_EMOJIS, + coding: "๐Ÿ‘จโ€๐Ÿ’ป", + }); + + const result = resolveTelegramReactionVariant({ + requestedEmoji: "๐Ÿ‘จโ€๐Ÿ’ป", + variantsByRequestedEmoji: variantsByEmoji, + }); + + expect(result).toBe("๐Ÿ‘จโ€๐Ÿ’ป"); + }); + + it("returns first Telegram-supported fallback for unsupported requested emoji", () => { + const variantsByEmoji = buildTelegramStatusReactionVariants({ + ...DEFAULT_EMOJIS, + coding: "๐Ÿ› ๏ธ", + }); + + const result = resolveTelegramReactionVariant({ + requestedEmoji: "๐Ÿ› ๏ธ", + variantsByRequestedEmoji: variantsByEmoji, + }); + + expect(result).toBe("๐Ÿ‘จโ€๐Ÿ’ป"); + }); + + it("uses generic Telegram fallbacks for unknown emojis", () => { + const result = resolveTelegramReactionVariant({ + requestedEmoji: "๐Ÿซ ", + variantsByRequestedEmoji: new Map(), + }); + + expect(result).toBe("๐Ÿ‘"); + }); + + it("returns undefined for empty requested emoji", () => { + const result = resolveTelegramReactionVariant({ + requestedEmoji: " ", + variantsByRequestedEmoji: new Map(), + }); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/telegram/status-reaction-variants.ts b/src/telegram/status-reaction-variants.ts new file mode 100644 index 00000000000..7fa2ccdbe67 --- /dev/null +++ b/src/telegram/status-reaction-variants.ts @@ -0,0 +1,179 @@ +import { DEFAULT_EMOJIS, type StatusReactionEmojis } from "../channels/status-reactions.js"; + +type StatusReactionEmojiKey = keyof Required; + +const TELEGRAM_GENERIC_REACTION_FALLBACKS = ["๐Ÿ‘", "๐Ÿ‘€", "๐Ÿ”ฅ"] as const; + +const TELEGRAM_SUPPORTED_REACTION_EMOJIS = new Set([ + "โค", + "๐Ÿ‘", + "๐Ÿ‘Ž", + "๐Ÿ”ฅ", + "๐Ÿฅฐ", + "๐Ÿ‘", + "๐Ÿ˜", + "๐Ÿค”", + "๐Ÿคฏ", + "๐Ÿ˜ฑ", + "๐Ÿคฌ", + "๐Ÿ˜ข", + "๐ŸŽ‰", + "๐Ÿคฉ", + "๐Ÿคฎ", + "๐Ÿ’ฉ", + "๐Ÿ™", + "๐Ÿ‘Œ", + "๐Ÿ•Š", + "๐Ÿคก", + "๐Ÿฅฑ", + "๐Ÿฅด", + "๐Ÿ˜", + "๐Ÿณ", + "โคโ€๐Ÿ”ฅ", + "๐ŸŒš", + "๐ŸŒญ", + "๐Ÿ’ฏ", + "๐Ÿคฃ", + "โšก", + "๐ŸŒ", + "๐Ÿ†", + "๐Ÿ’”", + "๐Ÿคจ", + "๐Ÿ˜", + "๐Ÿ“", + "๐Ÿพ", + "๐Ÿ’‹", + "๐Ÿ–•", + "๐Ÿ˜ˆ", + "๐Ÿ˜ด", + "๐Ÿ˜ญ", + "๐Ÿค“", + "๐Ÿ‘ป", + "๐Ÿ‘จโ€๐Ÿ’ป", + "๐Ÿ‘€", + "๐ŸŽƒ", + "๐Ÿ™ˆ", + "๐Ÿ˜‡", + "๐Ÿ˜จ", + "๐Ÿค", + "โœ", + "๐Ÿค—", + "๐Ÿซก", + "๐ŸŽ…", + "๐ŸŽ„", + "โ˜ƒ", + "๐Ÿ’…", + "๐Ÿคช", + "๐Ÿ—ฟ", + "๐Ÿ†’", + "๐Ÿ’˜", + "๐Ÿ™‰", + "๐Ÿฆ„", + "๐Ÿ˜˜", + "๐Ÿ’Š", + "๐Ÿ™Š", + "๐Ÿ˜Ž", + "๐Ÿ‘พ", + "๐Ÿคทโ€โ™‚", + "๐Ÿคท", + "๐Ÿคทโ€โ™€", + "๐Ÿ˜ก", +]); + +export const TELEGRAM_STATUS_REACTION_VARIANTS: Record = { + queued: ["๐Ÿ‘€", "๐Ÿ‘", "๐Ÿ”ฅ"], + thinking: ["๐Ÿค”", "๐Ÿค“", "๐Ÿ‘€"], + tool: ["๐Ÿ”ฅ", "โšก", "๐Ÿ‘"], + coding: ["๐Ÿ‘จโ€๐Ÿ’ป", "๐Ÿ”ฅ", "โšก"], + web: ["โšก", "๐Ÿ”ฅ", "๐Ÿ‘"], + done: ["๐Ÿ‘", "๐ŸŽ‰", "๐Ÿ’ฏ"], + error: ["๐Ÿ˜ฑ", "๐Ÿ˜จ", "๐Ÿคฏ"], + stallSoft: ["๐Ÿฅฑ", "๐Ÿ˜ด", "๐Ÿค”"], + stallHard: ["๐Ÿ˜จ", "๐Ÿ˜ฑ", "โšก"], +}; + +const STATUS_REACTION_EMOJI_KEYS: StatusReactionEmojiKey[] = [ + "queued", + "thinking", + "tool", + "coding", + "web", + "done", + "error", + "stallSoft", + "stallHard", +]; + +function normalizeEmoji(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function toUniqueNonEmpty(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +export function resolveTelegramStatusReactionEmojis(params: { + initialEmoji: string; + overrides?: StatusReactionEmojis; +}): Required { + const { overrides } = params; + const queuedFallback = normalizeEmoji(params.initialEmoji) ?? DEFAULT_EMOJIS.queued; + return { + queued: normalizeEmoji(overrides?.queued) ?? queuedFallback, + thinking: normalizeEmoji(overrides?.thinking) ?? DEFAULT_EMOJIS.thinking, + tool: normalizeEmoji(overrides?.tool) ?? DEFAULT_EMOJIS.tool, + coding: normalizeEmoji(overrides?.coding) ?? DEFAULT_EMOJIS.coding, + web: normalizeEmoji(overrides?.web) ?? DEFAULT_EMOJIS.web, + done: normalizeEmoji(overrides?.done) ?? DEFAULT_EMOJIS.done, + error: normalizeEmoji(overrides?.error) ?? DEFAULT_EMOJIS.error, + stallSoft: normalizeEmoji(overrides?.stallSoft) ?? DEFAULT_EMOJIS.stallSoft, + stallHard: normalizeEmoji(overrides?.stallHard) ?? DEFAULT_EMOJIS.stallHard, + }; +} + +export function buildTelegramStatusReactionVariants( + emojis: Required, +): Map { + const variantsByRequested = new Map(); + for (const key of STATUS_REACTION_EMOJI_KEYS) { + const requested = normalizeEmoji(emojis[key]); + if (!requested) { + continue; + } + const fallbackVariants = TELEGRAM_STATUS_REACTION_VARIANTS[key] ?? []; + const candidates = toUniqueNonEmpty([requested, ...fallbackVariants]); + variantsByRequested.set(requested, candidates); + } + return variantsByRequested; +} + +export function isTelegramSupportedReactionEmoji(emoji: string): boolean { + return TELEGRAM_SUPPORTED_REACTION_EMOJIS.has(emoji); +} + +export function resolveTelegramReactionVariant(params: { + requestedEmoji: string; + variantsByRequestedEmoji: Map; +}): string | undefined { + const requestedEmoji = normalizeEmoji(params.requestedEmoji); + if (!requestedEmoji) { + return undefined; + } + + const configuredVariants = params.variantsByRequestedEmoji.get(requestedEmoji) ?? [ + requestedEmoji, + ]; + const variants = toUniqueNonEmpty([ + ...configuredVariants, + ...TELEGRAM_GENERIC_REACTION_FALLBACKS, + ]); + + for (const candidate of variants) { + if (isTelegramSupportedReactionEmoji(candidate)) { + return candidate; + } + } + + return variants[0]; +}