diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 2e347670e42..06eb0d9a020 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1144,6 +1144,9 @@ Use SDK subpaths instead of the monolithic `openclaw/plugin-sdk` import when authoring plugins: - `openclaw/plugin-sdk/core` for the smallest generic plugin-facing contract. + It also carries small assembly helpers such as + `definePluginEntry`, `defineChannelPluginEntry`, `defineSetupPluginEntry`, + and `createChannelPluginBase` for bundled or third-party plugin entry wiring. - Domain subpaths such as `openclaw/plugin-sdk/channel-config-helpers`, `openclaw/plugin-sdk/channel-config-schema`, `openclaw/plugin-sdk/channel-policy`, diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 7558b27394a..8d2de525ad2 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -3,6 +3,7 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, DiscordConfigSchema, @@ -58,12 +59,10 @@ export function createDiscordPluginBase(params: { | "config" | "setup" > { - return { + return createChannelPluginBase({ id: DISCORD_CHANNEL, - meta: { - ...getChatChannelMeta(DISCORD_CHANNEL), - }, setupWizard: discordSetupWizard, + meta: { ...getChatChannelMeta(DISCORD_CHANNEL) }, capabilities: { chatTypes: ["direct", "channel", "thread"], polls: true, @@ -90,5 +89,16 @@ export function createDiscordPluginBase(params: { ...discordConfigAccessors, }, setup: params.setup, - }; + }) as Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "streaming" + | "reload" + | "configSchema" + | "config" + | "setup" + >; } diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index 301b1848f99..5f38cd9ef84 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -2,6 +2,7 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, @@ -47,7 +48,7 @@ export function createIMessagePluginBase(params: { | "security" | "setup" > { - return { + return createChannelPluginBase({ id: IMESSAGE_CHANNEL, meta: { ...getChatChannelMeta(IMESSAGE_CHANNEL), @@ -115,5 +116,16 @@ export function createIMessagePluginBase(params: { }), }, setup: params.setup, - }; + }) as Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "reload" + | "configSchema" + | "config" + | "security" + | "setup" + >; } diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index f03ecd847e2..76bc8b630b2 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -3,6 +3,7 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, @@ -59,7 +60,7 @@ export function createSignalPluginBase(params: { | "security" | "setup" > { - return { + return createChannelPluginBase({ id: SIGNAL_CHANNEL, meta: { ...getChatChannelMeta(SIGNAL_CHANNEL), @@ -129,5 +130,17 @@ export function createSignalPluginBase(params: { }), }, setup: params.setup, - }; + }) as Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "streaming" + | "reload" + | "configSchema" + | "config" + | "security" + | "setup" + >; } diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 0d4fd0a3481..ae9e76267d3 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -3,6 +3,7 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { formatDocsLink, hasConfiguredSecretInput, @@ -176,7 +177,7 @@ export function createSlackPluginBase(params: { | "config" | "setup" > { - return { + return createChannelPluginBase({ id: SLACK_CHANNEL, meta: { ...getChatChannelMeta(SLACK_CHANNEL), @@ -220,5 +221,17 @@ export function createSlackPluginBase(params: { ...slackConfigAccessors, }, setup: params.setup, - }; + }) as Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "agentPrompt" + | "streaming" + | "reload" + | "configSchema" + | "config" + | "setup" + >; } diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts new file mode 100644 index 00000000000..92d584b8ea9 --- /dev/null +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -0,0 +1,1776 @@ +import type { Message, ReactionTypeEmoji } from "@grammyjs/types"; +import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; +import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; +import { + loadSessionStore, + resolveSessionStoreEntry, + resolveStorePath, + updateSessionStore, +} from "openclaw/plugin-sdk/config-runtime"; +import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; +import type { + TelegramDirectConfig, + TelegramGroupConfig, + TelegramTopicConfig, +} from "openclaw/plugin-sdk/config-runtime"; +import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/config-runtime"; +import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; +import { + buildPluginBindingResolvedText, + parsePluginBindingApprovalCustomId, + resolvePluginConversationBindingApproval, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime"; +import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime"; +import { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "openclaw/plugin-sdk/reply-runtime"; +import { buildCommandsPaginationKeyboard } from "openclaw/plugin-sdk/reply-runtime"; +import { + buildModelsProviderData, + formatModelsAvailableHeader, +} from "openclaw/plugin-sdk/reply-runtime"; +import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime"; +import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime"; +import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; +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 { + getTelegramTextParts, + buildTelegramGroupPeerId, + buildTelegramParentPeer, + resolveTelegramForumThreadId, + resolveTelegramGroupAllowFromContext, +} from "./bot/helpers.js"; +import type { TelegramContext } from "./bot/types.js"; +import { + resolveTelegramConversationBaseSessionKey, + resolveTelegramConversationRoute, +} from "./conversation-route.js"; +import { enforceTelegramDmAccess } from "./dm-access.js"; +import { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, + shouldEnableTelegramExecApprovalButtons, +} from "./exec-approvals.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"; + +const APPROVE_CALLBACK_DATA_RE = + /^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i; + +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, + telegramTransport, + 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(); + let mediaGroupProcessing: Promise = Promise.resolve(); + + type TextFragmentEntry = { + key: string; + messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>; + timer: ReturnType; + }; + const textFragmentBuffer = new Map(); + let textFragmentProcessing: Promise = 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 & { 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({ + 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 hasDebounceableText = shouldDebounceTextInbound({ + text, + cfg, + commandOptions: { botUsername: entry.botUsername }, + }); + if (entry.debounceLane === "forward") { + // Forwarded bursts often split text + media into adjacent updates. + // Debounce media-only forward entries too so they can coalesce. + return hasDebounceableText || entry.allMedia.length > 0; + } + if (!hasDebounceableText) { + return false; + } + return entry.allMedia.length === 0; + }, + 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, items) => { + runtime.error?.(danger(`telegram debounce flush failed: ${String(err)}`)); + const chatId = items[0]?.msg.chat.id; + if (chatId != null) { + const threadId = items[0]?.msg.message_thread_id; + void bot.api + .sendMessage( + chatId, + "Something went wrong while processing your message. Please try again.", + threadId != null ? { message_thread_id: threadId } : undefined, + ) + .catch((sendErr) => { + logVerbose(`telegram: error fallback send failed: ${String(sendErr)}`); + }); + } + }, + }); + + const resolveTelegramSessionState = (params: { + chatId: number | string; + isGroup: boolean; + isForum: boolean; + messageThreadId?: number; + resolvedThreadId?: number; + senderId?: string | number; + }): { + agentId: string; + sessionEntry: ReturnType[string] | undefined; + sessionKey: string; + model?: string; + } => { + const resolvedThreadId = + params.resolvedThreadId ?? + resolveTelegramForumThreadId({ + isForum: params.isForum, + messageThreadId: params.messageThreadId, + }); + const dmThreadId = !params.isGroup ? params.messageThreadId : undefined; + const topicThreadId = resolvedThreadId ?? dmThreadId; + const { topicConfig } = resolveTelegramGroupConfig(params.chatId, topicThreadId); + const { route } = resolveTelegramConversationRoute({ + cfg, + accountId, + chatId: params.chatId, + isGroup: params.isGroup, + resolvedThreadId, + replyThreadId: topicThreadId, + senderId: params.senderId, + topicAgentId: topicConfig?.agentId, + }); + const baseSessionKey = resolveTelegramConversationBaseSessionKey({ + cfg, + route, + chatId: params.chatId, + isGroup: params.isGroup, + senderId: params.senderId, + }); + 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 = resolveSessionStoreEntry({ store, sessionKey }).existing; + const storedOverride = resolveStoredModelOverride({ + sessionEntry: entry, + sessionStore: store, + sessionKey, + }); + if (storedOverride) { + return { + agentId: route.agentId, + sessionEntry: entry, + sessionKey, + 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, + sessionKey, + model: `${provider}/${model}`, + }; + } + const modelCfg = cfg.agents?.defaults?.model; + return { + agentId: route.agentId, + sessionEntry: entry, + sessionKey, + 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, telegramTransport); + } 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 => { + 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, + telegramTransport, + ); + 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>; + 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 => { + 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> = null; + try { + media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport); + } 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(getTelegramTextParts(msg).text.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[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 clearCallbackButtons = async () => { + const emptyKeyboard = { inline_keyboard: [] }; + const replyMarkup = { reply_markup: emptyKeyboard }; + const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown }) + .editMessageReplyMarkup; + if (typeof editReplyMarkupFn === "function") { + return await ctx.editMessageReplyMarkup(replyMarkup); + } + const apiEditReplyMarkupFn = (bot.api as { editMessageReplyMarkup?: unknown }) + .editMessageReplyMarkup; + if (typeof apiEditReplyMarkupFn === "function") { + return await bot.api.editMessageReplyMarkup( + callbackMessage.chat.id, + callbackMessage.message_id, + replyMarkup, + ); + } + // Fallback path for older clients that do not expose editMessageReplyMarkup. + const messageText = callbackMessage.text ?? callbackMessage.caption; + if (typeof messageText !== "string" || messageText.trim().length === 0) { + return undefined; + } + return await editCallbackMessage(messageText, replyMarkup); + }; + const editCallbackButtons = async ( + buttons: Array< + Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> + >, + ) => { + const keyboard = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] }; + const replyMarkup = { reply_markup: keyboard }; + const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown }) + .editMessageReplyMarkup; + if (typeof editReplyMarkupFn === "function") { + return await ctx.editMessageReplyMarkup(replyMarkup); + } + return await bot.api.editMessageReplyMarkup( + callbackMessage.chat.id, + callbackMessage.message_id, + replyMarkup, + ); + }; + 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[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 chatId = callbackMessage.chat.id; + const isGroup = + callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup"; + const isApprovalCallback = APPROVE_CALLBACK_DATA_RE.test(data); + const inlineButtonsScope = resolveTelegramInlineButtonsScope({ + cfg, + accountId, + }); + const execApprovalButtonsEnabled = + isApprovalCallback && + shouldEnableTelegramExecApprovalButtons({ + cfg, + accountId, + to: String(chatId), + }); + if (!execApprovalButtonsEnabled) { + if (inlineButtonsScope === "off") { + return; + } + 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 = + !execApprovalButtonsEnabled && 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 callbackConversationId = + messageThreadId != null ? `${chatId}:topic:${messageThreadId}` : String(chatId); + const pluginBindingApproval = parsePluginBindingApprovalCustomId(data); + if (pluginBindingApproval) { + const resolved = await resolvePluginConversationBindingApproval({ + approvalId: pluginBindingApproval.approvalId, + decision: pluginBindingApproval.decision, + senderId: senderId || undefined, + }); + await clearCallbackButtons(); + await replyToCallbackChat(buildPluginBindingResolvedText(resolved)); + return; + } + const pluginCallback = await dispatchPluginInteractiveHandler({ + channel: "telegram", + data, + callbackId: callback.id, + ctx: { + accountId, + callbackId: callback.id, + conversationId: callbackConversationId, + parentConversationId: messageThreadId != null ? String(chatId) : undefined, + senderId: senderId || undefined, + senderUsername: senderUsername || undefined, + threadId: messageThreadId, + isGroup, + isForum, + auth: { + isAuthorizedSender: true, + }, + callbackMessage: { + messageId: callbackMessage.message_id, + chatId: String(chatId), + messageText: callbackMessage.text ?? callbackMessage.caption, + }, + }, + respond: { + reply: async ({ text, buttons }) => { + await replyToCallbackChat( + text, + buttons ? { reply_markup: buildInlineKeyboard(buttons) } : undefined, + ); + }, + editMessage: async ({ text, buttons }) => { + await editCallbackMessage( + text, + buttons ? { reply_markup: buildInlineKeyboard(buttons) } : undefined, + ); + }, + editButtons: async ({ buttons }) => { + await editCallbackButtons(buttons); + }, + clearButtons: async () => { + await clearCallbackButtons(); + }, + deleteMessage: async () => { + await deleteCallbackMessage(); + }, + }, + }); + if (pluginCallback.handled) { + return; + } + + if (isApprovalCallback) { + if ( + !isTelegramExecApprovalClientEnabled({ cfg, accountId }) || + !isTelegramExecApprovalApprover({ cfg, accountId, senderId }) + ) { + logVerbose( + `Blocked telegram exec approval callback from ${senderId || "unknown"} (not an approver)`, + ); + return; + } + try { + await clearCallbackButtons(); + } catch (editErr) { + const errStr = String(editErr); + if ( + !errStr.includes("message is not modified") && + !errStr.includes("there is no text in the message to edit") + ) { + logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`); + } + } + } + + 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); + const skillCommands = listSkillCommandsForAgents({ + cfg, + agentIds: [agentId], + }); + 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 sessionState = resolveTelegramSessionState({ + chatId, + isGroup, + isForum, + messageThreadId, + resolvedThreadId, + senderId, + }); + const modelData = await buildModelsProviderData(cfg, sessionState.agentId); + const { byProvider, providers } = modelData; + + const editMessageWithButtons = async ( + text: string, + buttons: ReturnType, + ) => { + 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 currentSessionState = resolveTelegramSessionState({ + chatId, + isGroup, + isForum, + messageThreadId, + resolvedThreadId, + senderId, + }); + const currentModel = currentSessionState.model; + + const buttons = buildModelsKeyboard({ + provider, + models, + currentModel, + currentPage: safePage, + totalPages, + pageSize, + }); + const text = formatModelsAvailableHeader({ + provider, + total: models.length, + cfg, + agentDir: resolveAgentDir(cfg, currentSessionState.agentId), + sessionEntry: currentSessionState.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; + } + + const modelSet = byProvider.get(selection.provider); + if (!modelSet?.has(selection.model)) { + await editMessageWithButtons( + `❌ Model "${selection.provider}/${selection.model}" is not allowed.`, + [], + ); + return; + } + + // Directly set model override in session + try { + // Get session store path + const storePath = resolveStorePath(cfg.session?.store, { + agentId: sessionState.agentId, + }); + + const resolvedDefault = resolveDefaultModelForAgent({ + cfg, + agentId: sessionState.agentId, + }); + const isDefaultSelection = + selection.provider === resolvedDefault.provider && + selection.model === resolvedDefault.model; + + await updateSessionStore(storePath, (store) => { + const sessionKey = sessionState.sessionKey; + const entry = store[sessionKey] ?? {}; + store[sessionKey] = entry; + applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: selection.provider, + model: selection.model, + isDefault: isDefaultSelection, + }, + }); + }); + + // Update message to show success with visual feedback + const actionText = isDefaultSelection + ? "reset to default" + : `changed to **${selection.provider}/${selection.model}**`; + await editMessageWithButtons( + `✅ Model ${actionText}\n\nThis model will be used for your next message.`, + [], // Empty buttons = remove inline keyboard + ); + } catch (err) { + await editMessageWithButtons(`❌ Failed to change model: ${String(err)}`, []); + } + 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", + }); + }); +}; diff --git a/extensions/telegram/src/bot-handlers.ts b/extensions/telegram/src/bot-handlers.ts index 92d584b8ea9..20b0959cc68 100644 --- a/extensions/telegram/src/bot-handlers.ts +++ b/extensions/telegram/src/bot-handlers.ts @@ -1,1776 +1 @@ -import type { Message, ReactionTypeEmoji } from "@grammyjs/types"; -import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; -import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; -import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime"; -import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-runtime"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; -import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; -import { - loadSessionStore, - resolveSessionStoreEntry, - resolveStorePath, - updateSessionStore, -} from "openclaw/plugin-sdk/config-runtime"; -import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; -import type { - TelegramDirectConfig, - TelegramGroupConfig, - TelegramTopicConfig, -} from "openclaw/plugin-sdk/config-runtime"; -import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/config-runtime"; -import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; -import { - buildPluginBindingResolvedText, - parsePluginBindingApprovalCustomId, - resolvePluginConversationBindingApproval, -} from "openclaw/plugin-sdk/conversation-runtime"; -import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime"; -import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime"; -import { - createInboundDebouncer, - resolveInboundDebounceMs, -} from "openclaw/plugin-sdk/reply-runtime"; -import { buildCommandsPaginationKeyboard } from "openclaw/plugin-sdk/reply-runtime"; -import { - buildModelsProviderData, - formatModelsAvailableHeader, -} from "openclaw/plugin-sdk/reply-runtime"; -import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime"; -import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime"; -import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/reply-runtime"; -import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; -import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; -import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; -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 { - getTelegramTextParts, - buildTelegramGroupPeerId, - buildTelegramParentPeer, - resolveTelegramForumThreadId, - resolveTelegramGroupAllowFromContext, -} from "./bot/helpers.js"; -import type { TelegramContext } from "./bot/types.js"; -import { - resolveTelegramConversationBaseSessionKey, - resolveTelegramConversationRoute, -} from "./conversation-route.js"; -import { enforceTelegramDmAccess } from "./dm-access.js"; -import { - isTelegramExecApprovalApprover, - isTelegramExecApprovalClientEnabled, - shouldEnableTelegramExecApprovalButtons, -} from "./exec-approvals.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"; - -const APPROVE_CALLBACK_DATA_RE = - /^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i; - -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, - telegramTransport, - 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(); - let mediaGroupProcessing: Promise = Promise.resolve(); - - type TextFragmentEntry = { - key: string; - messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>; - timer: ReturnType; - }; - const textFragmentBuffer = new Map(); - let textFragmentProcessing: Promise = 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 & { 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({ - 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 hasDebounceableText = shouldDebounceTextInbound({ - text, - cfg, - commandOptions: { botUsername: entry.botUsername }, - }); - if (entry.debounceLane === "forward") { - // Forwarded bursts often split text + media into adjacent updates. - // Debounce media-only forward entries too so they can coalesce. - return hasDebounceableText || entry.allMedia.length > 0; - } - if (!hasDebounceableText) { - return false; - } - return entry.allMedia.length === 0; - }, - 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, items) => { - runtime.error?.(danger(`telegram debounce flush failed: ${String(err)}`)); - const chatId = items[0]?.msg.chat.id; - if (chatId != null) { - const threadId = items[0]?.msg.message_thread_id; - void bot.api - .sendMessage( - chatId, - "Something went wrong while processing your message. Please try again.", - threadId != null ? { message_thread_id: threadId } : undefined, - ) - .catch((sendErr) => { - logVerbose(`telegram: error fallback send failed: ${String(sendErr)}`); - }); - } - }, - }); - - const resolveTelegramSessionState = (params: { - chatId: number | string; - isGroup: boolean; - isForum: boolean; - messageThreadId?: number; - resolvedThreadId?: number; - senderId?: string | number; - }): { - agentId: string; - sessionEntry: ReturnType[string] | undefined; - sessionKey: string; - model?: string; - } => { - const resolvedThreadId = - params.resolvedThreadId ?? - resolveTelegramForumThreadId({ - isForum: params.isForum, - messageThreadId: params.messageThreadId, - }); - const dmThreadId = !params.isGroup ? params.messageThreadId : undefined; - const topicThreadId = resolvedThreadId ?? dmThreadId; - const { topicConfig } = resolveTelegramGroupConfig(params.chatId, topicThreadId); - const { route } = resolveTelegramConversationRoute({ - cfg, - accountId, - chatId: params.chatId, - isGroup: params.isGroup, - resolvedThreadId, - replyThreadId: topicThreadId, - senderId: params.senderId, - topicAgentId: topicConfig?.agentId, - }); - const baseSessionKey = resolveTelegramConversationBaseSessionKey({ - cfg, - route, - chatId: params.chatId, - isGroup: params.isGroup, - senderId: params.senderId, - }); - 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 = resolveSessionStoreEntry({ store, sessionKey }).existing; - const storedOverride = resolveStoredModelOverride({ - sessionEntry: entry, - sessionStore: store, - sessionKey, - }); - if (storedOverride) { - return { - agentId: route.agentId, - sessionEntry: entry, - sessionKey, - 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, - sessionKey, - model: `${provider}/${model}`, - }; - } - const modelCfg = cfg.agents?.defaults?.model; - return { - agentId: route.agentId, - sessionEntry: entry, - sessionKey, - 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, telegramTransport); - } 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 => { - 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, - telegramTransport, - ); - 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>; - 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 => { - 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> = null; - try { - media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport); - } 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(getTelegramTextParts(msg).text.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[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 clearCallbackButtons = async () => { - const emptyKeyboard = { inline_keyboard: [] }; - const replyMarkup = { reply_markup: emptyKeyboard }; - const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown }) - .editMessageReplyMarkup; - if (typeof editReplyMarkupFn === "function") { - return await ctx.editMessageReplyMarkup(replyMarkup); - } - const apiEditReplyMarkupFn = (bot.api as { editMessageReplyMarkup?: unknown }) - .editMessageReplyMarkup; - if (typeof apiEditReplyMarkupFn === "function") { - return await bot.api.editMessageReplyMarkup( - callbackMessage.chat.id, - callbackMessage.message_id, - replyMarkup, - ); - } - // Fallback path for older clients that do not expose editMessageReplyMarkup. - const messageText = callbackMessage.text ?? callbackMessage.caption; - if (typeof messageText !== "string" || messageText.trim().length === 0) { - return undefined; - } - return await editCallbackMessage(messageText, replyMarkup); - }; - const editCallbackButtons = async ( - buttons: Array< - Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> - >, - ) => { - const keyboard = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] }; - const replyMarkup = { reply_markup: keyboard }; - const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown }) - .editMessageReplyMarkup; - if (typeof editReplyMarkupFn === "function") { - return await ctx.editMessageReplyMarkup(replyMarkup); - } - return await bot.api.editMessageReplyMarkup( - callbackMessage.chat.id, - callbackMessage.message_id, - replyMarkup, - ); - }; - 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[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 chatId = callbackMessage.chat.id; - const isGroup = - callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup"; - const isApprovalCallback = APPROVE_CALLBACK_DATA_RE.test(data); - const inlineButtonsScope = resolveTelegramInlineButtonsScope({ - cfg, - accountId, - }); - const execApprovalButtonsEnabled = - isApprovalCallback && - shouldEnableTelegramExecApprovalButtons({ - cfg, - accountId, - to: String(chatId), - }); - if (!execApprovalButtonsEnabled) { - if (inlineButtonsScope === "off") { - return; - } - 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 = - !execApprovalButtonsEnabled && 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 callbackConversationId = - messageThreadId != null ? `${chatId}:topic:${messageThreadId}` : String(chatId); - const pluginBindingApproval = parsePluginBindingApprovalCustomId(data); - if (pluginBindingApproval) { - const resolved = await resolvePluginConversationBindingApproval({ - approvalId: pluginBindingApproval.approvalId, - decision: pluginBindingApproval.decision, - senderId: senderId || undefined, - }); - await clearCallbackButtons(); - await replyToCallbackChat(buildPluginBindingResolvedText(resolved)); - return; - } - const pluginCallback = await dispatchPluginInteractiveHandler({ - channel: "telegram", - data, - callbackId: callback.id, - ctx: { - accountId, - callbackId: callback.id, - conversationId: callbackConversationId, - parentConversationId: messageThreadId != null ? String(chatId) : undefined, - senderId: senderId || undefined, - senderUsername: senderUsername || undefined, - threadId: messageThreadId, - isGroup, - isForum, - auth: { - isAuthorizedSender: true, - }, - callbackMessage: { - messageId: callbackMessage.message_id, - chatId: String(chatId), - messageText: callbackMessage.text ?? callbackMessage.caption, - }, - }, - respond: { - reply: async ({ text, buttons }) => { - await replyToCallbackChat( - text, - buttons ? { reply_markup: buildInlineKeyboard(buttons) } : undefined, - ); - }, - editMessage: async ({ text, buttons }) => { - await editCallbackMessage( - text, - buttons ? { reply_markup: buildInlineKeyboard(buttons) } : undefined, - ); - }, - editButtons: async ({ buttons }) => { - await editCallbackButtons(buttons); - }, - clearButtons: async () => { - await clearCallbackButtons(); - }, - deleteMessage: async () => { - await deleteCallbackMessage(); - }, - }, - }); - if (pluginCallback.handled) { - return; - } - - if (isApprovalCallback) { - if ( - !isTelegramExecApprovalClientEnabled({ cfg, accountId }) || - !isTelegramExecApprovalApprover({ cfg, accountId, senderId }) - ) { - logVerbose( - `Blocked telegram exec approval callback from ${senderId || "unknown"} (not an approver)`, - ); - return; - } - try { - await clearCallbackButtons(); - } catch (editErr) { - const errStr = String(editErr); - if ( - !errStr.includes("message is not modified") && - !errStr.includes("there is no text in the message to edit") - ) { - logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`); - } - } - } - - 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); - const skillCommands = listSkillCommandsForAgents({ - cfg, - agentIds: [agentId], - }); - 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 sessionState = resolveTelegramSessionState({ - chatId, - isGroup, - isForum, - messageThreadId, - resolvedThreadId, - senderId, - }); - const modelData = await buildModelsProviderData(cfg, sessionState.agentId); - const { byProvider, providers } = modelData; - - const editMessageWithButtons = async ( - text: string, - buttons: ReturnType, - ) => { - 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 currentSessionState = resolveTelegramSessionState({ - chatId, - isGroup, - isForum, - messageThreadId, - resolvedThreadId, - senderId, - }); - const currentModel = currentSessionState.model; - - const buttons = buildModelsKeyboard({ - provider, - models, - currentModel, - currentPage: safePage, - totalPages, - pageSize, - }); - const text = formatModelsAvailableHeader({ - provider, - total: models.length, - cfg, - agentDir: resolveAgentDir(cfg, currentSessionState.agentId), - sessionEntry: currentSessionState.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; - } - - const modelSet = byProvider.get(selection.provider); - if (!modelSet?.has(selection.model)) { - await editMessageWithButtons( - `❌ Model "${selection.provider}/${selection.model}" is not allowed.`, - [], - ); - return; - } - - // Directly set model override in session - try { - // Get session store path - const storePath = resolveStorePath(cfg.session?.store, { - agentId: sessionState.agentId, - }); - - const resolvedDefault = resolveDefaultModelForAgent({ - cfg, - agentId: sessionState.agentId, - }); - const isDefaultSelection = - selection.provider === resolvedDefault.provider && - selection.model === resolvedDefault.model; - - await updateSessionStore(storePath, (store) => { - const sessionKey = sessionState.sessionKey; - const entry = store[sessionKey] ?? {}; - store[sessionKey] = entry; - applyModelOverrideToSessionEntry({ - entry, - selection: { - provider: selection.provider, - model: selection.model, - isDefault: isDefaultSelection, - }, - }); - }); - - // Update message to show success with visual feedback - const actionText = isDefaultSelection - ? "reset to default" - : `changed to **${selection.provider}/${selection.model}**`; - await editMessageWithButtons( - `✅ Model ${actionText}\n\nThis model will be used for your next message.`, - [], // Empty buttons = remove inline keyboard - ); - } catch (err) { - await editMessageWithButtons(`❌ Failed to change model: ${String(err)}`, []); - } - 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", - }); - }); -}; +export * from "./bot-handlers.runtime.js"; diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index 2a6fbf41d0b..e75c17ed7b4 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -3,6 +3,7 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, getChatChannelMeta, @@ -79,7 +80,7 @@ export function createTelegramPluginBase(params: { ChannelPlugin, "id" | "meta" | "setupWizard" | "capabilities" | "reload" | "configSchema" | "config" | "setup" > { - return { + return createChannelPluginBase({ id: TELEGRAM_CHANNEL, meta: { ...getChatChannelMeta(TELEGRAM_CHANNEL), @@ -133,5 +134,8 @@ export function createTelegramPluginBase(params: { ...telegramConfigAccessors, }, setup: params.setup, - }; + }) as Pick< + ChannelPlugin, + "id" | "meta" | "setupWizard" | "capabilities" | "reload" | "configSchema" | "config" | "setup" + >; } diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 1777de07736..6f8952687e2 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -3,6 +3,7 @@ import { collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, @@ -96,7 +97,7 @@ export function createWhatsAppPluginBase(params: { | "setup" | "groups" > { - return { + return createChannelPluginBase({ id: WHATSAPP_CHANNEL, meta: { ...getChatChannelMeta(WHATSAPP_CHANNEL), @@ -218,5 +219,18 @@ export function createWhatsAppPluginBase(params: { resolveToolPolicy: resolveWhatsAppGroupToolPolicy, resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, }, - }; + }) as Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "reload" + | "gatewayMethods" + | "configSchema" + | "config" + | "security" + | "setup" + | "groups" + >; } diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 3571edf9772..1628506a055 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,4 +1,5 @@ import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +import { getChatChannelMeta } from "../channels/registry.js"; import { emptyPluginConfigSchema } from "../plugins/config-schema.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { @@ -130,6 +131,42 @@ type DefinedPluginEntry = { register: NonNullable; } & Pick; +type CreateChannelPluginBaseOptions = { + id: ChannelPlugin["id"]; + meta?: Partial["meta"]>>; + setupWizard?: NonNullable["setupWizard"]>; + capabilities?: ChannelPlugin["capabilities"]; + agentPrompt?: ChannelPlugin["agentPrompt"]; + streaming?: ChannelPlugin["streaming"]; + reload?: ChannelPlugin["reload"]; + gatewayMethods?: ChannelPlugin["gatewayMethods"]; + configSchema?: ChannelPlugin["configSchema"]; + config?: ChannelPlugin["config"]; + security?: ChannelPlugin["security"]; + setup: NonNullable["setup"]>; + groups?: ChannelPlugin["groups"]; +}; + +type CreatedChannelPluginBase = Pick< + ChannelPlugin, + "id" | "meta" | "setup" +> & + Partial< + Pick< + ChannelPlugin, + | "setupWizard" + | "capabilities" + | "agentPrompt" + | "streaming" + | "reload" + | "gatewayMethods" + | "configSchema" + | "config" + | "security" + | "groups" + > + >; + function resolvePluginConfigSchema( configSchema: DefinePluginEntryOptions["configSchema"] = emptyPluginConfigSchema, ): OpenClawPluginConfigSchema { @@ -185,3 +222,27 @@ export function defineChannelPluginEntry({ export function defineSetupPluginEntry(plugin: TPlugin) { return { plugin }; } + +// Shared base object for channel plugins that only need to override a few optional surfaces. +export function createChannelPluginBase( + params: CreateChannelPluginBaseOptions, +): CreatedChannelPluginBase { + return { + id: params.id, + meta: { + ...getChatChannelMeta(params.id as Parameters[0]), + ...params.meta, + }, + ...(params.setupWizard ? { setupWizard: params.setupWizard } : {}), + ...(params.capabilities ? { capabilities: params.capabilities } : {}), + ...(params.agentPrompt ? { agentPrompt: params.agentPrompt } : {}), + ...(params.streaming ? { streaming: params.streaming } : {}), + ...(params.reload ? { reload: params.reload } : {}), + ...(params.gatewayMethods ? { gatewayMethods: params.gatewayMethods } : {}), + ...(params.configSchema ? { configSchema: params.configSchema } : {}), + ...(params.config ? { config: params.config } : {}), + ...(params.security ? { security: params.security } : {}), + ...(params.groups ? { groups: params.groups } : {}), + setup: params.setup, + } as CreatedChannelPluginBase; +} diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index dc72414a1f1..06a1108a45e 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -68,6 +68,7 @@ describe("plugin-sdk subpath exports", () => { expect(typeof coreSdk.definePluginEntry).toBe("function"); expect(typeof coreSdk.defineChannelPluginEntry).toBe("function"); expect(typeof coreSdk.defineSetupPluginEntry).toBe("function"); + expect(typeof coreSdk.createChannelPluginBase).toBe("function"); expect(typeof coreSdk.optionalStringEnum).toBe("function"); expect("runPassiveAccountLifecycle" in asExports(coreSdk)).toBe(false); expect("createLoggerBackedRuntime" in asExports(coreSdk)).toBe(false);