import type { Bot, Context } from "grammy"; import { resolveChunkMode } from "../auto-reply/chunk.js"; import type { CommandArgs } from "../auto-reply/commands-registry.js"; import { buildCommandTextFromArgs, findCommandByNativeName, listNativeCommandSpecs, listNativeCommandSpecsForConfig, parseCommandArgs, resolveCommandArgMenu, } from "../auto-reply/commands-registry.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { normalizeTelegramCommandName, resolveTelegramCustomCommands, TELEGRAM_COMMAND_NAME_PATTERN, } from "../config/telegram-custom-commands.js"; import type { ReplyToMode, TelegramAccountConfig, TelegramGroupConfig, TelegramTopicConfig, } from "../config/types.js"; import { danger, logVerbose } from "../globals.js"; import { getChildLogger } from "../logging.js"; import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; import { executePluginCommand, getPluginCommandSpecs, matchPluginCommand, } from "../plugins/commands.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; import { buildCappedTelegramMenuCommands, buildPluginTelegramMenuCommands, syncTelegramMenuCommands, } from "./bot-native-command-menu.js"; import { TelegramUpdateKeyContext } from "./bot-updates.js"; import { TelegramBotOptions } from "./bot.js"; import { deliverReplies } from "./bot/delivery.js"; import { buildTelegramThreadParams, buildSenderName, buildTelegramGroupFrom, buildTelegramGroupPeerId, buildTelegramParentPeer, resolveTelegramGroupAllowFromContext, resolveTelegramThreadSpec, } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { evaluateTelegramGroupBaseAccess, evaluateTelegramGroupPolicyAccess, } from "./group-access.js"; import { buildInlineKeyboard } from "./send.js"; const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; type TelegramNativeCommandContext = Context & { match?: string }; type TelegramCommandAuthResult = { chatId: number; isGroup: boolean; isForum: boolean; resolvedThreadId?: number; senderId: string; senderUsername: string; groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig; commandAuthorized: boolean; }; export type RegisterTelegramHandlerParams = { cfg: OpenClawConfig; accountId: string; bot: Bot; mediaMaxBytes: number; opts: TelegramBotOptions; runtime: RuntimeEnv; telegramCfg: TelegramAccountConfig; groupAllowFrom?: Array; resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy; resolveTelegramGroupConfig: ( chatId: string | number, messageThreadId?: number, ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean; processMessage: ( ctx: TelegramContext, allMedia: Array<{ path: string; contentType?: string }>, storeAllowFrom: string[], options?: { messageIdOverride?: string; forceWasMentioned?: boolean; }, ) => Promise; logger: ReturnType; }; type RegisterTelegramNativeCommandsParams = { bot: Bot; cfg: OpenClawConfig; runtime: RuntimeEnv; accountId: string; telegramCfg: TelegramAccountConfig; allowFrom?: Array; groupAllowFrom?: Array; replyToMode: ReplyToMode; textLimit: number; useAccessGroups: boolean; nativeEnabled: boolean; nativeSkillsEnabled: boolean; nativeDisabledExplicit: boolean; resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy; resolveTelegramGroupConfig: ( chatId: string | number, messageThreadId?: number, ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean; opts: { token: string }; }; async function resolveTelegramCommandAuth(params: { msg: NonNullable; bot: Bot; cfg: OpenClawConfig; accountId: string; telegramCfg: TelegramAccountConfig; allowFrom?: Array; groupAllowFrom?: Array; useAccessGroups: boolean; resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy; resolveTelegramGroupConfig: ( chatId: string | number, messageThreadId?: number, ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; requireAuth: boolean; }): Promise { const { msg, bot, cfg, accountId, telegramCfg, allowFrom, groupAllowFrom, useAccessGroups, resolveGroupPolicy, resolveTelegramGroupConfig, requireAuth, } = params; const chatId = msg.chat.id; const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; const groupAllowContext = await resolveTelegramGroupAllowFromContext({ chatId, accountId, isForum, messageThreadId, groupAllowFrom, resolveTelegramGroupConfig, }); const { resolvedThreadId, storeAllowFrom, groupConfig, topicConfig, effectiveGroupAllow, hasGroupAllowOverride, } = groupAllowContext; const senderId = msg.from?.id ? String(msg.from.id) : ""; const senderUsername = msg.from?.username ?? ""; const sendAuthMessage = async (text: string) => { await withTelegramApiErrorLogging({ operation: "sendMessage", fn: () => bot.api.sendMessage(chatId, text), }); return null; }; const rejectNotAuthorized = async () => { return await sendAuthMessage("You are not authorized to use this command."); }; const baseAccess = evaluateTelegramGroupBaseAccess({ isGroup, groupConfig, topicConfig, hasGroupAllowOverride, effectiveGroupAllow, senderId, senderUsername, enforceAllowOverride: requireAuth, requireSenderForAllowOverride: true, }); if (!baseAccess.allowed) { if (baseAccess.reason === "group-disabled") { return await sendAuthMessage("This group is disabled."); } if (baseAccess.reason === "topic-disabled") { return await sendAuthMessage("This topic is disabled."); } return await rejectNotAuthorized(); } const policyAccess = evaluateTelegramGroupPolicyAccess({ isGroup, chatId, cfg, telegramCfg, topicConfig, groupConfig, effectiveGroupAllow, senderId, senderUsername, resolveGroupPolicy, enforcePolicy: useAccessGroups, useTopicAndGroupOverrides: false, enforceAllowlistAuthorization: requireAuth, allowEmptyAllowlistEntries: true, requireSenderForAllowlistAuthorization: true, checkChatAllowlist: useAccessGroups, }); if (!policyAccess.allowed) { if (policyAccess.reason === "group-policy-disabled") { return await sendAuthMessage("Telegram group commands are disabled."); } if ( policyAccess.reason === "group-policy-allowlist-no-sender" || policyAccess.reason === "group-policy-allowlist-unauthorized" ) { return await rejectNotAuthorized(); } if (policyAccess.reason === "group-chat-not-allowed") { return await sendAuthMessage("This group is not allowed."); } } const dmAllow = normalizeAllowFromWithStore({ allowFrom: allowFrom, storeAllowFrom, }); const senderAllowed = isSenderAllowed({ allow: dmAllow, senderId, senderUsername, }); const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }], modeWhenAccessGroupsOff: "configured", }); if (requireAuth && !commandAuthorized) { return await rejectNotAuthorized(); } return { chatId, isGroup, isForum, resolvedThreadId, senderId, senderUsername, groupConfig, topicConfig, commandAuthorized, }; } export const registerTelegramNativeCommands = ({ bot, cfg, runtime, accountId, telegramCfg, allowFrom, groupAllowFrom, replyToMode, textLimit, useAccessGroups, nativeEnabled, nativeSkillsEnabled, nativeDisabledExplicit, resolveGroupPolicy, resolveTelegramGroupConfig, shouldSkipUpdate, opts, }: RegisterTelegramNativeCommandsParams) => { const boundRoute = nativeEnabled && nativeSkillsEnabled ? resolveAgentRoute({ cfg, channel: "telegram", accountId }) : null; const boundAgentIds = boundRoute ? [boundRoute.agentId] : null; const skillCommands = nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents(boundAgentIds ? { cfg, agentIds: boundAgentIds } : { cfg }) : []; const nativeCommands = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "telegram", }) : []; const reservedCommands = new Set( listNativeCommandSpecs().map((command) => normalizeTelegramCommandName(command.name)), ); for (const command of skillCommands) { reservedCommands.add(command.name.toLowerCase()); } const customResolution = resolveTelegramCustomCommands({ commands: telegramCfg.customCommands, reservedCommands, }); for (const issue of customResolution.issues) { runtime.error?.(danger(issue.message)); } const customCommands = customResolution.commands; const pluginCommandSpecs = getPluginCommandSpecs(); const existingCommands = new Set( [ ...nativeCommands.map((command) => normalizeTelegramCommandName(command.name)), ...customCommands.map((command) => command.command), ].map((command) => command.toLowerCase()), ); const pluginCatalog = buildPluginTelegramMenuCommands({ specs: pluginCommandSpecs, existingCommands, }); for (const issue of pluginCatalog.issues) { runtime.error?.(danger(issue)); } const allCommandsFull: Array<{ command: string; description: string }> = [ ...nativeCommands .map((command) => { const normalized = normalizeTelegramCommandName(command.name); if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) { runtime.error?.( danger( `Native command "${command.name}" is invalid for Telegram (resolved to "${normalized}"). Skipping.`, ), ); return null; } return { command: normalized, description: command.description, }; }) .filter((cmd): cmd is { command: string; description: string } => cmd !== null), ...(nativeEnabled ? pluginCatalog.commands : []), ...customCommands, ]; const { commandsToRegister, totalCommands, maxCommands, overflowCount } = buildCappedTelegramMenuCommands({ allCommands: allCommandsFull, }); if (overflowCount > 0) { runtime.log?.( `Telegram limits bots to ${maxCommands} commands. ` + `${totalCommands} configured; registering first ${maxCommands}. ` + `Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.`, ); } // Telegram only limits the setMyCommands payload (menu entries). // Keep hidden commands callable by registering handlers for the full catalog. syncTelegramMenuCommands({ bot, runtime, commandsToRegister }); const resolveCommandRuntimeContext = (params: { msg: NonNullable; isGroup: boolean; isForum: boolean; resolvedThreadId?: number; }) => { const { msg, isGroup, isForum, resolvedThreadId } = params; const chatId = msg.chat.id; const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; const threadSpec = resolveTelegramThreadSpec({ isGroup, isForum, messageThreadId, }); const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); const route = resolveAgentRoute({ cfg, channel: "telegram", accountId, peer: { kind: isGroup ? "group" : "direct", id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId), }, parentPeer, }); const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); const tableMode = resolveMarkdownTableMode({ cfg, channel: "telegram", accountId: route.accountId, }); const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); return { chatId, threadSpec, route, mediaLocalRoots, tableMode, chunkMode }; }; const buildCommandDeliveryBaseOptions = (params: { chatId: string | number; mediaLocalRoots?: readonly string[]; threadSpec: ReturnType; tableMode: ReturnType; chunkMode: ReturnType; }) => ({ chatId: String(params.chatId), token: opts.token, runtime, bot, mediaLocalRoots: params.mediaLocalRoots, replyToMode, textLimit, thread: params.threadSpec, tableMode: params.tableMode, chunkMode: params.chunkMode, linkPreview: telegramCfg.linkPreview, }); if (commandsToRegister.length > 0 || pluginCatalog.commands.length > 0) { if (typeof (bot as unknown as { command?: unknown }).command !== "function") { logVerbose("telegram: bot.command unavailable; skipping native handlers"); } else { for (const command of nativeCommands) { const normalizedCommandName = normalizeTelegramCommandName(command.name); bot.command(normalizedCommandName, async (ctx: TelegramNativeCommandContext) => { const msg = ctx.message; if (!msg) { return; } if (shouldSkipUpdate(ctx)) { return; } const auth = await resolveTelegramCommandAuth({ msg, bot, cfg, accountId, telegramCfg, allowFrom, groupAllowFrom, useAccessGroups, resolveGroupPolicy, resolveTelegramGroupConfig, requireAuth: true, }); if (!auth) { return; } const { chatId, isGroup, isForum, resolvedThreadId, senderId, senderUsername, groupConfig, topicConfig, commandAuthorized, } = auth; const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = resolveCommandRuntimeContext({ msg, isGroup, isForum, resolvedThreadId, }); const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ chatId, mediaLocalRoots, threadSpec, tableMode, chunkMode, }); const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; const commandDefinition = findCommandByNativeName(command.name, "telegram"); const rawText = ctx.match?.trim() ?? ""; const commandArgs = commandDefinition ? parseCommandArgs(commandDefinition, rawText) : rawText ? ({ raw: rawText } satisfies CommandArgs) : undefined; const prompt = commandDefinition ? buildCommandTextFromArgs(commandDefinition, commandArgs) : rawText ? `/${command.name} ${rawText}` : `/${command.name}`; const menu = commandDefinition ? resolveCommandArgMenu({ command: commandDefinition, args: commandArgs, cfg, }) : null; if (menu && commandDefinition) { const title = menu.title ?? `Choose ${menu.arg.description || menu.arg.name} for /${commandDefinition.nativeName ?? commandDefinition.key}.`; const rows: Array> = []; for (let i = 0; i < menu.choices.length; i += 2) { const slice = menu.choices.slice(i, i + 2); rows.push( slice.map((choice) => { const args: CommandArgs = { values: { [menu.arg.name]: choice.value }, }; return { text: choice.label, callback_data: buildCommandTextFromArgs(commandDefinition, args), }; }), ); } const replyMarkup = buildInlineKeyboard(rows); await withTelegramApiErrorLogging({ operation: "sendMessage", runtime, fn: () => bot.api.sendMessage(chatId, title, { ...(replyMarkup ? { reply_markup: replyMarkup } : {}), ...threadParams, }), }); return; } const baseSessionKey = route.sessionKey; // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId), }) : null; const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills); const systemPromptParts = [ groupConfig?.systemPrompt?.trim() || null, topicConfig?.systemPrompt?.trim() || null, ].filter((entry): entry is string => Boolean(entry)); const groupSystemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; const conversationLabel = isGroup ? msg.chat.title ? `${msg.chat.title} id:${chatId}` : `group:${chatId}` : (buildSenderName(msg) ?? String(senderId || chatId)); const ctxPayload = finalizeInboundContext({ Body: prompt, BodyForAgent: prompt, RawBody: prompt, CommandBody: prompt, CommandArgs: commandArgs, From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, To: `slash:${senderId || chatId}`, ChatType: isGroup ? "group" : "direct", ConversationLabel: conversationLabel, GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, SenderName: buildSenderName(msg), SenderId: senderId || undefined, SenderUsername: senderUsername || undefined, Surface: "telegram", MessageSid: String(msg.message_id), Timestamp: msg.date ? msg.date * 1000 : undefined, WasMentioned: true, CommandAuthorized: commandAuthorized, CommandSource: "native" as const, SessionKey: `telegram:slash:${senderId || chatId}`, AccountId: route.accountId, CommandTargetSessionKey: sessionKey, MessageThreadId: threadSpec.id, IsForum: isForum, // Originating context for sub-agent announce routing OriginatingChannel: "telegram" as const, OriginatingTo: `telegram:${chatId}`, }); const disableBlockStreaming = typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming : undefined; const deliveryState = { delivered: false, skippedNonSilent: 0, }; const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg, agentId: route.agentId, channel: "telegram", accountId: route.accountId, }); await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { ...prefixOptions, deliver: async (payload, _info) => { const result = await deliverReplies({ replies: [payload], ...deliveryBaseOptions, }); if (result.delivered) { deliveryState.delivered = true; } }, onSkip: (_payload, info) => { if (info.reason !== "silent") { deliveryState.skippedNonSilent += 1; } }, onError: (err, info) => { runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); }, }, replyOptions: { skillFilter, disableBlockStreaming, onModelSelected, }, }); if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { await deliverReplies({ replies: [{ text: EMPTY_RESPONSE_FALLBACK }], ...deliveryBaseOptions, }); } }); } for (const pluginCommand of pluginCatalog.commands) { bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => { const msg = ctx.message; if (!msg) { return; } if (shouldSkipUpdate(ctx)) { return; } const chatId = msg.chat.id; const rawText = ctx.match?.trim() ?? ""; const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`; const match = matchPluginCommand(commandBody); if (!match) { await withTelegramApiErrorLogging({ operation: "sendMessage", runtime, fn: () => bot.api.sendMessage(chatId, "Command not found."), }); return; } const auth = await resolveTelegramCommandAuth({ msg, bot, cfg, accountId, telegramCfg, allowFrom, groupAllowFrom, useAccessGroups, resolveGroupPolicy, resolveTelegramGroupConfig, requireAuth: match.command.requireAuth !== false, }); if (!auth) { return; } const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth; const { threadSpec, mediaLocalRoots, tableMode, chunkMode } = resolveCommandRuntimeContext({ msg, isGroup, isForum, resolvedThreadId, }); const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ chatId, mediaLocalRoots, threadSpec, tableMode, chunkMode, }); const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`; const to = `telegram:${chatId}`; const result = await executePluginCommand({ command: match.command, args: match.args, senderId, channel: "telegram", isAuthorizedSender: commandAuthorized, commandBody, config: cfg, from, to, accountId, messageThreadId: threadSpec.id, }); await deliverReplies({ replies: [result], ...deliveryBaseOptions, }); }); } } } else if (nativeDisabledExplicit) { withTelegramApiErrorLogging({ operation: "setMyCommands", runtime, fn: () => bot.api.setMyCommands([]), }).catch(() => {}); } };