openclaw/src/telegram/bot-handlers.ts

1537 lines
51 KiB
TypeScript

import type { Message, ReactionTypeEmoji } from "@grammyjs/types";
import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../auto-reply/inbound-debounce.js";
import { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js";
import {
buildModelsProviderData,
formatModelsAvailableHeader,
} from "../auto-reply/reply/commands-models.js";
import { resolveStoredModelOverride } from "../auto-reply/reply/model-selection.js";
import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
import { buildCommandsMessagePaginated } from "../auto-reply/status.js";
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
import { loadConfig } from "../config/config.js";
import { writeConfigFile } from "../config/io.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import type { DmPolicy } from "../config/types.base.js";
import type {
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "../config/types.js";
import { danger, logVerbose, warn } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { MediaFetchError } from "../media/fetch.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import {
isSenderAllowed,
normalizeDmAllowFromWithStore,
type NormalizedAllowFrom,
} from "./bot-access.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
MEDIA_GROUP_TIMEOUT_MS,
type MediaGroupEntry,
type TelegramUpdateKeyContext,
} from "./bot-updates.js";
import { resolveMedia } from "./bot/delivery.js";
import {
buildTelegramGroupPeerId,
buildTelegramParentPeer,
resolveTelegramForumThreadId,
resolveTelegramGroupAllowFromContext,
} from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import { enforceTelegramDmAccess } from "./dm-access.js";
import {
evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess,
} from "./group-access.js";
import { migrateTelegramGroupConfig } from "./group-migration.js";
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
import {
buildModelsKeyboard,
buildProviderKeyboard,
calculateTotalPages,
getModelsPageSize,
parseModelCallbackData,
resolveModelSelection,
type ProviderInfo,
} from "./model-buttons.js";
import { buildInlineKeyboard } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
function isMediaSizeLimitError(err: unknown): boolean {
const errMsg = String(err);
return errMsg.includes("exceeds") && errMsg.includes("MB limit");
}
function isRecoverableMediaGroupError(err: unknown): boolean {
return err instanceof MediaFetchError || isMediaSizeLimitError(err);
}
function hasInboundMedia(msg: Message): boolean {
return (
Boolean(msg.media_group_id) ||
(Array.isArray(msg.photo) && msg.photo.length > 0) ||
Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker)
);
}
function hasReplyTargetMedia(msg: Message): boolean {
const externalReply = (msg as Message & { external_reply?: Message }).external_reply;
const replyTarget = msg.reply_to_message ?? externalReply;
return Boolean(replyTarget && hasInboundMedia(replyTarget));
}
function resolveInboundMediaFileId(msg: Message): string | undefined {
return (
msg.sticker?.file_id ??
msg.photo?.[msg.photo.length - 1]?.file_id ??
msg.video?.file_id ??
msg.video_note?.file_id ??
msg.document?.file_id ??
msg.audio?.file_id ??
msg.voice?.file_id
);
}
export const registerTelegramHandlers = ({
cfg,
accountId,
bot,
opts,
runtime,
mediaMaxBytes,
telegramCfg,
allowFrom,
groupAllowFrom,
resolveGroupPolicy,
resolveTelegramGroupConfig,
shouldSkipUpdate,
processMessage,
logger,
}: RegisterTelegramHandlerParams) => {
const DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS = 1500;
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS =
typeof opts.testTimings?.textFragmentGapMs === "number" &&
Number.isFinite(opts.testTimings.textFragmentGapMs)
? Math.max(10, Math.floor(opts.testTimings.textFragmentGapMs))
: DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS;
const TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP = 1;
const TELEGRAM_TEXT_FRAGMENT_MAX_PARTS = 12;
const TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS = 50_000;
const mediaGroupTimeoutMs =
typeof opts.testTimings?.mediaGroupFlushMs === "number" &&
Number.isFinite(opts.testTimings.mediaGroupFlushMs)
? Math.max(10, Math.floor(opts.testTimings.mediaGroupFlushMs))
: MEDIA_GROUP_TIMEOUT_MS;
const mediaGroupBuffer = new Map<string, MediaGroupEntry>();
let mediaGroupProcessing: Promise<void> = Promise.resolve();
type TextFragmentEntry = {
key: string;
messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>;
timer: ReturnType<typeof setTimeout>;
};
const textFragmentBuffer = new Map<string, TextFragmentEntry>();
let textFragmentProcessing: Promise<void> = Promise.resolve();
const debounceMs = resolveInboundDebounceMs({ cfg, channel: "telegram" });
const FORWARD_BURST_DEBOUNCE_MS = 80;
type TelegramDebounceLane = "default" | "forward";
type TelegramDebounceEntry = {
ctx: TelegramContext;
msg: Message;
allMedia: TelegramMediaRef[];
storeAllowFrom: string[];
debounceKey: string | null;
debounceLane: TelegramDebounceLane;
botUsername?: string;
};
const resolveTelegramDebounceLane = (msg: Message): TelegramDebounceLane => {
const forwardMeta = msg as {
forward_origin?: unknown;
forward_from?: unknown;
forward_from_chat?: unknown;
forward_sender_name?: unknown;
forward_date?: unknown;
};
return (forwardMeta.forward_origin ??
forwardMeta.forward_from ??
forwardMeta.forward_from_chat ??
forwardMeta.forward_sender_name ??
forwardMeta.forward_date)
? "forward"
: "default";
};
const buildSyntheticTextMessage = (params: {
base: Message;
text: string;
date?: number;
from?: Message["from"];
}): Message => ({
...params.base,
...(params.from ? { from: params.from } : {}),
text: params.text,
caption: undefined,
caption_entities: undefined,
entities: undefined,
...(params.date != null ? { date: params.date } : {}),
});
const buildSyntheticContext = (
ctx: Pick<TelegramContext, "me"> & { getFile?: unknown },
message: Message,
): TelegramContext => {
const getFile =
typeof ctx.getFile === "function"
? (ctx.getFile as TelegramContext["getFile"]).bind(ctx as object)
: async () => ({});
return { message, me: ctx.me, getFile };
};
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
debounceMs,
resolveDebounceMs: (entry) =>
entry.debounceLane === "forward" ? FORWARD_BURST_DEBOUNCE_MS : debounceMs,
buildKey: (entry) => entry.debounceKey,
shouldDebounce: (entry) => {
const text = entry.msg.text ?? entry.msg.caption ?? "";
const hasText = text.trim().length > 0;
if (hasText && hasControlCommand(text, cfg, { botUsername: entry.botUsername })) {
return false;
}
if (entry.debounceLane === "forward") {
return true;
}
return entry.allMedia.length === 0 && hasText;
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
if (entries.length === 1) {
const replyMedia = await resolveReplyMediaForMessage(last.ctx, last.msg);
await processMessage(last.ctx, last.allMedia, last.storeAllowFrom, undefined, replyMedia);
return;
}
const combinedText = entries
.map((entry) => entry.msg.text ?? entry.msg.caption ?? "")
.filter(Boolean)
.join("\n");
const combinedMedia = entries.flatMap((entry) => entry.allMedia);
if (!combinedText.trim() && combinedMedia.length === 0) {
return;
}
const first = entries[0];
const baseCtx = first.ctx;
const syntheticMessage = buildSyntheticTextMessage({
base: first.msg,
text: combinedText,
date: last.msg.date ?? first.msg.date,
});
const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined;
const syntheticCtx = buildSyntheticContext(baseCtx, syntheticMessage);
const replyMedia = await resolveReplyMediaForMessage(baseCtx, syntheticMessage);
await processMessage(
syntheticCtx,
combinedMedia,
first.storeAllowFrom,
messageIdOverride ? { messageIdOverride } : undefined,
replyMedia,
);
},
onError: (err) => {
runtime.error?.(danger(`telegram debounce flush failed: ${String(err)}`));
},
});
const resolveTelegramSessionState = (params: {
chatId: number | string;
isGroup: boolean;
isForum: boolean;
messageThreadId?: number;
resolvedThreadId?: number;
}): {
agentId: string;
sessionEntry: ReturnType<typeof loadSessionStore>[string];
model?: string;
} => {
const resolvedThreadId =
params.resolvedThreadId ??
resolveTelegramForumThreadId({
isForum: params.isForum,
messageThreadId: params.messageThreadId,
});
const peerId = params.isGroup
? buildTelegramGroupPeerId(params.chatId, resolvedThreadId)
: String(params.chatId);
const parentPeer = buildTelegramParentPeer({
isGroup: params.isGroup,
resolvedThreadId,
chatId: params.chatId,
});
const route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId,
peer: {
kind: params.isGroup ? "group" : "direct",
id: peerId,
},
parentPeer,
});
const baseSessionKey = route.sessionKey;
const dmThreadId = !params.isGroup ? params.messageThreadId : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` })
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId });
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
const storedOverride = resolveStoredModelOverride({
sessionEntry: entry,
sessionStore: store,
sessionKey,
});
if (storedOverride) {
return {
agentId: route.agentId,
sessionEntry: entry,
model: storedOverride.provider
? `${storedOverride.provider}/${storedOverride.model}`
: storedOverride.model,
};
}
const provider = entry?.modelProvider?.trim();
const model = entry?.model?.trim();
if (provider && model) {
return {
agentId: route.agentId,
sessionEntry: entry,
model: `${provider}/${model}`,
};
}
const modelCfg = cfg.agents?.defaults?.model;
return {
agentId: route.agentId,
sessionEntry: entry,
model: typeof modelCfg === "string" ? modelCfg : modelCfg?.primary,
};
};
const processMediaGroup = async (entry: MediaGroupEntry) => {
try {
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
const captionMsg = entry.messages.find((m) => m.msg.caption || m.msg.text);
const primaryEntry = captionMsg ?? entry.messages[0];
const allMedia: TelegramMediaRef[] = [];
for (const { ctx } of entry.messages) {
let media;
try {
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch);
} catch (mediaErr) {
if (!isRecoverableMediaGroupError(mediaErr)) {
throw mediaErr;
}
runtime.log?.(
warn(`media group: skipping photo that failed to fetch: ${String(mediaErr)}`),
);
continue;
}
if (media) {
allMedia.push({
path: media.path,
contentType: media.contentType,
stickerMetadata: media.stickerMetadata,
});
}
}
const storeAllowFrom = await loadStoreAllowFrom();
const replyMedia = await resolveReplyMediaForMessage(primaryEntry.ctx, primaryEntry.msg);
await processMessage(primaryEntry.ctx, allMedia, storeAllowFrom, undefined, replyMedia);
} catch (err) {
runtime.error?.(danger(`media group handler failed: ${String(err)}`));
}
};
const flushTextFragments = async (entry: TextFragmentEntry) => {
try {
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
const first = entry.messages[0];
const last = entry.messages.at(-1);
if (!first || !last) {
return;
}
const combinedText = entry.messages.map((m) => m.msg.text ?? "").join("");
if (!combinedText.trim()) {
return;
}
const syntheticMessage = buildSyntheticTextMessage({
base: first.msg,
text: combinedText,
date: last.msg.date ?? first.msg.date,
});
const storeAllowFrom = await loadStoreAllowFrom();
const baseCtx = first.ctx;
await processMessage(buildSyntheticContext(baseCtx, syntheticMessage), [], storeAllowFrom, {
messageIdOverride: String(last.msg.message_id),
});
} catch (err) {
runtime.error?.(danger(`text fragment handler failed: ${String(err)}`));
}
};
const queueTextFragmentFlush = async (entry: TextFragmentEntry) => {
textFragmentProcessing = textFragmentProcessing
.then(async () => {
await flushTextFragments(entry);
})
.catch(() => undefined);
await textFragmentProcessing;
};
const runTextFragmentFlush = async (entry: TextFragmentEntry) => {
textFragmentBuffer.delete(entry.key);
await queueTextFragmentFlush(entry);
};
const scheduleTextFragmentFlush = (entry: TextFragmentEntry) => {
clearTimeout(entry.timer);
entry.timer = setTimeout(async () => {
await runTextFragmentFlush(entry);
}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS);
};
const loadStoreAllowFrom = async () =>
readChannelAllowFromStore("telegram", process.env, accountId).catch(() => []);
const resolveReplyMediaForMessage = async (
ctx: TelegramContext,
msg: Message,
): Promise<TelegramMediaRef[]> => {
const replyMessage = msg.reply_to_message;
if (!replyMessage || !hasInboundMedia(replyMessage)) {
return [];
}
const replyFileId = resolveInboundMediaFileId(replyMessage);
if (!replyFileId) {
return [];
}
try {
const media = await resolveMedia(
{
message: replyMessage,
me: ctx.me,
getFile: async () => await bot.api.getFile(replyFileId),
},
mediaMaxBytes,
opts.token,
opts.proxyFetch,
);
if (!media) {
return [];
}
return [
{
path: media.path,
contentType: media.contentType,
stickerMetadata: media.stickerMetadata,
},
];
} catch (err) {
logger.warn({ chatId: msg.chat.id, error: String(err) }, "reply media fetch failed");
return [];
}
};
const isAllowlistAuthorized = (
allow: NormalizedAllowFrom,
senderId: string,
senderUsername: string,
) =>
allow.hasWildcard ||
(allow.hasEntries &&
isSenderAllowed({
allow,
senderId,
senderUsername,
}));
const shouldSkipGroupMessage = (params: {
isGroup: boolean;
chatId: string | number;
chatTitle?: string;
resolvedThreadId?: number;
senderId: string;
senderUsername: string;
effectiveGroupAllow: NormalizedAllowFrom;
hasGroupAllowOverride: boolean;
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
}) => {
const {
isGroup,
chatId,
chatTitle,
resolvedThreadId,
senderId,
senderUsername,
effectiveGroupAllow,
hasGroupAllowOverride,
groupConfig,
topicConfig,
} = params;
const baseAccess = evaluateTelegramGroupBaseAccess({
isGroup,
groupConfig,
topicConfig,
hasGroupAllowOverride,
effectiveGroupAllow,
senderId,
senderUsername,
enforceAllowOverride: true,
requireSenderForAllowOverride: true,
});
if (!baseAccess.allowed) {
if (baseAccess.reason === "group-disabled") {
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
return true;
}
if (baseAccess.reason === "topic-disabled") {
logVerbose(
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
);
return true;
}
logVerbose(
`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`,
);
return true;
}
if (!isGroup) {
return false;
}
const policyAccess = evaluateTelegramGroupPolicyAccess({
isGroup,
chatId,
cfg,
telegramCfg,
topicConfig,
groupConfig,
effectiveGroupAllow,
senderId,
senderUsername,
resolveGroupPolicy,
enforcePolicy: true,
useTopicAndGroupOverrides: true,
enforceAllowlistAuthorization: true,
allowEmptyAllowlistEntries: false,
requireSenderForAllowlistAuthorization: true,
checkChatAllowlist: true,
});
if (!policyAccess.allowed) {
if (policyAccess.reason === "group-policy-disabled") {
logVerbose("Blocked telegram group message (groupPolicy: disabled)");
return true;
}
if (policyAccess.reason === "group-policy-allowlist-no-sender") {
logVerbose("Blocked telegram group message (no sender ID, groupPolicy: allowlist)");
return true;
}
if (policyAccess.reason === "group-policy-allowlist-empty") {
logVerbose(
"Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)",
);
return true;
}
if (policyAccess.reason === "group-policy-allowlist-unauthorized") {
logVerbose(`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`);
return true;
}
logger.info({ chatId, title: chatTitle, reason: "not-allowed" }, "skipping group message");
return true;
}
return false;
};
type TelegramGroupAllowContext = Awaited<ReturnType<typeof resolveTelegramGroupAllowFromContext>>;
type TelegramEventAuthorizationMode = "reaction" | "callback-scope" | "callback-allowlist";
type TelegramEventAuthorizationResult = { allowed: true } | { allowed: false; reason: string };
type TelegramEventAuthorizationContext = TelegramGroupAllowContext & { dmPolicy: DmPolicy };
const TELEGRAM_EVENT_AUTH_RULES: Record<
TelegramEventAuthorizationMode,
{
enforceDirectAuthorization: boolean;
enforceGroupAllowlistAuthorization: boolean;
deniedDmReason: string;
deniedGroupReason: string;
}
> = {
reaction: {
enforceDirectAuthorization: true,
enforceGroupAllowlistAuthorization: false,
deniedDmReason: "reaction unauthorized by dm policy/allowlist",
deniedGroupReason: "reaction unauthorized by group allowlist",
},
"callback-scope": {
enforceDirectAuthorization: false,
enforceGroupAllowlistAuthorization: false,
deniedDmReason: "callback unauthorized by inlineButtonsScope",
deniedGroupReason: "callback unauthorized by inlineButtonsScope",
},
"callback-allowlist": {
enforceDirectAuthorization: true,
// Group auth is already enforced by shouldSkipGroupMessage (group policy + allowlist).
// An extra allowlist gate here would block users whose original command was authorized.
enforceGroupAllowlistAuthorization: false,
deniedDmReason: "callback unauthorized by inlineButtonsScope allowlist",
deniedGroupReason: "callback unauthorized by inlineButtonsScope allowlist",
},
};
const resolveTelegramEventAuthorizationContext = async (params: {
chatId: number;
isGroup: boolean;
isForum: boolean;
messageThreadId?: number;
groupAllowContext?: TelegramGroupAllowContext;
}): Promise<TelegramEventAuthorizationContext> => {
const groupAllowContext =
params.groupAllowContext ??
(await resolveTelegramGroupAllowFromContext({
chatId: params.chatId,
accountId,
isGroup: params.isGroup,
isForum: params.isForum,
messageThreadId: params.messageThreadId,
groupAllowFrom,
resolveTelegramGroupConfig,
}));
// Use direct config dmPolicy override if available for DMs
const effectiveDmPolicy =
!params.isGroup &&
groupAllowContext.groupConfig &&
"dmPolicy" in groupAllowContext.groupConfig
? (groupAllowContext.groupConfig.dmPolicy ?? telegramCfg.dmPolicy ?? "pairing")
: (telegramCfg.dmPolicy ?? "pairing");
return { dmPolicy: effectiveDmPolicy, ...groupAllowContext };
};
const authorizeTelegramEventSender = (params: {
chatId: number;
chatTitle?: string;
isGroup: boolean;
senderId: string;
senderUsername: string;
mode: TelegramEventAuthorizationMode;
context: TelegramEventAuthorizationContext;
}): TelegramEventAuthorizationResult => {
const { chatId, chatTitle, isGroup, senderId, senderUsername, mode, context } = params;
const {
dmPolicy,
resolvedThreadId,
storeAllowFrom,
groupConfig,
topicConfig,
groupAllowOverride,
effectiveGroupAllow,
hasGroupAllowOverride,
} = context;
const authRules = TELEGRAM_EVENT_AUTH_RULES[mode];
const {
enforceDirectAuthorization,
enforceGroupAllowlistAuthorization,
deniedDmReason,
deniedGroupReason,
} = authRules;
if (
shouldSkipGroupMessage({
isGroup,
chatId,
chatTitle,
resolvedThreadId,
senderId,
senderUsername,
effectiveGroupAllow,
hasGroupAllowOverride,
groupConfig,
topicConfig,
})
) {
return { allowed: false, reason: "group-policy" };
}
if (!isGroup && enforceDirectAuthorization) {
if (dmPolicy === "disabled") {
logVerbose(
`Blocked telegram direct event from ${senderId || "unknown"} (${deniedDmReason})`,
);
return { allowed: false, reason: "direct-disabled" };
}
if (dmPolicy !== "open") {
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
const dmAllowFrom = groupAllowOverride ?? allowFrom;
const effectiveDmAllow = normalizeDmAllowFromWithStore({
allowFrom: dmAllowFrom,
storeAllowFrom,
dmPolicy,
});
if (!isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername)) {
logVerbose(`Blocked telegram direct sender ${senderId || "unknown"} (${deniedDmReason})`);
return { allowed: false, reason: "direct-unauthorized" };
}
}
}
if (isGroup && enforceGroupAllowlistAuthorization) {
if (!isAllowlistAuthorized(effectiveGroupAllow, senderId, senderUsername)) {
logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (${deniedGroupReason})`);
return { allowed: false, reason: "group-unauthorized" };
}
}
return { allowed: true };
};
// 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;
const senderId = user?.id != null ? String(user.id) : "";
const senderUsername = user?.username ?? "";
const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup";
const isForum = reaction.chat.is_forum === true;
// 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;
}
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
chatId,
isGroup,
isForum,
});
const senderAuthorization = authorizeTelegramEventSender({
chatId,
chatTitle: reaction.chat.title,
isGroup,
senderId,
senderUsername,
mode: "reaction",
context: eventAuthContext,
});
if (!senderAuthorization.allowed) {
return;
}
// Enforce requireTopic for DM reactions: since Telegram doesn't provide messageThreadId
// for reactions, we cannot determine if the reaction came from a topic, so block all
// reactions if requireTopic is enabled for this DM.
if (!isGroup) {
const requireTopic = (eventAuthContext.groupConfig as TelegramDirectConfig | undefined)
?.requireTopic;
if (requireTopic === true) {
logVerbose(
`Blocked telegram reaction in DM ${chatId}: requireTopic=true but topic unknown for reactions`,
);
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 senderUsernameLabel = user?.username ? `@${user.username}` : undefined;
let senderLabel = senderName;
if (senderName && senderUsernameLabel) {
senderLabel = `${senderName} (${senderUsernameLabel})`;
} else if (!senderName && senderUsernameLabel) {
senderLabel = senderUsernameLabel;
}
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 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)}`));
}
});
const processInboundMessage = async (params: {
ctx: TelegramContext;
msg: Message;
chatId: number;
resolvedThreadId?: number;
dmThreadId?: number;
storeAllowFrom: string[];
sendOversizeWarning: boolean;
oversizeLogMessage: string;
}) => {
const {
ctx,
msg,
chatId,
resolvedThreadId,
dmThreadId,
storeAllowFrom,
sendOversizeWarning,
oversizeLogMessage,
} = params;
// Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars).
// We buffer “near-limit” messages and append immediately-following parts.
const text = typeof msg.text === "string" ? msg.text : undefined;
const isCommandLike = (text ?? "").trim().startsWith("/");
if (text && !isCommandLike) {
const nowMs = Date.now();
const senderId = msg.from?.id != null ? String(msg.from.id) : "unknown";
// Use resolvedThreadId for forum groups, dmThreadId for DM topics
const threadId = resolvedThreadId ?? dmThreadId;
const key = `text:${chatId}:${threadId ?? "main"}:${senderId}`;
const existing = textFragmentBuffer.get(key);
if (existing) {
const last = existing.messages.at(-1);
const lastMsgId = last?.msg.message_id;
const lastReceivedAtMs = last?.receivedAtMs ?? nowMs;
const idGap = typeof lastMsgId === "number" ? msg.message_id - lastMsgId : Infinity;
const timeGapMs = nowMs - lastReceivedAtMs;
const canAppend =
idGap > 0 &&
idGap <= TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP &&
timeGapMs >= 0 &&
timeGapMs <= TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS;
if (canAppend) {
const currentTotalChars = existing.messages.reduce(
(sum, m) => sum + (m.msg.text?.length ?? 0),
0,
);
const nextTotalChars = currentTotalChars + text.length;
if (
existing.messages.length + 1 <= TELEGRAM_TEXT_FRAGMENT_MAX_PARTS &&
nextTotalChars <= TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS
) {
existing.messages.push({ msg, ctx, receivedAtMs: nowMs });
scheduleTextFragmentFlush(existing);
return;
}
}
// Not appendable (or limits exceeded): flush buffered entry first, then continue normally.
clearTimeout(existing.timer);
textFragmentBuffer.delete(key);
textFragmentProcessing = textFragmentProcessing
.then(async () => {
await flushTextFragments(existing);
})
.catch(() => undefined);
await textFragmentProcessing;
}
const shouldStart = text.length >= TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS;
if (shouldStart) {
const entry: TextFragmentEntry = {
key,
messages: [{ msg, ctx, receivedAtMs: nowMs }],
timer: setTimeout(() => {}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS),
};
textFragmentBuffer.set(key, entry);
scheduleTextFragmentFlush(entry);
return;
}
}
// Media group handling - buffer multi-image messages
const mediaGroupId = msg.media_group_id;
if (mediaGroupId) {
const existing = mediaGroupBuffer.get(mediaGroupId);
if (existing) {
clearTimeout(existing.timer);
existing.messages.push({ msg, ctx });
existing.timer = setTimeout(async () => {
mediaGroupBuffer.delete(mediaGroupId);
mediaGroupProcessing = mediaGroupProcessing
.then(async () => {
await processMediaGroup(existing);
})
.catch(() => undefined);
await mediaGroupProcessing;
}, mediaGroupTimeoutMs);
} else {
const entry: MediaGroupEntry = {
messages: [{ msg, ctx }],
timer: setTimeout(async () => {
mediaGroupBuffer.delete(mediaGroupId);
mediaGroupProcessing = mediaGroupProcessing
.then(async () => {
await processMediaGroup(entry);
})
.catch(() => undefined);
await mediaGroupProcessing;
}, mediaGroupTimeoutMs),
};
mediaGroupBuffer.set(mediaGroupId, entry);
}
return;
}
let media: Awaited<ReturnType<typeof resolveMedia>> = null;
try {
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch);
} catch (mediaErr) {
if (isMediaSizeLimitError(mediaErr)) {
if (sendOversizeWarning) {
const limitMb = Math.round(mediaMaxBytes / (1024 * 1024));
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () =>
bot.api.sendMessage(chatId, `⚠️ File too large. Maximum size is ${limitMb}MB.`, {
reply_to_message_id: msg.message_id,
}),
}).catch(() => {});
}
logger.warn({ chatId, error: String(mediaErr) }, oversizeLogMessage);
return;
}
logger.warn({ chatId, error: String(mediaErr) }, "media fetch failed");
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () =>
bot.api.sendMessage(chatId, "⚠️ Failed to download media. Please try again.", {
reply_to_message_id: msg.message_id,
}),
}).catch(() => {});
return;
}
// Skip sticker-only messages where the sticker was skipped (animated/video)
// These have no media and no text content to process.
const hasText = Boolean((msg.text ?? msg.caption ?? "").trim());
if (msg.sticker && !media && !hasText) {
logVerbose("telegram: skipping sticker-only message (unsupported sticker type)");
return;
}
const allMedia = media
? [
{
path: media.path,
contentType: media.contentType,
stickerMetadata: media.stickerMetadata,
},
]
: [];
const senderId = msg.from?.id ? String(msg.from.id) : "";
const conversationThreadId = resolvedThreadId ?? dmThreadId;
const conversationKey =
conversationThreadId != null ? `${chatId}:topic:${conversationThreadId}` : String(chatId);
const debounceLane = resolveTelegramDebounceLane(msg);
const debounceKey = senderId
? `telegram:${accountId ?? "default"}:${conversationKey}:${senderId}:${debounceLane}`
: null;
await inboundDebouncer.enqueue({
ctx,
msg,
allMedia,
storeAllowFrom,
debounceKey,
debounceLane,
botUsername: ctx.me?.username,
});
};
bot.on("callback_query", async (ctx) => {
const callback = ctx.callbackQuery;
if (!callback) {
return;
}
if (shouldSkipUpdate(ctx)) {
return;
}
const answerCallbackQuery =
typeof (ctx as { answerCallbackQuery?: unknown }).answerCallbackQuery === "function"
? () => ctx.answerCallbackQuery()
: () => bot.api.answerCallbackQuery(callback.id);
// Answer immediately to prevent Telegram from retrying while we process
await withTelegramApiErrorLogging({
operation: "answerCallbackQuery",
runtime,
fn: answerCallbackQuery,
}).catch(() => {});
try {
const data = (callback.data ?? "").trim();
const callbackMessage = callback.message;
if (!data || !callbackMessage) {
return;
}
const editCallbackMessage = async (
text: string,
params?: Parameters<typeof bot.api.editMessageText>[3],
) => {
const editTextFn = (ctx as { editMessageText?: unknown }).editMessageText;
if (typeof editTextFn === "function") {
return await ctx.editMessageText(text, params);
}
return await bot.api.editMessageText(
callbackMessage.chat.id,
callbackMessage.message_id,
text,
params,
);
};
const deleteCallbackMessage = async () => {
const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage;
if (typeof deleteFn === "function") {
return await ctx.deleteMessage();
}
return await bot.api.deleteMessage(callbackMessage.chat.id, callbackMessage.message_id);
};
const replyToCallbackChat = async (
text: string,
params?: Parameters<typeof bot.api.sendMessage>[2],
) => {
const replyFn = (ctx as { reply?: unknown }).reply;
if (typeof replyFn === "function") {
return await ctx.reply(text, params);
}
return await bot.api.sendMessage(callbackMessage.chat.id, text, params);
};
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
accountId,
});
if (inlineButtonsScope === "off") {
return;
}
const chatId = callbackMessage.chat.id;
const isGroup =
callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup";
if (inlineButtonsScope === "dm" && isGroup) {
return;
}
if (inlineButtonsScope === "group" && !isGroup) {
return;
}
const messageThreadId = callbackMessage.message_thread_id;
const isForum = callbackMessage.chat.is_forum === true;
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
chatId,
isGroup,
isForum,
messageThreadId,
});
const { resolvedThreadId, dmThreadId, storeAllowFrom, groupConfig } = eventAuthContext;
const requireTopic = (groupConfig as { requireTopic?: boolean } | undefined)?.requireTopic;
if (!isGroup && requireTopic === true && dmThreadId == null) {
logVerbose(
`Blocked telegram callback in DM ${chatId}: requireTopic=true but no topic present`,
);
return;
}
const senderId = callback.from?.id ? String(callback.from.id) : "";
const senderUsername = callback.from?.username ?? "";
const authorizationMode: TelegramEventAuthorizationMode =
inlineButtonsScope === "allowlist" ? "callback-allowlist" : "callback-scope";
const senderAuthorization = authorizeTelegramEventSender({
chatId,
chatTitle: callbackMessage.chat.title,
isGroup,
senderId,
senderUsername,
mode: authorizationMode,
context: eventAuthContext,
});
if (!senderAuthorization.allowed) {
return;
}
const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/);
if (paginationMatch) {
const pageValue = paginationMatch[1];
if (pageValue === "noop") {
return;
}
const page = Number.parseInt(pageValue, 10);
if (Number.isNaN(page) || page < 1) {
return;
}
const agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(cfg) || undefined;
const skillCommands = listSkillCommandsForAgents({
cfg,
agentIds: agentId ? [agentId] : undefined,
});
const result = buildCommandsMessagePaginated(cfg, skillCommands, {
page,
surface: "telegram",
});
const keyboard =
result.totalPages > 1
? buildInlineKeyboard(
buildCommandsPaginationKeyboard(result.currentPage, result.totalPages, agentId),
)
: undefined;
try {
await editCallbackMessage(result.text, keyboard ? { reply_markup: keyboard } : undefined);
} catch (editErr) {
const errStr = String(editErr);
if (!errStr.includes("message is not modified")) {
throw editErr;
}
}
return;
}
// Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back)
const modelCallback = parseModelCallbackData(data);
if (modelCallback) {
const modelData = await buildModelsProviderData(cfg);
const { byProvider, providers } = modelData;
const editMessageWithButtons = async (
text: string,
buttons: ReturnType<typeof buildProviderKeyboard>,
) => {
const keyboard = buildInlineKeyboard(buttons);
try {
await editCallbackMessage(text, keyboard ? { reply_markup: keyboard } : undefined);
} catch (editErr) {
const errStr = String(editErr);
if (errStr.includes("no text in the message")) {
try {
await deleteCallbackMessage();
} catch {}
await replyToCallbackChat(text, keyboard ? { reply_markup: keyboard } : undefined);
} else if (!errStr.includes("message is not modified")) {
throw editErr;
}
}
};
if (modelCallback.type === "providers" || modelCallback.type === "back") {
if (providers.length === 0) {
await editMessageWithButtons("No providers available.", []);
return;
}
const providerInfos: ProviderInfo[] = providers.map((p) => ({
id: p,
count: byProvider.get(p)?.size ?? 0,
}));
const buttons = buildProviderKeyboard(providerInfos);
await editMessageWithButtons("Select a provider:", buttons);
return;
}
if (modelCallback.type === "list") {
const { provider, page } = modelCallback;
const modelSet = byProvider.get(provider);
if (!modelSet || modelSet.size === 0) {
// Provider not found or no models - show providers list
const providerInfos: ProviderInfo[] = providers.map((p) => ({
id: p,
count: byProvider.get(p)?.size ?? 0,
}));
const buttons = buildProviderKeyboard(providerInfos);
await editMessageWithButtons(
`Unknown provider: ${provider}\n\nSelect a provider:`,
buttons,
);
return;
}
const models = [...modelSet].toSorted();
const pageSize = getModelsPageSize();
const totalPages = calculateTotalPages(models.length, pageSize);
const safePage = Math.max(1, Math.min(page, totalPages));
// Resolve current model from session (prefer overrides)
const sessionState = resolveTelegramSessionState({
chatId,
isGroup,
isForum,
messageThreadId,
resolvedThreadId,
});
const currentModel = sessionState.model;
const buttons = buildModelsKeyboard({
provider,
models,
currentModel,
currentPage: safePage,
totalPages,
pageSize,
});
const text = formatModelsAvailableHeader({
provider,
total: models.length,
cfg,
agentDir: resolveAgentDir(cfg, sessionState.agentId),
sessionEntry: sessionState.sessionEntry,
});
await editMessageWithButtons(text, buttons);
return;
}
if (modelCallback.type === "select") {
const selection = resolveModelSelection({
callback: modelCallback,
providers,
byProvider,
});
if (selection.kind !== "resolved") {
const providerInfos: ProviderInfo[] = providers.map((p) => ({
id: p,
count: byProvider.get(p)?.size ?? 0,
}));
const buttons = buildProviderKeyboard(providerInfos);
await editMessageWithButtons(
`Could not resolve model "${selection.model}".\n\nSelect a provider:`,
buttons,
);
return;
}
// Process model selection as a synthetic message with /model command
const syntheticMessage = buildSyntheticTextMessage({
base: callbackMessage,
from: callback.from,
text: `/model ${selection.provider}/${selection.model}`,
});
await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, {
forceWasMentioned: true,
messageIdOverride: callback.id,
});
return;
}
return;
}
const syntheticMessage = buildSyntheticTextMessage({
base: callbackMessage,
from: callback.from,
text: data,
});
await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, {
forceWasMentioned: true,
messageIdOverride: callback.id,
});
} catch (err) {
runtime.error?.(danger(`callback handler failed: ${String(err)}`));
}
});
// Handle group migration to supergroup (chat ID changes)
bot.on("message:migrate_to_chat_id", async (ctx) => {
try {
const msg = ctx.message;
if (!msg?.migrate_to_chat_id) {
return;
}
if (shouldSkipUpdate(ctx)) {
return;
}
const oldChatId = String(msg.chat.id);
const newChatId = String(msg.migrate_to_chat_id);
const chatTitle = msg.chat.title ?? "Unknown";
runtime.log?.(warn(`[telegram] Group migrated: "${chatTitle}" ${oldChatId}${newChatId}`));
if (!resolveChannelConfigWrites({ cfg, channelId: "telegram", accountId })) {
runtime.log?.(warn("[telegram] Config writes disabled; skipping group config migration."));
return;
}
// Check if old chat ID has config and migrate it
const currentConfig = loadConfig();
const migration = migrateTelegramGroupConfig({
cfg: currentConfig,
accountId,
oldChatId,
newChatId,
});
if (migration.migrated) {
runtime.log?.(warn(`[telegram] Migrating group config from ${oldChatId} to ${newChatId}`));
migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId });
await writeConfigFile(currentConfig);
runtime.log?.(warn(`[telegram] Group config migrated and saved successfully`));
} else if (migration.skippedExisting) {
runtime.log?.(
warn(
`[telegram] Group config already exists for ${newChatId}; leaving ${oldChatId} unchanged`,
),
);
} else {
runtime.log?.(
warn(`[telegram] No config found for old group ID ${oldChatId}, migration logged only`),
);
}
} catch (err) {
runtime.error?.(danger(`[telegram] Group migration handler failed: ${String(err)}`));
}
});
type InboundTelegramEvent = {
ctxForDedupe: TelegramUpdateKeyContext;
ctx: TelegramContext;
msg: Message;
chatId: number;
isGroup: boolean;
isForum: boolean;
messageThreadId?: number;
senderId: string;
senderUsername: string;
requireConfiguredGroup: boolean;
sendOversizeWarning: boolean;
oversizeLogMessage: string;
errorMessage: string;
};
const handleInboundMessageLike = async (event: InboundTelegramEvent) => {
try {
if (shouldSkipUpdate(event.ctxForDedupe)) {
return;
}
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
chatId: event.chatId,
isGroup: event.isGroup,
isForum: event.isForum,
messageThreadId: event.messageThreadId,
});
const {
dmPolicy,
resolvedThreadId,
dmThreadId,
storeAllowFrom,
groupConfig,
topicConfig,
groupAllowOverride,
effectiveGroupAllow,
hasGroupAllowOverride,
} = eventAuthContext;
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
const dmAllowFrom = groupAllowOverride ?? allowFrom;
const effectiveDmAllow = normalizeDmAllowFromWithStore({
allowFrom: dmAllowFrom,
storeAllowFrom,
dmPolicy,
});
if (event.requireConfiguredGroup && (!groupConfig || groupConfig.enabled === false)) {
logVerbose(`Blocked telegram channel ${event.chatId} (channel disabled)`);
return;
}
if (
shouldSkipGroupMessage({
isGroup: event.isGroup,
chatId: event.chatId,
chatTitle: event.msg.chat.title,
resolvedThreadId,
senderId: event.senderId,
senderUsername: event.senderUsername,
effectiveGroupAllow,
hasGroupAllowOverride,
groupConfig,
topicConfig,
})
) {
return;
}
if (!event.isGroup && (hasInboundMedia(event.msg) || hasReplyTargetMedia(event.msg))) {
const dmAuthorized = await enforceTelegramDmAccess({
isGroup: event.isGroup,
dmPolicy,
msg: event.msg,
chatId: event.chatId,
effectiveDmAllow,
accountId,
bot,
logger,
});
if (!dmAuthorized) {
return;
}
}
await processInboundMessage({
ctx: event.ctx,
msg: event.msg,
chatId: event.chatId,
resolvedThreadId,
dmThreadId,
storeAllowFrom,
sendOversizeWarning: event.sendOversizeWarning,
oversizeLogMessage: event.oversizeLogMessage,
});
} catch (err) {
runtime.error?.(danger(`${event.errorMessage}: ${String(err)}`));
}
};
bot.on("message", async (ctx) => {
const msg = ctx.message;
if (!msg) {
return;
}
await handleInboundMessageLike({
ctxForDedupe: ctx,
ctx: buildSyntheticContext(ctx, msg),
msg,
chatId: msg.chat.id,
isGroup: msg.chat.type === "group" || msg.chat.type === "supergroup",
isForum: msg.chat.is_forum === true,
messageThreadId: msg.message_thread_id,
senderId: msg.from?.id != null ? String(msg.from.id) : "",
senderUsername: msg.from?.username ?? "",
requireConfiguredGroup: false,
sendOversizeWarning: true,
oversizeLogMessage: "media exceeds size limit",
errorMessage: "handler failed",
});
});
// Handle channel posts — enables bot-to-bot communication via Telegram channels.
// Telegram bots cannot see other bot messages in groups, but CAN in channels.
// This handler normalizes channel_post updates into the standard message pipeline.
bot.on("channel_post", async (ctx) => {
const post = ctx.channelPost;
if (!post) {
return;
}
const chatId = post.chat.id;
const syntheticFrom = post.sender_chat
? {
id: post.sender_chat.id,
is_bot: true as const,
first_name: post.sender_chat.title || "Channel",
username: post.sender_chat.username,
}
: {
id: chatId,
is_bot: true as const,
first_name: post.chat.title || "Channel",
username: post.chat.username,
};
const syntheticMsg: Message = {
...post,
from: post.from ?? syntheticFrom,
chat: {
...post.chat,
type: "supergroup" as const,
},
} as Message;
await handleInboundMessageLike({
ctxForDedupe: ctx,
ctx: buildSyntheticContext(ctx, syntheticMsg),
msg: syntheticMsg,
chatId,
isGroup: true,
isForum: false,
senderId:
post.sender_chat?.id != null
? String(post.sender_chat.id)
: post.from?.id != null
? String(post.from.id)
: "",
senderUsername: post.sender_chat?.username ?? post.from?.username ?? "",
requireConfiguredGroup: true,
sendOversizeWarning: false,
oversizeLogMessage: "channel post media exceeds size limit",
errorMessage: "channel_post handler failed",
});
});
};