mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 15:01:41 +03:00
feat: make telegram reactions visible to clawdbot
This commit is contained in:
committed by
Peter Steinberger
parent
01c43b0b0c
commit
d05c3d0659
+1838
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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user