refactor(telegram): split bot handlers

This commit is contained in:
Peter Steinberger
2026-01-14 09:11:32 +00:00
parent 32cfc49002
commit ce59e2dd76
8 changed files with 1451 additions and 1179 deletions

View File

@@ -0,0 +1,48 @@
export type NormalizedAllowFrom = {
entries: string[];
entriesLower: string[];
hasWildcard: boolean;
hasEntries: boolean;
};
export const normalizeAllowFrom = (
list?: Array<string | number>,
): NormalizedAllowFrom => {
const entries = (list ?? [])
.map((value) => String(value).trim())
.filter(Boolean);
const hasWildcard = entries.includes("*");
const normalized = entries
.filter((value) => value !== "*")
.map((value) => value.replace(/^(telegram|tg):/i, ""));
const normalizedLower = normalized.map((value) => value.toLowerCase());
return {
entries: normalized,
entriesLower: normalizedLower,
hasWildcard,
hasEntries: entries.length > 0,
};
};
export const firstDefined = <T>(...values: Array<T | undefined>) => {
for (const value of values) {
if (typeof value !== "undefined") return value;
}
return undefined;
};
export const isSenderAllowed = (params: {
allow: NormalizedAllowFrom;
senderId?: string;
senderUsername?: string;
}) => {
const { allow, senderId, senderUsername } = params;
if (!allow.hasEntries) return true;
if (allow.hasWildcard) return true;
if (senderId && allow.entries.includes(senderId)) return true;
const username = senderUsername?.toLowerCase();
if (!username) return false;
return allow.entriesLower.some(
(entry) => entry === username || entry === `@${username}`,
);
};

View File

@@ -0,0 +1,269 @@
// @ts-nocheck
import { danger, logVerbose } from "../globals.js";
import { resolveMedia } from "./bot/delivery.js";
import { resolveTelegramForumThreadId } from "./bot/helpers.js";
import type { TelegramMessage } from "./bot/types.js";
import {
firstDefined,
isSenderAllowed,
normalizeAllowFrom,
} from "./bot-access.js";
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
import { readTelegramAllowFromStore } from "./pairing-store.js";
export const registerTelegramHandlers = ({
bot,
opts,
runtime,
mediaMaxBytes,
telegramCfg,
groupAllowFrom,
resolveGroupPolicy,
resolveTelegramGroupConfig,
shouldSkipUpdate,
processMessage,
logger,
}) => {
const mediaGroupBuffer = new Map<string, MediaGroupEntry>();
let mediaGroupProcessing: Promise<void> = Promise.resolve();
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: Array<{ path: string; contentType?: string }> = [];
for (const { ctx } of entry.messages) {
const media = await resolveMedia(
ctx,
mediaMaxBytes,
opts.token,
opts.proxyFetch,
);
if (media) {
allMedia.push({ path: media.path, contentType: media.contentType });
}
}
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
await processMessage(primaryEntry.ctx, allMedia, storeAllowFrom);
} catch (err) {
runtime.error?.(danger(`media group handler failed: ${String(err)}`));
}
};
bot.on("callback_query", async (ctx) => {
const callback = ctx.callbackQuery;
if (!callback) return;
if (shouldSkipUpdate(ctx)) return;
try {
const data = (callback.data ?? "").trim();
const callbackMessage = callback.message;
if (!data || !callbackMessage) return;
const syntheticMessage: TelegramMessage = {
...callbackMessage,
from: callback.from,
text: data,
caption: undefined,
caption_entities: undefined,
entities: undefined,
};
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
const getFile =
typeof ctx.getFile === "function"
? ctx.getFile.bind(ctx)
: async () => ({});
await processMessage(
{ message: syntheticMessage, me: ctx.me, getFile },
[],
storeAllowFrom,
{ forceWasMentioned: true, messageIdOverride: callback.id },
);
} catch (err) {
runtime.error?.(danger(`callback handler failed: ${String(err)}`));
} finally {
await bot.api.answerCallbackQuery(callback.id).catch(() => {});
}
});
bot.on("message", async (ctx) => {
try {
const msg = ctx.message;
if (!msg) return;
if (shouldSkipUpdate(ctx)) return;
const chatId = msg.chat.id;
const isGroup =
msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number })
.message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const resolvedThreadId = resolveTelegramForumThreadId({
isForum,
messageThreadId,
});
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
chatId,
resolvedThreadId,
);
const groupAllowOverride = firstDefined(
topicConfig?.allowFrom,
groupConfig?.allowFrom,
);
const effectiveGroupAllow = normalizeAllowFrom([
...(groupAllowOverride ?? groupAllowFrom ?? []),
...storeAllowFrom,
]);
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
if (isGroup) {
if (groupConfig?.enabled === false) {
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
return;
}
if (topicConfig?.enabled === false) {
logVerbose(
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
);
return;
}
if (hasGroupAllowOverride) {
const senderId = msg.from?.id;
const senderUsername = msg.from?.username ?? "";
const allowed =
senderId != null &&
isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderId),
senderUsername,
});
if (!allowed) {
logVerbose(
`Blocked telegram group sender ${senderId ?? "unknown"} (group allowFrom override)`,
);
return;
}
}
// Group policy filtering: controls how group messages are handled
// - "open": groups bypass allowFrom, only mention-gating applies
// - "disabled": block all group messages entirely
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
const groupPolicy = telegramCfg.groupPolicy ?? "open";
if (groupPolicy === "disabled") {
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
return;
}
if (groupPolicy === "allowlist") {
// For allowlist mode, the sender (msg.from.id) must be in allowFrom
const senderId = msg.from?.id;
if (senderId == null) {
logVerbose(
`Blocked telegram group message (no sender ID, groupPolicy: allowlist)`,
);
return;
}
if (!effectiveGroupAllow.hasEntries) {
logVerbose(
"Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)",
);
return;
}
const senderUsername = msg.from?.username ?? "";
if (
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderId),
senderUsername,
})
) {
logVerbose(
`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`,
);
return;
}
}
// Group allowlist based on configured group IDs.
const groupAllowlist = resolveGroupPolicy(chatId);
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
logger.info(
{ chatId, title: msg.chat.title, reason: "not-allowed" },
"skipping group message",
);
return;
}
}
// Media group handling - buffer multi-image messages
const mediaGroupId = (msg as { media_group_id?: string }).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;
}, MEDIA_GROUP_TIMEOUT_MS);
} else {
const entry: MediaGroupEntry = {
messages: [{ msg, ctx }],
timer: setTimeout(async () => {
mediaGroupBuffer.delete(mediaGroupId);
mediaGroupProcessing = mediaGroupProcessing
.then(async () => {
await processMediaGroup(entry);
})
.catch(() => undefined);
await mediaGroupProcessing;
}, MEDIA_GROUP_TIMEOUT_MS),
};
mediaGroupBuffer.set(mediaGroupId, entry);
}
return;
}
let media: Awaited<ReturnType<typeof resolveMedia>> = null;
try {
media = await resolveMedia(
ctx,
mediaMaxBytes,
opts.token,
opts.proxyFetch,
);
} catch (mediaErr) {
const errMsg = String(mediaErr);
if (errMsg.includes("exceeds") && errMsg.includes("MB limit")) {
const limitMb = Math.round(mediaMaxBytes / (1024 * 1024));
await bot.api
.sendMessage(
chatId,
`⚠️ File too large. Maximum size is ${limitMb}MB.`,
{ reply_to_message_id: msg.message_id },
)
.catch(() => {});
logger.warn({ chatId, error: errMsg }, "media exceeds size limit");
return;
}
throw mediaErr;
}
const allMedia = media
? [{ path: media.path, contentType: media.contentType }]
: [];
await processMessage(ctx, allMedia, storeAllowFrom);
} catch (err) {
runtime.error?.(danger(`handler failed: ${String(err)}`));
}
});
};

View File

@@ -0,0 +1,465 @@
// @ts-nocheck
import { resolveAckReaction } from "../agents/identity.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { buildHistoryContextFromMap } from "../auto-reply/reply/history.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
} from "../auto-reply/reply/mentions.js";
import { formatLocationText, toLocationContext } from "../channels/location.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import {
buildGroupFromLabel,
buildGroupLabel,
buildSenderLabel,
buildSenderName,
buildTelegramGroupFrom,
buildTelegramGroupPeerId,
buildTelegramThreadParams,
describeReplyTarget,
extractTelegramLocation,
hasBotMention,
resolveTelegramForumThreadId,
} from "./bot/helpers.js";
import {
firstDefined,
isSenderAllowed,
normalizeAllowFrom,
} from "./bot-access.js";
import { upsertTelegramPairingRequest } from "./pairing-store.js";
export const buildTelegramMessageContext = async ({
primaryCtx,
allMedia,
storeAllowFrom,
options,
bot,
cfg,
account,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
}) => {
const msg = primaryCtx.message;
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "inbound",
});
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number })
.message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const resolvedThreadId = resolveTelegramForumThreadId({
isForum,
messageThreadId,
});
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
chatId,
resolvedThreadId,
);
const peerId = isGroup
? buildTelegramGroupPeerId(chatId, resolvedThreadId)
: String(chatId);
const route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: peerId,
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const effectiveDmAllow = normalizeAllowFrom([
...(allowFrom ?? []),
...storeAllowFrom,
]);
const groupAllowOverride = firstDefined(
topicConfig?.allowFrom,
groupConfig?.allowFrom,
);
const effectiveGroupAllow = normalizeAllowFrom([
...(groupAllowOverride ?? groupAllowFrom ?? []),
...storeAllowFrom,
]);
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
if (isGroup && groupConfig?.enabled === false) {
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
return null;
}
if (isGroup && topicConfig?.enabled === false) {
logVerbose(
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
);
return null;
}
const sendTyping = async () => {
try {
await bot.api.sendChatAction(
chatId,
"typing",
buildTelegramThreadParams(resolvedThreadId),
);
} catch (err) {
logVerbose(
`telegram typing cue failed for chat ${chatId}: ${String(err)}`,
);
}
};
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled"
if (!isGroup) {
if (dmPolicy === "disabled") return null;
if (dmPolicy !== "open") {
const candidate = String(chatId);
const senderUsername = msg.from?.username ?? "";
const allowed =
effectiveDmAllow.hasWildcard ||
(effectiveDmAllow.hasEntries &&
isSenderAllowed({
allow: effectiveDmAllow,
senderId: candidate,
senderUsername,
}));
if (!allowed) {
if (dmPolicy === "pairing") {
try {
const from = msg.from as
| {
first_name?: string;
last_name?: string;
username?: string;
id?: number;
}
| undefined;
const telegramUserId = from?.id ? String(from.id) : candidate;
const { code, created } = await upsertTelegramPairingRequest({
chatId: candidate,
username: from?.username,
firstName: from?.first_name,
lastName: from?.last_name,
});
if (created) {
logger.info(
{
chatId: candidate,
username: from?.username,
firstName: from?.first_name,
lastName: from?.last_name,
},
"telegram pairing request",
);
await bot.api.sendMessage(
chatId,
[
"Clawdbot: access not configured.",
"",
`Your Telegram user id: ${telegramUserId}`,
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve telegram <code>",
].join("\n"),
);
}
} catch (err) {
logVerbose(
`telegram pairing reply failed for chat ${chatId}: ${String(err)}`,
);
}
} else {
logVerbose(
`Blocked unauthorized telegram sender ${candidate} (dmPolicy=${dmPolicy})`,
);
}
return null;
}
}
}
const botUsername = primaryCtx.me?.username?.toLowerCase();
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
if (isGroup && hasGroupAllowOverride) {
const allowed = isSenderAllowed({
allow: effectiveGroupAllow,
senderId,
senderUsername,
});
if (!allowed) {
logVerbose(
`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`,
);
return null;
}
}
const commandAuthorized = isSenderAllowed({
allow: isGroup ? effectiveGroupAllow : effectiveDmAllow,
senderId,
senderUsername,
});
const computedWasMentioned =
(Boolean(botUsername) && hasBotMention(msg, botUsername)) ||
matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes);
const wasMentioned =
options?.forceWasMentioned === true ? true : computedWasMentioned;
const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some(
(ent) => ent.type === "mention",
);
const activationOverride = resolveGroupActivation({
chatId,
messageThreadId: resolvedThreadId,
sessionKey: route.sessionKey,
agentId: route.agentId,
});
const baseRequireMention = resolveGroupRequireMention(chatId);
const requireMention = firstDefined(
activationOverride,
topicConfig?.requireMention,
groupConfig?.requireMention,
baseRequireMention,
);
const shouldBypassMention =
isGroup &&
requireMention &&
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(msg.text ?? msg.caption ?? "", cfg, { botUsername });
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
if (isGroup && requireMention && canDetectMention) {
if (!wasMentioned && !shouldBypassMention) {
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
return null;
}
}
// ACK reactions
const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () => {
if (!ackReaction) return false;
if (ackReactionScope === "all") return true;
if (ackReactionScope === "direct") return !isGroup;
if (ackReactionScope === "group-all") return isGroup;
if (ackReactionScope === "group-mentions") {
if (!isGroup) return false;
if (!requireMention) return false;
if (!canDetectMention) return false;
return wasMentioned || shouldBypassMention;
}
return false;
};
const api = bot.api as unknown as {
setMessageReaction?: (
chatId: number | string,
messageId: number,
reactions: Array<{ type: "emoji"; emoji: string }>,
) => Promise<void>;
};
const reactionApi =
typeof api.setMessageReaction === "function"
? api.setMessageReaction.bind(api)
: null;
const ackReactionPromise =
shouldAckReaction() && msg.message_id && reactionApi
? reactionApi(chatId, msg.message_id, [
{ type: "emoji", emoji: ackReaction },
]).then(
() => true,
(err) => {
logVerbose(
`telegram react failed for chat ${chatId}: ${String(err)}`,
);
return false;
},
)
: null;
let placeholder = "";
if (msg.photo) placeholder = "<media:image>";
else if (msg.video) placeholder = "<media:video>";
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
else if (msg.document) placeholder = "<media:document>";
const replyTarget = describeReplyTarget(msg);
const locationData = extractTelegramLocation(msg);
const locationText = locationData
? formatLocationText(locationData)
: undefined;
const rawText = (msg.text ?? msg.caption ?? "").trim();
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
if (!rawBody) rawBody = placeholder;
if (!rawBody && allMedia.length === 0) return null;
let bodyText = rawBody;
if (!bodyText && allMedia.length > 0) {
bodyText = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
}
const replySuffix = replyTarget
? `\n\n[Replying to ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n${replyTarget.body}\n[/Replying]`
: "";
const groupLabel = isGroup
? buildGroupLabel(msg, chatId, resolvedThreadId)
: undefined;
const body = formatAgentEnvelope({
channel: "Telegram",
from: isGroup
? buildGroupFromLabel(msg, chatId, senderId, resolvedThreadId)
: buildSenderLabel(msg, senderId || chatId),
timestamp: msg.date ? msg.date * 1000 : undefined,
body: `${bodyText}${replySuffix}`,
});
let combinedBody = body;
const historyKey = isGroup
? buildTelegramGroupPeerId(chatId, resolvedThreadId)
: undefined;
if (isGroup && historyKey && historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
historyMap: groupHistories,
historyKey,
limit: historyLimit,
entry: {
sender: buildSenderLabel(msg, senderId || chatId),
body: rawBody,
timestamp: msg.date ? msg.date * 1000 : undefined,
messageId:
typeof msg.message_id === "number"
? String(msg.message_id)
: undefined,
},
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({
channel: "Telegram",
from: groupLabel ?? `group:${chatId}`,
timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`,
}),
});
}
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
const systemPromptParts = [
groupConfig?.systemPrompt?.trim() || null,
topicConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const commandBody = normalizeCommandBody(rawBody, { botUsername });
const ctxPayload = {
Body: combinedBody,
RawBody: rawBody,
CommandBody: commandBody,
From: isGroup
? buildTelegramGroupFrom(chatId, resolvedThreadId)
: `telegram:${chatId}`,
To: `telegram:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
SenderName: buildSenderName(msg),
SenderId: senderId || undefined,
SenderUsername: senderUsername || undefined,
Provider: "telegram",
Surface: "telegram",
MessageSid: options?.messageIdOverride ?? String(msg.message_id),
ReplyToId: replyTarget?.id,
ReplyToBody: replyTarget?.body,
ReplyToSender: replyTarget?.sender,
Timestamp: msg.date ? msg.date * 1000 : undefined,
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
MediaPath: allMedia[0]?.path,
MediaType: allMedia[0]?.contentType,
MediaUrl: allMedia[0]?.path,
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
MediaUrls: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
MediaTypes:
allMedia.length > 0
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
: undefined,
...(locationData ? toLocationContext(locationData) : undefined),
CommandAuthorized: commandAuthorized,
MessageThreadId: resolvedThreadId,
IsForum: isForum,
// Originating channel for reply routing.
OriginatingChannel: "telegram" as const,
OriginatingTo: `telegram:${chatId}`,
};
if (replyTarget && shouldLogVerbose()) {
const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120);
logVerbose(
`telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`,
);
}
if (!isGroup) {
const sessionCfg = cfg.session;
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
channel: "telegram",
to: String(chatId),
accountId: route.accountId,
});
}
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
const mediaInfo =
allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : "";
const topicInfo =
resolvedThreadId != null ? ` topic=${resolvedThreadId}` : "";
logVerbose(
`telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`,
);
}
return {
ctxPayload,
primaryCtx,
msg,
chatId,
isGroup,
resolvedThreadId,
isForum,
historyKey,
historyLimit,
groupHistories,
route,
skillFilter,
sendTyping,
ackReactionPromise,
reactionApi,
removeAckAfterReply,
accountId: account.accountId,
};
};

View File

@@ -0,0 +1,190 @@
// @ts-nocheck
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
import { clearHistoryEntries } from "../auto-reply/reply/history.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { danger, logVerbose } from "../globals.js";
import { deliverReplies } from "./bot/delivery.js";
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
import { createTelegramDraftStream } from "./draft-stream.js";
export const dispatchTelegramMessage = async ({
context,
bot,
cfg,
runtime,
replyToMode,
streamMode,
textLimit,
telegramCfg,
opts,
resolveBotTopicsEnabled,
}) => {
const {
ctxPayload,
primaryCtx,
msg,
chatId,
isGroup,
resolvedThreadId,
historyKey,
historyLimit,
groupHistories,
route,
skillFilter,
sendTyping,
ackReactionPromise,
reactionApi,
removeAckAfterReply,
} = context;
const isPrivateChat = msg.chat.type === "private";
const draftMaxChars = Math.min(textLimit, 4096);
const canStreamDraft =
streamMode !== "off" &&
isPrivateChat &&
typeof resolvedThreadId === "number" &&
(await resolveBotTopicsEnabled(primaryCtx));
const draftStream = canStreamDraft
? createTelegramDraftStream({
api: bot.api,
chatId,
draftId: msg.message_id || Date.now(),
maxChars: draftMaxChars,
messageThreadId: resolvedThreadId,
log: logVerbose,
warn: logVerbose,
})
: undefined;
const draftChunking =
draftStream && streamMode === "block"
? resolveTelegramDraftStreamingChunking(cfg, route.accountId)
: undefined;
const draftChunker = draftChunking
? new EmbeddedBlockChunker(draftChunking)
: undefined;
let lastPartialText = "";
let draftText = "";
const updateDraftFromPartial = (text?: string) => {
if (!draftStream || !text) return;
if (text === lastPartialText) return;
if (streamMode === "partial") {
lastPartialText = text;
draftStream.update(text);
return;
}
let delta = text;
if (text.startsWith(lastPartialText)) {
delta = text.slice(lastPartialText.length);
} else {
// Streaming buffer reset (or non-monotonic stream). Start fresh.
draftChunker?.reset();
draftText = "";
}
lastPartialText = text;
if (!delta) return;
if (!draftChunker) {
draftText = text;
draftStream.update(draftText);
return;
}
draftChunker.append(delta);
draftChunker.drain({
force: false,
emit: (chunk) => {
draftText += chunk;
draftStream.update(draftText);
},
});
};
const flushDraft = async () => {
if (!draftStream) return;
if (draftChunker?.hasBuffered()) {
draftChunker.drain({
force: true,
emit: (chunk) => {
draftText += chunk;
},
});
draftChunker.reset();
if (draftText) draftStream.update(draftText);
}
await draftStream.flush();
};
const disableBlockStreaming =
Boolean(draftStream) ||
(typeof telegramCfg.blockStreaming === "boolean"
? !telegramCfg.blockStreaming
: undefined);
let didSendReply = false;
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
deliver: async (payload, info) => {
if (info.kind === "final") {
await flushDraft();
draftStream?.stop();
}
await deliverReplies({
replies: [payload],
chatId: String(chatId),
token: opts.token,
runtime,
bot,
replyToMode,
textLimit,
messageThreadId: resolvedThreadId,
});
didSendReply = true;
},
onError: (err, info) => {
runtime.error?.(
danger(`telegram ${info.kind} reply failed: ${String(err)}`),
);
},
onReplyStart: sendTyping,
},
replyOptions: {
skillFilter,
onPartialReply: draftStream
? (payload) => updateDraftFromPartial(payload.text)
: undefined,
onReasoningStream: draftStream
? (payload) => {
if (payload.text) draftStream.update(payload.text);
}
: undefined,
disableBlockStreaming,
},
});
draftStream?.stop();
if (!queuedFinal) {
if (isGroup && historyKey && historyLimit > 0 && didSendReply) {
clearHistoryEntries({ historyMap: groupHistories, historyKey });
}
return;
}
if (
removeAckAfterReply &&
ackReactionPromise &&
msg.message_id &&
reactionApi
) {
void ackReactionPromise.then((didAck) => {
if (!didAck) return;
reactionApi(chatId, msg.message_id, []).catch((err) => {
logVerbose(
`telegram: failed to remove ack reaction from ${chatId}/${msg.message_id}: ${String(err)}`,
);
});
});
}
if (isGroup && historyKey && historyLimit > 0 && didSendReply) {
clearHistoryEntries({ historyMap: groupHistories, historyKey });
}
};

View File

@@ -0,0 +1,63 @@
// @ts-nocheck
import { buildTelegramMessageContext } from "./bot-message-context.js";
import { dispatchTelegramMessage } from "./bot-message-dispatch.js";
export const createTelegramMessageProcessor = (deps) => {
const {
bot,
cfg,
account,
telegramCfg,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
runtime,
replyToMode,
streamMode,
textLimit,
opts,
resolveBotTopicsEnabled,
} = deps;
return async (primaryCtx, allMedia, storeAllowFrom, options) => {
const context = await buildTelegramMessageContext({
primaryCtx,
allMedia,
storeAllowFrom,
options,
bot,
cfg,
account,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
});
if (!context) return;
await dispatchTelegramMessage({
context,
bot,
cfg,
runtime,
replyToMode,
streamMode,
textLimit,
telegramCfg,
opts,
resolveBotTopicsEnabled,
});
};
};

View File

@@ -0,0 +1,300 @@
// @ts-nocheck
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import {
buildCommandText,
listNativeCommandSpecsForConfig,
} from "../auto-reply/commands-registry.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { danger, logVerbose } from "../globals.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { deliverReplies } from "./bot/delivery.js";
import {
buildSenderName,
buildTelegramGroupFrom,
buildTelegramGroupPeerId,
resolveTelegramForumThreadId,
} from "./bot/helpers.js";
import {
firstDefined,
isSenderAllowed,
normalizeAllowFrom,
} from "./bot-access.js";
import { readTelegramAllowFromStore } from "./pairing-store.js";
export const registerTelegramNativeCommands = ({
bot,
cfg,
runtime,
accountId,
telegramCfg,
allowFrom,
groupAllowFrom,
replyToMode,
textLimit,
useAccessGroups,
nativeEnabled,
nativeDisabledExplicit,
resolveGroupPolicy,
resolveTelegramGroupConfig,
shouldSkipUpdate,
opts,
}) => {
const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg)
: [];
if (nativeCommands.length > 0) {
const api = bot.api as unknown as {
setMyCommands?: (
commands: Array<{ command: string; description: string }>,
) => Promise<unknown>;
};
if (typeof api.setMyCommands === "function") {
api
.setMyCommands(
nativeCommands.map((command) => ({
command: command.name,
description: command.description,
})),
)
.catch((err) => {
runtime.error?.(
danger(`telegram setMyCommands failed: ${String(err)}`),
);
});
} else {
logVerbose("telegram: setMyCommands unavailable; skipping registration");
}
if (
typeof (bot as unknown as { command?: unknown }).command !== "function"
) {
logVerbose("telegram: bot.command unavailable; skipping native handlers");
} else {
for (const command of nativeCommands) {
bot.command(command.name, async (ctx) => {
const msg = ctx.message;
if (!msg) return;
if (shouldSkipUpdate(ctx)) return;
const chatId = msg.chat.id;
const isGroup =
msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number })
.message_thread_id;
const isForum =
(msg.chat as { is_forum?: boolean }).is_forum === true;
const resolvedThreadId = resolveTelegramForumThreadId({
isForum,
messageThreadId,
});
const storeAllowFrom = await readTelegramAllowFromStore().catch(
() => [],
);
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
chatId,
resolvedThreadId,
);
const groupAllowOverride = firstDefined(
topicConfig?.allowFrom,
groupConfig?.allowFrom,
);
const effectiveGroupAllow = normalizeAllowFrom([
...(groupAllowOverride ?? groupAllowFrom ?? []),
...storeAllowFrom,
]);
const hasGroupAllowOverride =
typeof groupAllowOverride !== "undefined";
if (isGroup && groupConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This group is disabled.");
return;
}
if (isGroup && topicConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This topic is disabled.");
return;
}
if (isGroup && hasGroupAllowOverride) {
const senderId = msg.from?.id;
const senderUsername = msg.from?.username ?? "";
if (
senderId == null ||
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderId),
senderUsername,
})
) {
await bot.api.sendMessage(
chatId,
"You are not authorized to use this command.",
);
return;
}
}
if (isGroup && useAccessGroups) {
const groupPolicy = telegramCfg.groupPolicy ?? "open";
if (groupPolicy === "disabled") {
await bot.api.sendMessage(
chatId,
"Telegram group commands are disabled.",
);
return;
}
if (groupPolicy === "allowlist") {
const senderId = msg.from?.id;
if (senderId == null) {
await bot.api.sendMessage(
chatId,
"You are not authorized to use this command.",
);
return;
}
const senderUsername = msg.from?.username ?? "";
if (
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderId),
senderUsername,
})
) {
await bot.api.sendMessage(
chatId,
"You are not authorized to use this command.",
);
return;
}
}
const groupAllowlist = resolveGroupPolicy(chatId);
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
await bot.api.sendMessage(chatId, "This group is not allowed.");
return;
}
}
const allowFromList = Array.isArray(allowFrom)
? allowFrom.map((entry) => String(entry).trim()).filter(Boolean)
: [];
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
const commandAuthorized =
allowFromList.length === 0 ||
allowFromList.includes("*") ||
(senderId && allowFromList.includes(senderId)) ||
(senderId && allowFromList.includes(`telegram:${senderId}`)) ||
(senderUsername &&
allowFromList.some(
(entry) =>
entry.toLowerCase() === senderUsername.toLowerCase() ||
entry.toLowerCase() === `@${senderUsername.toLowerCase()}`,
));
if (!commandAuthorized) {
await bot.api.sendMessage(
chatId,
"You are not authorized to use this command.",
);
return;
}
const prompt = buildCommandText(command.name, ctx.match ?? "");
const route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup
? buildTelegramGroupPeerId(chatId, resolvedThreadId)
: String(chatId),
},
});
const skillFilter = firstDefined(
topicConfig?.skills,
groupConfig?.skills,
);
const systemPromptParts = [
groupConfig?.systemPrompt?.trim() || null,
topicConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0
? systemPromptParts.join("\n\n")
: undefined;
const ctxPayload = {
Body: prompt,
From: isGroup
? buildTelegramGroupFrom(chatId, resolvedThreadId)
: `telegram:${chatId}`,
To: `slash:${senderId || chatId}`,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
SenderName: buildSenderName(msg),
SenderId: senderId || undefined,
SenderUsername: senderUsername || undefined,
Surface: "telegram",
MessageSid: String(msg.message_id),
Timestamp: msg.date ? msg.date * 1000 : undefined,
WasMentioned: true,
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
SessionKey: `telegram:slash:${senderId || chatId}`,
CommandTargetSessionKey: route.sessionKey,
MessageThreadId: resolvedThreadId,
IsForum: isForum,
};
const disableBlockStreaming =
typeof telegramCfg.blockStreaming === "boolean"
? !telegramCfg.blockStreaming
: undefined;
await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
deliver: async (payload) => {
await deliverReplies({
replies: [payload],
chatId: String(chatId),
token: opts.token,
runtime,
bot,
replyToMode,
textLimit,
messageThreadId: resolvedThreadId,
});
},
onError: (err, info) => {
runtime.error?.(
danger(
`telegram slash ${info.kind} reply failed: ${String(err)}`,
),
);
},
},
replyOptions: {
skillFilter,
disableBlockStreaming,
},
});
});
}
}
} else if (nativeDisabledExplicit) {
const api = bot.api as unknown as {
setMyCommands?: (commands: []) => Promise<unknown>;
};
if (typeof api.setMyCommands === "function") {
api.setMyCommands([]).catch((err) => {
runtime.error?.(
danger(`telegram clear commands failed: ${String(err)}`),
);
});
} else {
logVerbose("telegram: setMyCommands unavailable; skipping clear");
}
}
};

View File

@@ -0,0 +1,54 @@
import { createDedupeCache } from "../infra/dedupe.js";
import type { TelegramContext, TelegramMessage } from "./bot/types.js";
const MEDIA_GROUP_TIMEOUT_MS = 500;
const RECENT_TELEGRAM_UPDATE_TTL_MS = 5 * 60_000;
const RECENT_TELEGRAM_UPDATE_MAX = 2000;
export type MediaGroupEntry = {
messages: Array<{
msg: TelegramMessage;
ctx: TelegramContext;
}>;
timer: ReturnType<typeof setTimeout>;
};
export type TelegramUpdateKeyContext = {
update?: {
update_id?: number;
message?: TelegramMessage;
edited_message?: TelegramMessage;
};
update_id?: number;
message?: TelegramMessage;
callbackQuery?: { id?: string; message?: TelegramMessage };
};
export const resolveTelegramUpdateId = (ctx: TelegramUpdateKeyContext) =>
ctx.update?.update_id ?? ctx.update_id;
export const buildTelegramUpdateKey = (ctx: TelegramUpdateKeyContext) => {
const updateId = resolveTelegramUpdateId(ctx);
if (typeof updateId === "number") return `update:${updateId}`;
const callbackId = ctx.callbackQuery?.id;
if (callbackId) return `callback:${callbackId}`;
const msg =
ctx.message ??
ctx.update?.message ??
ctx.update?.edited_message ??
ctx.callbackQuery?.message;
const chatId = msg?.chat?.id;
const messageId = msg?.message_id;
if (typeof chatId !== "undefined" && typeof messageId === "number") {
return `message:${chatId}:${messageId}`;
}
return undefined;
};
export const createTelegramUpdateDedupe = () =>
createDedupeCache({
ttlMs: RECENT_TELEGRAM_UPDATE_TTL_MS,
maxSize: RECENT_TELEGRAM_UPDATE_MAX,
});
export { MEDIA_GROUP_TIMEOUT_MS };

File diff suppressed because it is too large Load Diff