mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:20:43 +00:00
feat(telegram): receive and surface user message reactions (#10075)
This commit is contained in:
committed by
Peter Steinberger
parent
d3698f4eb6
commit
cd4f7524e3
@@ -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 { TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
|
||||||
import type { TelegramMediaRef } from "./bot-message-context.js";
|
import type { TelegramMediaRef } from "./bot-message-context.js";
|
||||||
import type { TelegramContext } from "./bot/types.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 { writeConfigFile } from "../config/io.js";
|
||||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||||
import { danger, logVerbose, warn } from "../globals.js";
|
import { danger, logVerbose, warn } from "../globals.js";
|
||||||
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
} from "./model-buttons.js";
|
} from "./model-buttons.js";
|
||||||
import { getSentPoll } from "./poll-vote-cache.js";
|
import { getSentPoll } from "./poll-vote-cache.js";
|
||||||
import { buildInlineKeyboard } from "./send.js";
|
import { buildInlineKeyboard } from "./send.js";
|
||||||
|
import { wasSentByBot } from "./sent-message-cache.js";
|
||||||
|
|
||||||
export const registerTelegramHandlers = ({
|
export const registerTelegramHandlers = ({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -459,6 +461,98 @@ export const registerTelegramHandlers = ({
|
|||||||
return false;
|
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) => {
|
bot.on("callback_query", async (ctx) => {
|
||||||
const callback = ctx.callbackQuery;
|
const callback = ctx.callbackQuery;
|
||||||
if (!callback) {
|
if (!callback) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ApiClientOptions } from "grammy";
|
import type { ApiClientOptions } from "grammy";
|
||||||
import { sequentialize } from "@grammyjs/runner";
|
import { sequentialize } from "@grammyjs/runner";
|
||||||
import { apiThrottler } from "@grammyjs/transformer-throttler";
|
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 { Bot, webhookCallback } from "grammy";
|
||||||
import type { OpenClawConfig, ReplyToMode } from "../config/config.js";
|
import type { OpenClawConfig, ReplyToMode } from "../config/config.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
@@ -22,10 +22,8 @@ import {
|
|||||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { formatUncaughtError } from "../infra/errors.js";
|
import { formatUncaughtError } from "../infra/errors.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
|
||||||
import { resolveTelegramAccount } from "./accounts.js";
|
import { resolveTelegramAccount } from "./accounts.js";
|
||||||
import { registerTelegramHandlers } from "./bot-handlers.js";
|
import { registerTelegramHandlers } from "./bot-handlers.js";
|
||||||
import { createTelegramMessageProcessor } from "./bot-message.js";
|
import { createTelegramMessageProcessor } from "./bot-message.js";
|
||||||
@@ -38,12 +36,10 @@ import {
|
|||||||
} from "./bot-updates.js";
|
} from "./bot-updates.js";
|
||||||
import {
|
import {
|
||||||
buildTelegramGroupPeerId,
|
buildTelegramGroupPeerId,
|
||||||
buildTelegramParentPeer,
|
|
||||||
resolveTelegramForumThreadId,
|
resolveTelegramForumThreadId,
|
||||||
resolveTelegramStreamMode,
|
resolveTelegramStreamMode,
|
||||||
} from "./bot/helpers.js";
|
} from "./bot/helpers.js";
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramFetch } from "./fetch.js";
|
||||||
import { wasSentByBot } from "./sent-message-cache.js";
|
|
||||||
|
|
||||||
export type TelegramBotOptions = {
|
export type TelegramBotOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -354,98 +350,6 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
opts,
|
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({
|
registerTelegramHandlers({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
|
|||||||
Reference in New Issue
Block a user