From a18a129c232bfc36d900dfad4ba9a829d23aa06b Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 21 Feb 2026 08:54:28 +0530 Subject: [PATCH] fix: honor Telegram available_reactions for status reactions --- src/telegram/bot-message-context.ts | 18 +++++ src/telegram/status-reaction-variants.test.ts | 71 +++++++++++++++++++ src/telegram/status-reaction-variants.ts | 66 ++++++++++++++++- 3 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index ff07682ed8f..8d9a722c927 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -64,6 +64,7 @@ import type { StickerMetadata, TelegramContext } from "./bot/types.js"; import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; import { buildTelegramStatusReactionVariants, + resolveTelegramAllowedEmojiReactions, resolveTelegramReactionVariant, resolveTelegramStatusReactionEmojis, } from "./status-reaction-variants.js"; @@ -527,9 +528,11 @@ 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; @@ -542,6 +545,7 @@ export const buildTelegramMessageContext = async ({ const statusReactionVariantsByEmoji = buildTelegramStatusReactionVariants( resolvedStatusReactionEmojis, ); + let allowedStatusReactionEmojisPromise: Promise | null> | null = null; const statusReactionController: StatusReactionController | null = statusReactionsEnabled && msg.message_id ? createStatusReactionController({ @@ -549,9 +553,23 @@ export const buildTelegramMessageContext = async ({ adapter: { setReaction: async (emoji: string) => { if (reactionApi) { + 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 new Set(); + }); + } + const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise; const resolvedEmoji = resolveTelegramReactionVariant({ requestedEmoji: emoji, variantsByRequestedEmoji: statusReactionVariantsByEmoji, + allowedEmojiReactions: allowedStatusReactionEmojis, }); if (!resolvedEmoji) { return; diff --git a/src/telegram/status-reaction-variants.test.ts b/src/telegram/status-reaction-variants.test.ts index 3bac083080d..09f865a6348 100644 --- a/src/telegram/status-reaction-variants.test.ts +++ b/src/telegram/status-reaction-variants.test.ts @@ -2,7 +2,9 @@ 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"; @@ -58,6 +60,45 @@ describe("isTelegramSupportedReactionEmoji", () => { }); }); +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(["👍"]); + }); +}); + describe("resolveTelegramReactionVariant", () => { it("returns requested emoji when already Telegram-supported", () => { const variantsByEmoji = buildTelegramStatusReactionVariants({ @@ -96,6 +137,36 @@ describe("resolveTelegramReactionVariant", () => { 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: " ", diff --git a/src/telegram/status-reaction-variants.ts b/src/telegram/status-reaction-variants.ts index 7fa2ccdbe67..fc189f200fa 100644 --- a/src/telegram/status-reaction-variants.ts +++ b/src/telegram/status-reaction-variants.ts @@ -152,9 +152,69 @@ 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) { + const chatInfo = await params.getChat(params.chatId); + const fromLookup = extractTelegramAllowedEmojiReactions(chatInfo); + if (fromLookup !== undefined) { + return fromLookup; + } + } + + // 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) { @@ -170,10 +230,12 @@ export function resolveTelegramReactionVariant(params: { ]); for (const candidate of variants) { - if (isTelegramSupportedReactionEmoji(candidate)) { + const isAllowedByChat = + params.allowedEmojiReactions == null || params.allowedEmojiReactions.has(candidate); + if (isAllowedByChat && isTelegramSupportedReactionEmoji(candidate)) { return candidate; } } - return variants[0]; + return undefined; }