feat(telegram): receive and surface user message reactions (#10075)

This commit is contained in:
Glucksberg
2026-02-15 00:39:51 +00:00
committed by Peter Steinberger
parent d3698f4eb6
commit cd4f7524e3
2 changed files with 96 additions and 98 deletions

View File

@@ -1,4 +1,4 @@
import type { Message } from "@grammyjs/types";
import type { Message, ReactionTypeEmoji } from "@grammyjs/types";
import type { TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import type { TelegramContext } from "./bot/types.js";
@@ -18,6 +18,7 @@ import { loadConfig } from "../config/config.js";
import { writeConfigFile } from "../config/io.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { danger, logVerbose, warn } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
@@ -52,6 +53,7 @@ import {
} from "./model-buttons.js";
import { getSentPoll } from "./poll-vote-cache.js";
import { buildInlineKeyboard } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
export const registerTelegramHandlers = ({
cfg,
@@ -459,6 +461,98 @@ export const registerTelegramHandlers = ({
return false;
};
// Handle emoji reactions to messages.
bot.on("message_reaction", async (ctx) => {
try {
const reaction = ctx.messageReaction;
if (!reaction) {
return;
}
if (shouldSkipUpdate(ctx)) {
return;
}
const chatId = reaction.chat.id;
const messageId = reaction.message_id;
const user = reaction.user;
// Resolve reaction notification mode (default: "own").
const reactionMode = telegramCfg.reactionNotifications ?? "own";
if (reactionMode === "off") {
return;
}
if (user?.is_bot) {
return;
}
if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) {
return;
}
// Detect added reactions.
const oldEmojis = new Set(
reaction.old_reaction
.filter((r): r is ReactionTypeEmoji => r.type === "emoji")
.map((r) => r.emoji),
);
const addedReactions = reaction.new_reaction
.filter((r): r is ReactionTypeEmoji => r.type === "emoji")
.filter((r) => !oldEmojis.has(r.emoji));
if (addedReactions.length === 0) {
return;
}
// Build sender label.
const senderName = user
? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username
: undefined;
const senderUsername = user?.username ? `@${user.username}` : undefined;
let senderLabel = senderName;
if (senderName && senderUsername) {
senderLabel = `${senderName} (${senderUsername})`;
} else if (!senderName && senderUsername) {
senderLabel = senderUsername;
}
if (!senderLabel && user?.id) {
senderLabel = `id:${user.id}`;
}
senderLabel = senderLabel || "unknown";
// Reactions target a specific message_id; the Telegram Bot API does not include
// message_thread_id on MessageReactionUpdated, so we route to the chat-level
// session (forum topic routing is not available for reactions).
const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup";
const isForum = reaction.chat.is_forum === true;
const resolvedThreadId = isForum
? resolveTelegramForumThreadId({ isForum, messageThreadId: undefined })
: undefined;
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
// Fresh config for bindings lookup; other routing inputs are payload-derived.
const route = resolveAgentRoute({
cfg: loadConfig(),
channel: "telegram",
accountId,
peer: { kind: isGroup ? "group" : "direct", id: peerId },
parentPeer,
});
const sessionKey = route.sessionKey;
// Enqueue system event for each added reaction.
for (const r of addedReactions) {
const emoji = r.emoji;
const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`;
enqueueSystemEvent(text, {
sessionKey,
contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`,
});
logVerbose(`telegram: reaction event enqueued: ${text}`);
}
} catch (err) {
runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`));
}
});
bot.on("callback_query", async (ctx) => {
const callback = ctx.callbackQuery;
if (!callback) {

View File

@@ -1,7 +1,7 @@
import type { ApiClientOptions } from "grammy";
import { sequentialize } from "@grammyjs/runner";
import { apiThrottler } from "@grammyjs/transformer-throttler";
import { type Message, type UserFromGetMe, ReactionTypeEmoji } from "@grammyjs/types";
import { type Message, type UserFromGetMe } from "@grammyjs/types";
import { Bot, webhookCallback } from "grammy";
import type { OpenClawConfig, ReplyToMode } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -22,10 +22,8 @@ import {
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { formatUncaughtError } from "../infra/errors.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveTelegramAccount } from "./accounts.js";
import { registerTelegramHandlers } from "./bot-handlers.js";
import { createTelegramMessageProcessor } from "./bot-message.js";
@@ -38,12 +36,10 @@ import {
} from "./bot-updates.js";
import {
buildTelegramGroupPeerId,
buildTelegramParentPeer,
resolveTelegramForumThreadId,
resolveTelegramStreamMode,
} from "./bot/helpers.js";
import { resolveTelegramFetch } from "./fetch.js";
import { wasSentByBot } from "./sent-message-cache.js";
export type TelegramBotOptions = {
token: string;
@@ -354,98 +350,6 @@ export function createTelegramBot(opts: TelegramBotOptions) {
opts,
});
// Handle emoji reactions to messages
bot.on("message_reaction", async (ctx) => {
try {
const reaction = ctx.messageReaction;
if (!reaction) {
return;
}
if (shouldSkipUpdate(ctx)) {
return;
}
const chatId = reaction.chat.id;
const messageId = reaction.message_id;
const user = reaction.user;
// Resolve reaction notification mode (default: "own")
const reactionMode = telegramCfg.reactionNotifications ?? "own";
if (reactionMode === "off") {
return;
}
if (user?.is_bot) {
return;
}
if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) {
return;
}
// Detect added reactions
const oldEmojis = new Set(
reaction.old_reaction
.filter((r): r is ReactionTypeEmoji => r.type === "emoji")
.map((r) => r.emoji),
);
const addedReactions = reaction.new_reaction
.filter((r): r is ReactionTypeEmoji => r.type === "emoji")
.filter((r) => !oldEmojis.has(r.emoji));
if (addedReactions.length === 0) {
return;
}
// Build sender label
const senderName = user
? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username
: undefined;
const senderUsername = user?.username ? `@${user.username}` : undefined;
let senderLabel = senderName;
if (senderName && senderUsername) {
senderLabel = `${senderName} (${senderUsername})`;
} else if (!senderName && senderUsername) {
senderLabel = senderUsername;
}
if (!senderLabel && user?.id) {
senderLabel = `id:${user.id}`;
}
senderLabel = senderLabel || "unknown";
// Reactions target a specific message_id; the Telegram Bot API does not include
// message_thread_id on MessageReactionUpdated, so we route to the chat-level
// session (forum topic routing is not available for reactions).
const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup";
const isForum = reaction.chat.is_forum === true;
const resolvedThreadId = isForum
? resolveTelegramForumThreadId({ isForum, messageThreadId: undefined })
: undefined;
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
// Fresh config for bindings lookup; other routing inputs are payload-derived.
const route = resolveAgentRoute({
cfg: loadConfig(),
channel: "telegram",
accountId: account.accountId,
peer: { kind: isGroup ? "group" : "direct", id: peerId },
parentPeer,
});
const sessionKey = route.sessionKey;
// Enqueue system event for each added reaction
for (const r of addedReactions) {
const emoji = r.emoji;
const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`;
enqueueSystemEvent(text, {
sessionKey: sessionKey,
contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`,
});
logVerbose(`telegram: reaction event enqueued: ${text}`);
}
} catch (err) {
runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`));
}
});
registerTelegramHandlers({
cfg,
accountId: account.accountId,