fix: honor Telegram available_reactions for status reactions

This commit is contained in:
Ayaan Zaidi
2026-02-21 08:54:28 +05:30
parent d28b61dc2d
commit a18a129c23
3 changed files with 153 additions and 2 deletions

View File

@@ -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<void>;
getChat?: (chatId: number | string) => Promise<unknown>;
};
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<Set<string> | 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<string>();
});
}
const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise;
const resolvedEmoji = resolveTelegramReactionVariant({
requestedEmoji: emoji,
variantsByRequestedEmoji: statusReactionVariantsByEmoji,
allowedEmojiReactions: allowedStatusReactionEmojis,
});
if (!resolvedEmoji) {
return;

View File

@@ -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: " ",

View File

@@ -152,9 +152,69 @@ export function isTelegramSupportedReactionEmoji(emoji: string): boolean {
return TELEGRAM_SUPPORTED_REACTION_EMOJIS.has(emoji);
}
export function extractTelegramAllowedEmojiReactions(
chat: unknown,
): Set<string> | 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<string>();
}
const allowed = new Set<string>();
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<unknown>;
}): Promise<Set<string> | 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<string, string[]>;
allowedEmojiReactions?: Set<string> | 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;
}