feat: make telegram reactions visible to clawdbot

This commit is contained in:
Bohdan Podvirnyi
2026-01-13 21:13:05 +02:00
committed by Peter Steinberger
parent 01c43b0b0c
commit d05c3d0659
7 changed files with 5915 additions and 3 deletions
+1838
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+102 -2
View File
@@ -17,8 +17,16 @@ import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../config/group-policy.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import {
loadSessionStore,
resolveStorePath,
updateLastRoute,
} from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { createDedupeCache } from "../infra/dedupe.js";
import { formatErrorMessage } from "../infra/errors.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveTelegramAccount } from "./accounts.js";
@@ -34,6 +42,12 @@ import {
type TelegramUpdateKeyContext,
} from "./bot-updates.js";
import { resolveTelegramFetch } from "./fetch.js";
import {
readTelegramAllowFromStore,
upsertTelegramPairingRequest,
} from "./pairing-store.js";
import { wasSentByBot } from "./sent-message-cache.js";
import { resolveTelegramVoiceSend } from "./voice.js";
export type TelegramBotOptions = {
token: string;
@@ -59,8 +73,14 @@ export function getTelegramSequentialKey(ctx: {
message?: TelegramMessage;
edited_message?: TelegramMessage;
callback_query?: { message?: TelegramMessage };
message_reaction?: { chat?: { id?: number } };
};
}): string {
// Handle reaction updates
const reaction = ctx.update?.message_reaction;
if (reaction?.chat?.id) {
return `telegram:${reaction.chat.id}`;
}
const msg =
ctx.message ??
ctx.update?.message ??
@@ -291,6 +311,86 @@ 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;
// For "own" mode, only notify for reactions to bot's messages
if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) {
return;
}
// Detect added reactions
const oldEmojis = new Set(
reaction.old_reaction
.filter(
(r): r is { type: "emoji"; emoji: string } => r.type === "emoji",
)
.map((r) => r.emoji),
);
const addedReactions = reaction.new_reaction
.filter(
(r): r is { type: "emoji"; emoji: string } => 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";
// Resolve agent route for session
const isGroup =
reaction.chat.type === "group" || reaction.chat.type === "supergroup";
const route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId: account.accountId,
peer: { kind: isGroup ? "group" : "dm", id: String(chatId) },
});
// 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: route.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,
+11 -1
View File
@@ -17,7 +17,11 @@ import { loadWebMedia } from "../web/media.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramFetch } from "./fetch.js";
import { markdownToTelegramHtml } from "./format.js";
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
import { recordSentMessage } from "./sent-message-cache.js";
import {
parseTelegramTarget,
stripTelegramInternalPrefixes,
} from "./targets.js";
import { resolveTelegramVoiceSend } from "./voice.js";
type TelegramSendOpts = {
@@ -272,6 +276,9 @@ export async function sendMessageTelegram(
}
const mediaMessageId = String(result?.message_id ?? "unknown");
const resolvedChatId = String(result?.chat?.id ?? chatId);
if (result?.message_id) {
recordSentMessage(chatId, result.message_id);
}
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
@@ -353,6 +360,9 @@ export async function sendMessageTelegram(
},
);
const messageId = String(res?.message_id ?? "unknown");
if (res?.message_id) {
recordSentMessage(chatId, res.message_id);
}
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearSentMessageCache,
recordSentMessage,
wasSentByBot,
} from "./sent-message-cache.js";
describe("sent-message-cache", () => {
afterEach(() => {
clearSentMessageCache();
});
it("records and retrieves sent messages", () => {
recordSentMessage(123, 1);
recordSentMessage(123, 2);
recordSentMessage(456, 10);
expect(wasSentByBot(123, 1)).toBe(true);
expect(wasSentByBot(123, 2)).toBe(true);
expect(wasSentByBot(456, 10)).toBe(true);
expect(wasSentByBot(123, 3)).toBe(false);
expect(wasSentByBot(789, 1)).toBe(false);
});
it("handles string chat IDs", () => {
recordSentMessage("123", 1);
expect(wasSentByBot("123", 1)).toBe(true);
expect(wasSentByBot(123, 1)).toBe(true);
});
it("clears cache", () => {
recordSentMessage(123, 1);
expect(wasSentByBot(123, 1)).toBe(true);
clearSentMessageCache();
expect(wasSentByBot(123, 1)).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* In-memory cache of sent message IDs per chat.
* Used to identify bot's own messages for reaction filtering ("own" mode).
*/
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
type CacheEntry = {
messageIds: Set<number>;
timestamps: Map<number, number>;
};
const sentMessages = new Map<string, CacheEntry>();
function getChatKey(chatId: number | string): string {
return String(chatId);
}
function cleanupExpired(entry: CacheEntry): void {
const now = Date.now();
for (const [msgId, timestamp] of entry.timestamps) {
if (now - timestamp > TTL_MS) {
entry.messageIds.delete(msgId);
entry.timestamps.delete(msgId);
}
}
}
/**
* Record a message ID as sent by the bot.
*/
export function recordSentMessage(
chatId: number | string,
messageId: number,
): void {
const key = getChatKey(chatId);
let entry = sentMessages.get(key);
if (!entry) {
entry = { messageIds: new Set(), timestamps: new Map() };
sentMessages.set(key, entry);
}
entry.messageIds.add(messageId);
entry.timestamps.set(messageId, Date.now());
// Periodic cleanup
if (entry.messageIds.size > 100) {
cleanupExpired(entry);
}
}
/**
* Check if a message was sent by the bot.
*/
export function wasSentByBot(
chatId: number | string,
messageId: number,
): boolean {
const key = getChatKey(chatId);
const entry = sentMessages.get(key);
if (!entry) return false;
// Clean up expired entries on read
cleanupExpired(entry);
return entry.messageIds.has(messageId);
}
/**
* Clear all cached entries (for testing).
*/
export function clearSentMessageCache(): void {
sentMessages.clear();
}