diff --git a/CHANGELOG.md b/CHANGELOG.md index 759ec15ed0d..040d95a694f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Docs: https://docs.openclaw.ai - Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii. - Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal `` tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus. - Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow. +- Telegram/Status reactions: keep lifecycle reactions active when available-reactions lookup fails by falling back to unrestricted variant selection instead of suppressing reaction updates. (#22380) thanks @obviyus. - Discord/Streaming: apply `replyToMode: first` only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report. - Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow. - Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang. diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 416e1f8fcb3..951b381d216 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -62,6 +62,12 @@ import { } from "./bot/helpers.js"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; +import { + buildTelegramStatusReactionVariants, + resolveTelegramAllowedEmojiReactions, + resolveTelegramReactionVariant, + resolveTelegramStatusReactionEmojis, +} from "./status-reaction-variants.js"; export type TelegramMediaRef = { path: string; @@ -522,14 +528,24 @@ export const buildTelegramMessageContext = async ({ messageId: number, reactions: Array<{ type: "emoji"; emoji: string }>, ) => Promise; + getChat?: (chatId: number | string) => Promise; }; const reactionApi = typeof api.setMessageReaction === "function" ? api.setMessageReaction.bind(api) : null; + const getChatApi = typeof api.getChat === "function" ? api.getChat.bind(api) : null; // Status Reactions controller (lifecycle reactions) 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, + ); + let allowedStatusReactionEmojisPromise: Promise | null> | null = null; const statusReactionController: StatusReactionController | null = statusReactionsEnabled && msg.message_id ? createStatusReactionController({ @@ -537,13 +553,36 @@ export const buildTelegramMessageContext = async ({ adapter: { setReaction: async (emoji: string) => { if (reactionApi) { - await reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji }]); + if (!allowedStatusReactionEmojisPromise) { + allowedStatusReactionEmojisPromise = resolveTelegramAllowedEmojiReactions({ + chat: msg.chat, + chatId, + getChat: getChatApi ?? undefined, + }).catch((err) => { + logVerbose( + `telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`, + ); + return null; + }); + } + const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise; + const resolvedEmoji = resolveTelegramReactionVariant({ + requestedEmoji: emoji, + variantsByRequestedEmoji: statusReactionVariantsByEmoji, + allowedEmojiReactions: allowedStatusReactionEmojis, + }); + 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..53d13e60ca8 --- /dev/null +++ b/src/telegram/status-reaction-variants.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_EMOJIS } from "../channels/status-reactions.js"; +import { + buildTelegramStatusReactionVariants, + extractTelegramAllowedEmojiReactions, + isTelegramSupportedReactionEmoji, + resolveTelegramAllowedEmojiReactions, + 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("extractTelegramAllowedEmojiReactions", () => { + it("returns undefined when chat does not include available_reactions", () => { + const result = extractTelegramAllowedEmojiReactions({ id: 1 }); + expect(result).toBeUndefined(); + }); + + it("returns null when available_reactions is omitted/null", () => { + const result = extractTelegramAllowedEmojiReactions({ available_reactions: null }); + expect(result).toBeNull(); + }); + + it("extracts emoji reactions only", () => { + const result = extractTelegramAllowedEmojiReactions({ + available_reactions: [ + { type: "emoji", emoji: "๐Ÿ‘" }, + { type: "custom_emoji", custom_emoji_id: "abc" }, + { type: "emoji", emoji: "๐Ÿ”ฅ" }, + ], + }); + expect(result ? Array.from(result).toSorted() : null).toEqual(["๐Ÿ‘", "๐Ÿ”ฅ"]); + }); +}); + +describe("resolveTelegramAllowedEmojiReactions", () => { + it("uses getChat lookup when message chat does not include available_reactions", async () => { + const getChat = async () => ({ + available_reactions: [{ type: "emoji", emoji: "๐Ÿ‘" }], + }); + + const result = await resolveTelegramAllowedEmojiReactions({ + chat: { id: 1 }, + chatId: 1, + getChat, + }); + + expect(result ? Array.from(result) : null).toEqual(["๐Ÿ‘"]); + }); + + it("falls back to unrestricted reactions when getChat lookup fails", async () => { + const getChat = async () => { + throw new Error("lookup failed"); + }; + + const result = await resolveTelegramAllowedEmojiReactions({ + chat: { id: 1 }, + chatId: 1, + getChat, + }); + + expect(result).toBeNull(); + }); +}); + +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("respects chat allowed reactions", () => { + const variantsByEmoji = buildTelegramStatusReactionVariants({ + ...DEFAULT_EMOJIS, + coding: "๐Ÿ‘จโ€๐Ÿ’ป", + }); + + const result = resolveTelegramReactionVariant({ + requestedEmoji: "๐Ÿ‘จโ€๐Ÿ’ป", + variantsByRequestedEmoji: variantsByEmoji, + allowedEmojiReactions: new Set(["๐Ÿ‘"]), + }); + + expect(result).toBe("๐Ÿ‘"); + }); + + it("returns undefined when no candidate is chat-allowed", () => { + const variantsByEmoji = buildTelegramStatusReactionVariants({ + ...DEFAULT_EMOJIS, + coding: "๐Ÿ‘จโ€๐Ÿ’ป", + }); + + const result = resolveTelegramReactionVariant({ + requestedEmoji: "๐Ÿ‘จโ€๐Ÿ’ป", + variantsByRequestedEmoji: variantsByEmoji, + allowedEmojiReactions: new Set(["๐ŸŽ‰"]), + }); + + expect(result).toBeUndefined(); + }); + + 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..5f79b1cbadb --- /dev/null +++ b/src/telegram/status-reaction-variants.ts @@ -0,0 +1,245 @@ +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 extractTelegramAllowedEmojiReactions( + chat: unknown, +): Set | null | undefined { + if (!chat || typeof chat !== "object") { + return undefined; + } + + if (!Object.prototype.hasOwnProperty.call(chat, "available_reactions")) { + return undefined; + } + + const availableReactions = (chat as { available_reactions?: unknown }).available_reactions; + if (availableReactions == null) { + // Explicitly omitted/null => all emoji reactions are allowed in this chat. + return null; + } + if (!Array.isArray(availableReactions)) { + return new Set(); + } + + const allowed = new Set(); + for (const reaction of availableReactions) { + if (!reaction || typeof reaction !== "object") { + continue; + } + const typedReaction = reaction as { type?: unknown; emoji?: unknown }; + if (typedReaction.type !== "emoji" || typeof typedReaction.emoji !== "string") { + continue; + } + const emoji = typedReaction.emoji.trim(); + if (emoji) { + allowed.add(emoji); + } + } + return allowed; +} + +export async function resolveTelegramAllowedEmojiReactions(params: { + chat: unknown; + chatId: string | number; + getChat?: (chatId: string | number) => Promise; +}): Promise | null> { + const fromMessage = extractTelegramAllowedEmojiReactions(params.chat); + if (fromMessage !== undefined) { + return fromMessage; + } + + if (params.getChat) { + try { + const chatInfo = await params.getChat(params.chatId); + const fromLookup = extractTelegramAllowedEmojiReactions(chatInfo); + if (fromLookup !== undefined) { + return fromLookup; + } + } catch { + return null; + } + } + + // If unavailable, assume no explicit restriction. + return null; +} + +export function resolveTelegramReactionVariant(params: { + requestedEmoji: string; + variantsByRequestedEmoji: Map; + allowedEmojiReactions?: Set | null; +}): 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) { + const isAllowedByChat = + params.allowedEmojiReactions == null || params.allowedEmojiReactions.has(candidate); + if (isAllowedByChat && isTelegramSupportedReactionEmoji(candidate)) { + return candidate; + } + } + + return undefined; +}