diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 4472fd6d167c..070abfb3639a 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -418,7 +418,19 @@ curl "https://api.telegram.org/bot/getUpdates" - Outbound text uses Telegram rich messages. + Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients. + + Set `channels.telegram.richMessages: true` to opt into Bot API 10.1 rich messages: + +```json5 +{ + channels: { + telegram: { + richMessages: true, + }, + }, +} +``` - Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML. - Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas. @@ -426,6 +438,8 @@ curl "https://api.telegram.org/bot/getUpdates" This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks. + Rich messages require compatible Telegram clients. Some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported, so keep this option disabled unless every client used with the bot can render them. + Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text. @@ -1081,7 +1095,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels - command/menu: `commands.native`, `commands.nativeSkills`, `customCommands` - threading/replies: `replyToMode` - streaming: `streaming` (preview), `streaming.preview.toolProgress`, `blockStreaming` -- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix` +- formatting/delivery: `textChunkLimit`, `chunkMode`, `richMessages`, `linkPreview`, `responsePrefix` - media/network: `mediaMaxMb`, `mediaGroupFlushMs`, `timeoutSeconds`, `pollingStallThresholdMs`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy` - custom API root: `apiRoot` (Bot API root only; do not include `/bot`) - webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost` diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index 6f179d2760e1..073683b0c7fe 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -49,6 +49,7 @@ import { resolveTelegramOutboundClientTimeoutFloorSeconds, } from "./client-fetch.js"; import { resolveTelegramTransport } from "./fetch.js"; +import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; import { stringifyTelegramRawUpdateForLog } from "./raw-update-log.js"; import { TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js"; import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js"; @@ -290,11 +291,13 @@ export function createTelegramBotCore( DEFAULT_GROUP_HISTORY_LIMIT, ); const groupHistories = new Map(); + const telegramTextLimit = + telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT; const textLimit = Math.min( resolveTextChunkLimit(cfg, "telegram", account.accountId, { - fallbackLimit: TELEGRAM_RICH_TEXT_LIMIT, + fallbackLimit: telegramTextLimit, }), - TELEGRAM_RICH_TEXT_LIMIT, + telegramTextLimit, ); const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom; diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index eb2023bd1ac7..dc363e7dbea9 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -649,6 +649,61 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).toHaveBeenCalledTimes(1); }); + it("renders default draft previews with standard Telegram HTML", async () => { + const draftStream = createDraftStream(); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "# Heading" }); + await dispatcherOptions.deliver({ text: "# Heading" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ context: createContext() }); + + const params = expectDraftStreamParams({}); + const renderText = params.renderText as ((text: string) => Record) | undefined; + expect(renderText?.("# Heading")).toEqual({ + text: "Heading", + parseMode: "HTML", + }); + }); + + it("renders rich draft previews only when enabled", async () => { + resolveMarkdownTableMode.mockReturnValueOnce("block"); + const draftStream = createDraftStream(); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ + text: "| A | B |\n| --- | --- |\n| 1 | 2 |", + }); + await dispatcherOptions.deliver( + { text: "| A | B |\n| --- | --- |\n| 1 | 2 |" }, + { kind: "final" }, + ); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext(), + telegramCfg: { richMessages: true }, + }); + + const params = expectDraftStreamParams({ richMessages: true }); + const renderText = params.renderText as ((text: string) => Record) | undefined; + const preview = renderText?.("| A | B |\n| --- | --- |\n| 1 | 2 |"); + expect(preview?.richMessage).toEqual( + expect.objectContaining({ + html: expect.stringContaining(""), + }), + ); + }); + it("recovers forum thread context from a topic-scoped session key", async () => { const recordInboundSession = vi.fn(async () => undefined); const oldHistoryKey = "-1003774691294:topic:1"; @@ -1521,7 +1576,7 @@ describe("dispatchTelegramMessage draft streaming", () => { telegramCfg: { streaming: { mode: "partial" } }, }); - expectDraftStreamParams({ maxChars: 4096 }); + expectDraftStreamParams({ maxChars: 4000 }); }); it("streams text-only finals into the answer message", async () => { diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 578997fc25ee..c2505cf49697 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -107,6 +107,7 @@ import { shouldSuppressTelegramError, } from "./error-policy.js"; import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; +import { renderTelegramHtmlText } from "./format.js"; import { includesRecentTelegramGroupHistoryContext } from "./group-history-context.js"; import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-delivery.js"; import { @@ -116,6 +117,7 @@ import { type LaneDeliveryResult, type LaneName, } from "./lane-delivery.js"; +import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; import { recordOutboundMessageForPromptContext } from "./outbound-message-context.js"; import { createTelegramReasoningStepState, @@ -891,20 +893,29 @@ export const dispatchTelegramMessage = async ({ const draftMaxChars = streamMode === "block" ? Math.min(resolveTelegramDraftStreamingChunking(cfg, route.accountId).maxChars, textLimit) - : Math.min(textLimit, TELEGRAM_RICH_TEXT_LIMIT); + : Math.min( + textLimit, + telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT, + ); const tableMode = resolveMarkdownTableMode({ cfg, channel: "telegram", accountId: route.accountId, - supportsBlockTables: true, - }); - const renderStreamText = (text: string) => ({ - text, - richMessage: buildTelegramRichMarkdown(text, { - tableMode, - skipEntityDetection: telegramCfg.linkPreview === false, - }), + supportsBlockTables: telegramCfg.richMessages === true, }); + const renderStreamText = (text: string): TelegramDraftPreview => + telegramCfg.richMessages === true + ? { + text, + richMessage: buildTelegramRichMarkdown(text, { + tableMode, + skipEntityDetection: telegramCfg.linkPreview === false, + }), + } + : { + text: renderTelegramHtmlText(text, { tableMode }), + parseMode: "HTML", + }; const accountBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(telegramCfg) ?? cfg.agents?.defaults?.blockStreamingDefault === "on"; @@ -988,6 +999,7 @@ export const dispatchTelegramMessage = async ({ maxChars: draftMaxChars, thread: threadSpec, replyToMessageId: draftReplyToMessageId, + richMessages: telegramCfg.richMessages, minInitialChars: draftMinInitialChars, renderText: renderStreamText, onSupersededPreview: (superseded) => { @@ -1507,6 +1519,7 @@ export const dispatchTelegramMessage = async ({ thread: threadSpec, tableMode, chunkMode, + richMessages: telegramCfg.richMessages, linkPreview: telegramCfg.linkPreview, replyQuoteMessageId, replyQuoteText, diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 5f4de16bedc1..15f35fb3906e 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -703,6 +703,25 @@ describe("registerTelegramNativeCommands", () => { expect(replyAt(deliverParams).isError).toBe(true); }); + it("uses rich messages for plugin command replies when enabled", async () => { + const { handler } = registerPlugCommand({ + cfg: { + channels: { + telegram: { + richMessages: true, + }, + }, + }, + registerOverrides: { + telegramCfg: { richMessages: true } as TelegramAccountConfig, + }, + }); + + await handler(createPrivateCommandContext()); + + expect(firstDeliverRepliesParams().richMessages).toBe(true); + }); + it("forwards topic-scoped binding context to Telegram plugin commands", async () => { const { handler } = registerPlugCommand(); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index badc921c19bf..b18cf15ae5ab 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -973,6 +973,7 @@ export const registerTelegramNativeCommands = ({ tableMode: ReturnType; chunkMode: TelegramChunkMode; linkPreview?: boolean; + richMessages?: boolean; }) => ({ cfg: params.cfg, chatId: String(params.chatId), @@ -992,6 +993,7 @@ export const registerTelegramNativeCommands = ({ tableMode: params.tableMode, chunkMode: params.chunkMode, linkPreview: params.linkPreview, + richMessages: params.richMessages, }); const resolveCommandTargetSessionKey = (params: { runtimeCfg: OpenClawConfig; @@ -1209,6 +1211,7 @@ export const registerTelegramNativeCommands = ({ tableMode, chunkMode, linkPreview: runtimeTelegramCfg.linkPreview, + richMessages: runtimeTelegramCfg.richMessages, }); let topicName: string | undefined; if (isForum && resolvedThreadId != null) { @@ -1431,6 +1434,7 @@ export const registerTelegramNativeCommands = ({ tableMode, chunkMode, linkPreview: runtimeTelegramCfg.linkPreview, + richMessages: runtimeTelegramCfg.richMessages, }); const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`; const to = `telegram:${chatId}`; diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 8af216f1271f..3c52ec393922 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -4,15 +4,15 @@ import { createOutboundPayloadPlan, projectOutboundPayloadPlanForDelivery, } from "openclaw/plugin-sdk/channel-outbound"; -import type { ReplyToMode } from "openclaw/plugin-sdk/config-contracts"; -import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts"; -import { fireAndForgetHook } from "openclaw/plugin-sdk/hook-runtime"; -import { createInternalHookEvent, triggerInternalHook } from "openclaw/plugin-sdk/hook-runtime"; +import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-contracts"; import { buildCanonicalSentMessageHookContext, + createInternalHookEvent, + fireAndForgetHook, toInternalMessageSentContext, toPluginMessageContext, toPluginMessageSentEvent, + triggerInternalHook, } from "openclaw/plugin-sdk/hook-runtime"; import type { ReplyPayloadDelivery } from "openclaw/plugin-sdk/interactive-runtime"; import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; @@ -23,7 +23,7 @@ import { probeVideoDimensions, } from "openclaw/plugin-sdk/media-runtime"; import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; -import type { ChunkMode } from "openclaw/plugin-sdk/reply-chunking"; +import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -31,13 +31,14 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "../button-types.js"; import { splitTelegramCaption } from "../caption.js"; -import { renderTelegramHtmlText } from "../format.js"; -import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js"; import { - splitTelegramRichMessageTextChunks, - TELEGRAM_RICH_TEXT_LIMIT, - type TelegramRichTextChunk, -} from "../rich-message.js"; + markdownToTelegramChunks, + markdownToTelegramHtml, + renderTelegramHtmlText, + wrapFileReferencesInHtml, +} from "../format.js"; +import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js"; +import { splitTelegramRichMessageTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js"; import { buildInlineKeyboard } from "../send.js"; import { resolveTelegramVoiceSend } from "../voice.js"; import { @@ -75,23 +76,58 @@ type TelegramReplyQuoteForSend = { entities?: unknown[]; }; -type ChunkTextFn = (markdown: string) => TelegramRichTextChunk[]; +type TelegramDeliveryTextChunk = { + text: string; + plainText: string; + textMode: "html"; +}; + +type ChunkTextFn = (markdown: string) => TelegramDeliveryTextChunk[]; function buildChunkTextResolver(params: { textLimit: number; chunkMode: ChunkMode; tableMode?: MarkdownTableMode; + richMessages?: boolean; skipEntityDetection?: boolean; }): ChunkTextFn { + if (params.richMessages === true) { + return (markdown: string) => + splitTelegramRichMessageTextChunks({ + text: markdown, + textLimit: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT), + textMode: "markdown", + chunkMode: params.chunkMode, + tableMode: params.tableMode, + skipEntityDetection: params.skipEntityDetection, + }); + } return (markdown: string) => { - return splitTelegramRichMessageTextChunks({ - text: markdown, - textLimit: params.textLimit, - textMode: "markdown", - chunkMode: params.chunkMode, - tableMode: params.tableMode, - skipEntityDetection: params.skipEntityDetection, - }); + const markdownChunks = + params.chunkMode === "newline" + ? chunkMarkdownTextWithMode(markdown, params.textLimit, params.chunkMode) + : [markdown]; + const chunks: ReturnType = []; + for (const chunk of markdownChunks) { + const nested = markdownToTelegramChunks(chunk, params.textLimit, { + tableMode: params.tableMode, + }); + if (!nested.length && chunk) { + chunks.push({ + html: wrapFileReferencesInHtml( + markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }), + ), + text: chunk, + }); + continue; + } + chunks.push(...nested); + } + return chunks.map((chunk) => ({ + text: chunk.html, + plainText: chunk.text, + textMode: "html" as const, + })); }; } @@ -158,9 +194,10 @@ async function deliverTextReply(params: { replyQuoteText?: string; replyQuotePosition?: number; replyQuoteEntities?: unknown[]; + richMessages?: boolean; + tableMode?: MarkdownTableMode; linkPreview?: boolean; silent?: boolean; - tableMode?: MarkdownTableMode; replyToId?: number; replyToMode: ReplyToMode; progress: DeliveryProgress; @@ -189,6 +226,8 @@ async function deliverTextReply(params: { replyQuoteEntities: params.replyQuoteEntities, thread: params.thread, textMode: chunk.textMode, + plainText: chunk.plainText, + richMessages: params.richMessages, linkPreview: params.linkPreview, tableMode: params.tableMode, silent: params.silent, @@ -211,9 +250,10 @@ async function sendPendingFollowUpText(params: { chunkText: ChunkTextFn; text: string; replyMarkup?: ReturnType; + richMessages?: boolean; + tableMode?: MarkdownTableMode; linkPreview?: boolean; silent?: boolean; - tableMode?: MarkdownTableMode; replyToId?: number; replyToMode: ReplyToMode; progress: DeliveryProgress; @@ -231,6 +271,8 @@ async function sendPendingFollowUpText(params: { replyToMessageId, thread: params.thread, textMode: chunk.textMode, + plainText: chunk.plainText, + richMessages: params.richMessages, linkPreview: params.linkPreview, tableMode: params.tableMode, silent: params.silent, @@ -275,9 +317,10 @@ async function sendTelegramVoiceFallbackText(opts: { replyQuotePosition?: number; replyQuoteEntities?: unknown[]; thread?: TelegramThreadSpec | null; + richMessages?: boolean; + tableMode?: MarkdownTableMode; linkPreview?: boolean; silent?: boolean; - tableMode?: MarkdownTableMode; replyMarkup?: ReturnType; replyQuoteText?: string; }): Promise { @@ -296,6 +339,8 @@ async function sendTelegramVoiceFallbackText(opts: { replyQuoteEntities: applyQuoteForChunk ? opts.replyQuoteEntities : undefined, thread: opts.thread, textMode: chunk.textMode, + plainText: chunk.plainText, + richMessages: opts.richMessages, linkPreview: opts.linkPreview, tableMode: opts.tableMode, silent: opts.silent, @@ -319,6 +364,7 @@ async function deliverMediaReply(params: { runtime: RuntimeEnv; thread?: TelegramThreadSpec | null; tableMode?: MarkdownTableMode; + richMessages?: boolean; mediaLocalRoots?: readonly string[]; mediaMaxBytes?: number; chunkText: ChunkTextFn; @@ -480,6 +526,8 @@ async function deliverMediaReply(params: { replyQuotePosition: params.replyQuotePosition, replyQuoteEntities: params.replyQuoteEntities, thread: params.thread, + richMessages: params.richMessages, + tableMode: params.tableMode, linkPreview: params.linkPreview, silent: params.silent, replyMarkup: params.replyMarkup, @@ -511,6 +559,8 @@ async function deliverMediaReply(params: { chunkText: params.chunkText, replyToId: undefined, thread: params.thread, + richMessages: params.richMessages, + tableMode: params.tableMode, linkPreview: params.linkPreview, silent: params.silent, replyMarkup: params.replyMarkup, @@ -560,9 +610,10 @@ async function deliverMediaReply(params: { chunkText: params.chunkText, text: pendingFollowUpText, replyMarkup: params.replyMarkup, + richMessages: params.richMessages, + tableMode: params.tableMode, linkPreview: params.linkPreview, silent: params.silent, - tableMode: params.tableMode, replyToId: params.replyToId, replyToMode: params.replyToMode, progress: params.progress, @@ -693,6 +744,8 @@ export async function deliverReplies(params: { thread?: TelegramThreadSpec | null; tableMode?: MarkdownTableMode; chunkMode?: ChunkMode; + /** Opt into Telegram Bot API 10.1 rich text delivery. */ + richMessages?: boolean; /** Callback invoked before sending a voice message to switch typing indicator. */ onVoiceRecording?: () => Promise | void; /** Controls whether link previews are shown. Default: true (previews enabled). */ @@ -725,9 +778,13 @@ export async function deliverReplies(params: { const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false; const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false; const chunkText = buildChunkTextResolver({ - textLimit: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT), + textLimit: + params.richMessages === true + ? Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT) + : Math.min(params.textLimit, 4000), chunkMode: params.chunkMode ?? "length", tableMode: params.tableMode, + richMessages: params.richMessages, skipEntityDetection: params.linkPreview === false, }); const candidateReplies: ReplyPayload[] = []; @@ -847,9 +904,10 @@ export async function deliverReplies(params: { replyQuoteText: replyQuote.text, replyQuotePosition: replyQuote.position, replyQuoteEntities: replyQuote.entities, + richMessages: params.richMessages, + tableMode: params.tableMode, linkPreview: params.linkPreview, silent: params.silent, - tableMode: params.tableMode, replyToId, replyToMode: params.replyToMode, progress, @@ -863,6 +921,7 @@ export async function deliverReplies(params: { runtime: params.runtime, thread: params.thread, tableMode: params.tableMode, + richMessages: params.richMessages, mediaLocalRoots: params.mediaLocalRoots, mediaMaxBytes: params.mediaMaxBytes, chunkText, diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts index b24035852ce2..77951e6d5e8b 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -5,6 +5,7 @@ import { createTelegramRetryRunner } from "openclaw/plugin-sdk/retry-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { withTelegramApiErrorLogging } from "../api-logging.js"; +import { markdownToTelegramHtml } from "../format.js"; import { isSafeToRetrySendError, isTelegramRateLimitError } from "../network-errors.js"; import { buildTelegramSendParams, @@ -22,6 +23,8 @@ import type { TelegramThreadSpec } from "./helpers.js"; export { buildTelegramSendParams } from "../reply-parameters.js"; +const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; +const EMPTY_TEXT_ERR_RE = /message text is empty/i; const QUOTE_PARAM_RE = /\bquote not found\b|\bQUOTE_TEXT_INVALID\b|\bquote text invalid\b/i; const GrammyErrorCtor: typeof GrammyError | undefined = typeof GrammyError === "function" ? GrammyError : undefined; @@ -73,14 +76,14 @@ export async function sendTelegramWithThreadFallback(params: { } catch (err) { if (hasNativeQuote && isTelegramQuoteParamError(err)) { params.runtime.log?.( - `telegram ${params.operation}: native quote rejected; retrying without quote text`, + `telegram ${params.operation}: native quote rejected; retrying with legacy reply_to_message_id`, ); - const removeNativeQuoteParam = - params.removeNativeQuoteParam ?? removeTelegramNativeQuoteParam; return await sendTelegramWithThreadFallback({ ...params, - operation: `${params.operation} (reply retry)`, - requestParams: removeNativeQuoteParam(params.requestParams), + operation: `${params.operation} (legacy reply retry)`, + requestParams: (params.removeNativeQuoteParam ?? removeTelegramNativeQuoteParam)( + params.requestParams, + ), }); } throw err; @@ -100,6 +103,8 @@ export async function sendTelegramText( replyQuoteEntities?: unknown[]; thread?: TelegramThreadSpec | null; textMode?: "markdown" | "html"; + plainText?: string; + richMessages?: boolean; linkPreview?: boolean; tableMode?: MarkdownTableMode; silent?: boolean; @@ -115,31 +120,88 @@ export async function sendTelegramText( thread: opts?.thread, silent: opts?.silent, }); - const richParams = toTelegramRichMessageContextParams(baseParams); const textMode = opts?.textMode ?? "markdown"; - const richMessage = buildTelegramRichMessage(text, textMode, { - skipEntityDetection: opts?.linkPreview === false, - tableMode: opts?.tableMode, - }); - const richRawApi = getTelegramRichRawApi(bot.api); - - if (!text.trim()) { - throw new Error("Message must be non-empty for Telegram sends"); + if (opts?.richMessages === true) { + const richMessage = buildTelegramRichMessage(text, textMode, { + skipEntityDetection: opts.linkPreview === false, + tableMode: opts.tableMode, + }); + const res = await sendTelegramWithThreadFallback({ + operation: "sendRichMessage", + runtime, + thread: opts.thread, + requestParams: toTelegramRichMessageContextParams(baseParams), + removeNativeQuoteParam: removeTelegramRichNativeQuoteParam, + send: (effectiveParams) => + getTelegramRichRawApi(bot.api).sendRichMessage({ + chat_id: chatId, + rich_message: richMessage, + ...(opts.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), + ...effectiveParams, + }), + }); + runtime.log?.(`telegram sendRichMessage ok chat=${chatId} message=${res.message_id}`); + return res.message_id; + } + // Add link_preview_options when link preview is disabled. + const linkPreviewEnabled = opts?.linkPreview ?? true; + const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; + const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); + const fallbackText = opts?.plainText ?? text; + const hasFallbackText = fallbackText.trim().length > 0; + const sendPlainFallback = async () => { + const res = await sendTelegramWithThreadFallback({ + operation: "sendMessage", + runtime, + thread: opts?.thread, + requestParams: baseParams, + send: (effectiveParams) => + bot.api.sendMessage(chatId, fallbackText, { + ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), + ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), + ...effectiveParams, + }), + }); + runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`); + return res.message_id; + }; + + // Markdown can render to empty HTML for syntax-only chunks; recover with plain text. + if (!htmlText.trim()) { + if (!hasFallbackText) { + throw new Error("telegram sendMessage failed: empty formatted text and empty plain fallback"); + } + return await sendPlainFallback(); + } + try { + const res = await sendTelegramWithThreadFallback({ + operation: "sendMessage", + runtime, + thread: opts?.thread, + requestParams: baseParams, + shouldLog: (err) => { + const errText = formatErrorMessage(err); + return !PARSE_ERR_RE.test(errText) && !EMPTY_TEXT_ERR_RE.test(errText); + }, + send: (effectiveParams) => + bot.api.sendMessage(chatId, htmlText, { + parse_mode: "HTML", + ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), + ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), + ...effectiveParams, + }), + }); + runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`); + return res.message_id; + } catch (err) { + const errText = formatErrorMessage(err); + if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) { + if (!hasFallbackText) { + throw err; + } + runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`); + return await sendPlainFallback(); + } + throw err; } - const res = await sendTelegramWithThreadFallback({ - operation: "sendRichMessage", - runtime, - thread: opts?.thread, - requestParams: richParams, - removeNativeQuoteParam: removeTelegramRichNativeQuoteParam, - send: (effectiveParams) => - richRawApi.sendRichMessage({ - chat_id: chatId, - rich_message: richMessage, - ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), - ...effectiveParams, - }), - }); - runtime.log?.(`telegram sendRichMessage ok chat=${chatId} message=${res.message_id}`); - return res.message_id; } diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index f95066d8c45d..a36ed0f4d282 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -813,7 +813,7 @@ describe("deliverReplies", () => { }); }); - it("skips rich entity detection when link previews are disabled", async () => { + it("disables link previews without rich-only entity flags", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, @@ -830,7 +830,10 @@ describe("deliverReplies", () => { expect(firstMockCallArg(sendMessage, 0)).toBe("123"); firstSendText(sendMessage); - expectRecordFields(mockCallArg(sendMessage, 0, 2), { skip_entity_detection: true }); + expectRecordFields(mockCallArg(sendMessage, 0, 2), { + link_preview_options: { is_disabled: true }, + }); + expect(mockCallArg(sendMessage, 0, 2)).not.toHaveProperty("skip_entity_detection"); }); it("includes message_thread_id for DM topics", async () => { @@ -1097,6 +1100,48 @@ describe("deliverReplies", () => { } }); + it("retries rich messages without converting reply parameters to legacy fields", async () => { + const runtime = createRuntime(); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(createQuoteNotFoundError()) + .mockResolvedValueOnce({ + message_id: 11, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [{ text: "Hello there", replyToId: "500" }], + runtime, + bot, + replyToMode: "all", + replyQuoteMessageId: 500, + replyQuoteText: " quoted text\n", + richMessages: true, + }); + + const raw = bot.api.raw as unknown as { + sendRichMessage: ReturnType; + }; + const { sendRichMessage } = raw; + expect(sendRichMessage).toHaveBeenCalledTimes(2); + expectRecordFields(firstMockCallArg(sendRichMessage, 0), { + reply_parameters: { + message_id: 500, + quote: " quoted text\n", + allow_sending_without_reply: true, + }, + }); + expectRecordFields(mockCallArg(sendRichMessage, 1, 0), { + reply_parameters: { + message_id: 500, + allow_sending_without_reply: true, + }, + }); + expect(mockCallArg(sendRichMessage, 1, 0)).not.toHaveProperty("reply_to_message_id"); + }); + it("uses legacy reply id when selected reply target differs from quote source", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ diff --git a/extensions/telegram/src/channel-actions.contract.test.ts b/extensions/telegram/src/channel-actions.contract.test.ts index 2a072fe80b15..67eea90a312a 100644 --- a/extensions/telegram/src/channel-actions.contract.test.ts +++ b/extensions/telegram/src/channel-actions.contract.test.ts @@ -23,12 +23,78 @@ describe("telegram actions contract", () => { ], }); - it("advertises Telegram rich text to the agent prompt", () => { + it.each([ + { richMessages: undefined, expected: false }, + { richMessages: false, expected: false }, + { richMessages: true, expected: true }, + ])("advertises Telegram rich text only when enabled", ({ richMessages, expected }) => { const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({ cfg: { channels: { telegram: { botToken: "123:telegram-test-token", + richMessages, + }, + }, + } as OpenClawConfig, + }); + + expect(capabilities).toContain("inlineButtons"); + expect(capabilities?.includes("richText")).toBe(expected); + }); + + it("uses the selected Telegram account's rich text setting", () => { + const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({ + cfg: { + channels: { + telegram: { + botToken: "123:telegram-test-token", + richMessages: true, + accounts: { + ops: { + richMessages: false, + }, + }, + }, + }, + } as OpenClawConfig, + accountId: "ops", + }); + + expect(capabilities).not.toContain("richText"); + }); + + it("does not resolve Telegram credentials while checking prompt capabilities", () => { + expect(() => + telegramPlugin.agentPrompt?.messageToolCapabilities?.({ + cfg: { + channels: { + telegram: { + tokenFile: "/definitely/missing/telegram-token", + richMessages: true, + }, + }, + } as OpenClawConfig, + }), + ).not.toThrow(); + }); + + it("uses the configured default Telegram account for prompt capabilities", () => { + const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({ + cfg: { + channels: { + telegram: { + defaultAccount: "ops", + accounts: { + default: { + botToken: "123:default-token", + richMessages: false, + }, + ops: { + botToken: "123:ops-token", + richMessages: true, + }, + }, }, }, } as OpenClawConfig, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 135f6cdaaf24..8411e87ec325 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -36,7 +36,12 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { resolveTelegramAccount, type ResolvedTelegramAccount } from "./accounts.js"; +import { + mergeTelegramAccountConfig, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + type ResolvedTelegramAccount, +} from "./accounts.js"; import { resolveTelegramAutoThreadId } from "./action-threading.js"; import { lookupTelegramChatId } from "./api-fetch.js"; import { telegramApprovalCapability } from "./approval-native.js"; @@ -783,7 +788,12 @@ export const telegramPlugin = createChatChannelPlugin({ cfg, accountId: accountId ?? undefined, }); - return inlineButtonsScope === "off" ? ["richText"] : ["inlineButtons", "richText"]; + const capabilities = inlineButtonsScope === "off" ? [] : ["inlineButtons"]; + const selectedAccountId = accountId ?? resolveDefaultTelegramAccountId(cfg); + if (mergeTelegramAccountConfig(cfg, selectedAccountId).richMessages === true) { + capabilities.push("richText"); + } + return capabilities; }, reactionGuidance: ({ cfg, accountId }) => { const level = resolveTelegramReactionLevel({ diff --git a/extensions/telegram/src/config-schema.test.ts b/extensions/telegram/src/config-schema.test.ts index 0f349151da9d..728719f9b84d 100644 --- a/extensions/telegram/src/config-schema.test.ts +++ b/extensions/telegram/src/config-schema.test.ts @@ -153,6 +153,19 @@ describe("telegram custom commands schema", () => { } }); + it("accepts rich message opt-in per account", () => { + const res = TelegramConfigSchema.safeParse({ + richMessages: true, + accounts: { ops: { richMessages: false } }, + }); + + expect(res.success).toBe(true); + if (res.success) { + expect(res.data.richMessages).toBe(true); + expect(res.data.accounts?.ops?.richMessages).toBe(false); + } + }); + it("normalizes custom commands", () => { const res = TelegramConfigSchema.safeParse({ customCommands: [{ command: "/Backup", description: " Git backup " }], diff --git a/extensions/telegram/src/config-ui-hints.ts b/extensions/telegram/src/config-ui-hints.ts index 754cf7ec47df..cf5e8aa9a613 100644 --- a/extensions/telegram/src/config-ui-hints.ts +++ b/extensions/telegram/src/config-ui-hints.ts @@ -62,6 +62,10 @@ export const telegramChannelConfigUiHints = { label: "Telegram Chunk Mode", help: 'Chunking mode for outbound Telegram text delivery: "length" (default) or "newline".', }, + richMessages: { + label: "Telegram Rich Messages", + help: "Opt into Bot API 10.1 rich text sends and edits, including native tables and rich media. Default: false because some current Telegram clients render these messages as unsupported.", + }, "streaming.block.enabled": { label: "Telegram Block Streaming Enabled", help: 'Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode="block".', diff --git a/extensions/telegram/src/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts index eb7ffdf6f31d..d57a8576a423 100644 --- a/extensions/telegram/src/draft-stream.test.ts +++ b/extensions/telegram/src/draft-stream.test.ts @@ -2,7 +2,7 @@ import type { Bot } from "grammy"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTelegramDraftStream } from "./draft-stream.js"; -import { markdownToTelegramRichHtml } from "./format.js"; +import type { TelegramInputRichMessage } from "./rich-message.js"; type TelegramDraftStreamParams = Parameters[0]; @@ -47,50 +47,56 @@ async function expectInitialForumSend( text = "Hello", ): Promise { await vi.waitFor(() => - expect(api.raw.sendRichMessage).toHaveBeenCalledWith({ - chat_id: 123, - rich_message: { html: markdownToTelegramRichHtml(text) }, + expect(api.sendMessage).toHaveBeenCalledWith(123, text, { message_thread_id: 99, }), ); } -function expectRichSend( +function expectPreviewSend( api: ReturnType, text: string, params: Record = {}, ) { - expect(api.raw.sendRichMessage).toHaveBeenCalledWith({ - chat_id: 123, - rich_message: { html: markdownToTelegramRichHtml(text) }, - ...params, - }); + expect(api.sendMessage).toHaveBeenCalledWith(123, text, params); } -function expectNthRichSend( +function expectNthPreviewSend( api: ReturnType, call: number, text: string, params: Record = {}, ) { - expect(api.raw.sendRichMessage).toHaveBeenNthCalledWith(call, { - chat_id: 123, - rich_message: { html: markdownToTelegramRichHtml(text) }, - ...params, - }); + expect(api.sendMessage).toHaveBeenNthCalledWith(call, 123, text, params); } -function expectRichEdit(api: ReturnType, text: string) { - expect(api.raw.editMessageText).toHaveBeenCalledWith({ - chat_id: 123, - message_id: 17, - rich_message: { html: markdownToTelegramRichHtml(text) }, - }); +function requireSendMessageCallText( + api: ReturnType, + callIndex: number, +): string { + const calls = api.sendMessage.mock.calls as unknown[][]; + const call = calls[callIndex]; + expect(call, `sendMessage call ${callIndex}`).toBeDefined(); + const text = call?.[1]; + expect(typeof text).toBe("string"); + return typeof text === "string" ? text : ""; +} + +function expectPreviewEdit( + api: ReturnType, + text: string, + params?: Record, +) { + if (params) { + expect(api.editMessageText).toHaveBeenCalledWith(123, 17, text, params); + return; + } + expect(api.editMessageText).toHaveBeenCalledWith(123, 17, text); } function createForceNewMessageHarness(params: { throttleMs?: number } = {}) { const api = createMockDraftApi(); - api.raw.sendRichMessage + api.sendMessage .mockResolvedValueOnce({ message_id: 17 }) .mockResolvedValueOnce({ message_id: 42 }); const stream = createDraftStream( @@ -115,12 +121,12 @@ describe("createTelegramDraftStream", () => { stream.update("Hello"); await expectInitialForumSend(api); - await (api.raw.sendRichMessage.mock.results[0]?.value as Promise); + await (api.sendMessage.mock.results[0]?.value as Promise); stream.update("Hello again"); await stream.flush(); - expectRichEdit(api, "Hello again"); + expectPreviewEdit(api, "Hello again"); }); it("waits for in-flight updates before final flush edit", async () => { @@ -132,15 +138,15 @@ describe("createTelegramDraftStream", () => { const stream = createForumDraftStream(api); stream.update("Hello"); - await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1)); stream.update("Hello final"); const flushPromise = stream.flush(); - expect(api.raw.editMessageText).not.toHaveBeenCalled(); + expect(api.editMessageText).not.toHaveBeenCalled(); resolveSend?.({ message_id: 17 }); await flushPromise; - expectRichEdit(api, "Hello final"); + expectPreviewEdit(api, "Hello final"); }); it("omits message_thread_id for general topic id", async () => { @@ -149,21 +155,21 @@ describe("createTelegramDraftStream", () => { stream.update("Hello"); - await vi.waitFor(() => expectRichSend(api, "Hello")); + await vi.waitFor(() => expectPreviewSend(api, "Hello")); }); - it("uses rich send/edit for dm thread previews", async () => { + it("uses text send/edit for dm thread previews", async () => { const api = createMockDraftApi(); const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" }); stream.update("Hello"); - await vi.waitFor(() => expectRichSend(api, "Hello", { message_thread_id: 42 })); - expect(api.raw.editMessageText).not.toHaveBeenCalled(); + await vi.waitFor(() => expectPreviewSend(api, "Hello", { message_thread_id: 42 })); + expect(api.editMessageText).not.toHaveBeenCalled(); stream.update("Hello again"); await stream.flush(); - expectRichEdit(api, "Hello again"); + expectPreviewEdit(api, "Hello again"); }); it("tracks when a message preview first became visible", async () => { @@ -192,7 +198,7 @@ describe("createTelegramDraftStream", () => { "does not retry %s message preview sends without the topic id", async (scope) => { const api = createMockDraftApi(); - api.raw.sendRichMessage.mockRejectedValueOnce( + api.sendMessage.mockRejectedValueOnce( new Error("400: Bad Request: message thread not found"), ); const warn = vi.fn(); @@ -204,8 +210,8 @@ describe("createTelegramDraftStream", () => { stream.update("Hello"); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); - expectRichSend(api, "Hello", { message_thread_id: 42 }); + expect(api.sendMessage).toHaveBeenCalledTimes(1); + expectPreviewSend(api, "Hello", { message_thread_id: 42 }); expect(warn).toHaveBeenCalledWith( "telegram stream preview failed: 400: Bad Request: message thread not found", ); @@ -217,7 +223,7 @@ describe("createTelegramDraftStream", () => { it("does not finalize stale preview text after a stopped send failure", async () => { const api = createMockDraftApi(); - api.raw.sendRichMessage.mockRejectedValueOnce(new Error("temporary send failure")); + api.sendMessage.mockRejectedValueOnce(new Error("temporary send failure")); const warn = vi.fn(); const stream = createDraftStream(api, { warn }); @@ -225,8 +231,8 @@ describe("createTelegramDraftStream", () => { await stream.flush(); await stream.stop(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); - expectRichSend(api, "Hello"); + expect(api.sendMessage).toHaveBeenCalledTimes(1); + expectPreviewSend(api, "Hello"); expect(warn).toHaveBeenCalledWith("telegram stream preview failed: temporary send failure"); }); @@ -240,7 +246,7 @@ describe("createTelegramDraftStream", () => { stream.update("Hello"); await stream.flush(); - expectRichSend(api, "Hello", { + expectPreviewSend(api, "Hello", { message_thread_id: 42, reply_parameters: { message_id: 411, @@ -249,13 +255,13 @@ describe("createTelegramDraftStream", () => { }); }); - it("materializes message previews using rendered rich HTML", async () => { + it("materializes message previews using rendered HTML text", async () => { const api = createMockDraftApi(); const stream = createDraftStream(api, { thread: { id: 42, scope: "dm" }, renderText: (text) => ({ text: text.replace("**bold**", "bold"), - richMessage: { html: text.replace("**bold**", "bold") }, + parseMode: "HTML", }), }); @@ -264,12 +270,11 @@ describe("createTelegramDraftStream", () => { const materializedId = await stream.materialize?.(); expect(materializedId).toBe(17); - expect(api.raw.sendRichMessage).toHaveBeenCalledWith({ - chat_id: 123, - rich_message: { html: "bold" }, + expect(api.sendMessage).toHaveBeenCalledWith(123, "bold", { + parse_mode: "HTML", message_thread_id: 42, }); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); + expect(api.raw.sendRichMessage).not.toHaveBeenCalled(); }); it("returns existing preview id when materializing message transport", async () => { @@ -283,7 +288,8 @@ describe("createTelegramDraftStream", () => { const materializedId = await stream.materialize?.(); expect(materializedId).toBe(17); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); + expect(api.sendMessage).toHaveBeenCalledTimes(1); + expect(api.raw.sendRichMessage).not.toHaveBeenCalled(); }); it("deletes message preview on clear after finalization", async () => { @@ -296,8 +302,8 @@ describe("createTelegramDraftStream", () => { await stream.stop(); await stream.clear(); - expectRichSend(api, "Hello", { message_thread_id: 42 }); - expectRichEdit(api, "Hello again"); + expectPreviewSend(api, "Hello", { message_thread_id: 42 }); + expectPreviewEdit(api, "Hello again"); expect(api.deleteMessage).toHaveBeenCalledWith(123, 17); }); @@ -307,12 +313,12 @@ describe("createTelegramDraftStream", () => { // First message stream.update("Hello"); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); + expect(api.sendMessage).toHaveBeenCalledTimes(1); // Normal edit (same message) stream.update("Hello edited"); await stream.flush(); - expectRichEdit(api, "Hello edited"); + expectPreviewEdit(api, "Hello edited"); // Force new message (e.g. after thinking block ends) stream.forceNewMessage(); @@ -320,8 +326,8 @@ describe("createTelegramDraftStream", () => { await stream.flush(); // Should have sent a second new message, not edited the first - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2); - expectNthRichSend(api, 2, "After thinking"); + expect(api.sendMessage).toHaveBeenCalledTimes(2); + expectNthPreviewSend(api, 2, "After thinking"); }); it("creates new message after cleanup and forceNewMessage", async () => { @@ -337,8 +343,8 @@ describe("createTelegramDraftStream", () => { stream.update("Next preview"); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2); - expectNthRichSend(api, 2, "Next preview"); + expect(api.sendMessage).toHaveBeenCalledTimes(2); + expectNthPreviewSend(api, 2, "Next preview"); }); it("sends first update immediately after forceNewMessage within throttle window", async () => { @@ -347,15 +353,15 @@ describe("createTelegramDraftStream", () => { const { api, stream } = createForceNewMessageHarness({ throttleMs: 1000 }); stream.update("Hello"); - await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1)); stream.update("Hello edited"); - expect(api.raw.editMessageText).not.toHaveBeenCalled(); + expect(api.editMessageText).not.toHaveBeenCalled(); stream.forceNewMessage(); stream.update("Second message"); - await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2)); - expectNthRichSend(api, 2, "Second message"); + await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(2)); + expectNthPreviewSend(api, 2, "Second message"); } finally { vi.useRealTimers(); } @@ -367,14 +373,12 @@ describe("createTelegramDraftStream", () => { resolveFirstSend = resolve; }); const api = createMockDraftApi(); - api.raw.sendRichMessage - .mockReturnValueOnce(firstSend) - .mockResolvedValueOnce({ message_id: 42 }); + api.sendMessage.mockReturnValueOnce(firstSend).mockResolvedValueOnce({ message_id: 42 }); const onSupersededPreview = vi.fn(); const stream = createDraftStream(api, { onSupersededPreview }); stream.update("Message A partial"); - await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1)); stream.forceNewMessage(); stream.update("Message B partial"); @@ -392,44 +396,38 @@ describe("createTelegramDraftStream", () => { }); expect(typeof supersededPreview.visibleSinceMs).toBe("number"); expect(Number.isFinite(supersededPreview.visibleSinceMs)).toBe(true); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2); - expectNthRichSend(api, 2, "Message B partial"); - expect(api.raw.editMessageText).not.toHaveBeenCalledWith({ - chat_id: 123, - message_id: 17, - rich_message: { html: markdownToTelegramRichHtml("Message B partial") }, - }); + expect(api.sendMessage).toHaveBeenCalledTimes(2); + expectNthPreviewSend(api, 2, "Message B partial"); + expect(api.editMessageText).not.toHaveBeenCalledWith(123, 17, "Message B partial"); }); it("marks sendMayHaveLanded after an ambiguous first preview send failure", async () => { const api = createMockDraftApi(); - api.raw.sendRichMessage.mockRejectedValueOnce( - new Error("timeout after Telegram accepted send"), - ); + api.sendMessage.mockRejectedValueOnce(new Error("timeout after Telegram accepted send")); const stream = createDraftStream(api); stream.update("Hello"); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); + expect(api.sendMessage).toHaveBeenCalledTimes(1); expect(stream.sendMayHaveLanded?.()).toBe(true); }); async function expectSendMayHaveLandedStateAfterFirstFailure(error: Error, expected: boolean) { const api = createMockDraftApi(); - api.raw.sendRichMessage.mockRejectedValueOnce(error); + api.sendMessage.mockRejectedValueOnce(error); const stream = createDraftStream(api); stream.update("Hello"); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); + expect(api.sendMessage).toHaveBeenCalledTimes(1); expect(stream.sendMayHaveLanded?.()).toBe(expected); } it("retries pre-connect first preview send failures instead of stopping", async () => { const api = createMockDraftApi(); - api.raw.sendRichMessage.mockRejectedValueOnce( + api.sendMessage.mockRejectedValueOnce( Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }), ); const stream = createDraftStream(api); @@ -438,7 +436,7 @@ describe("createTelegramDraftStream", () => { await stream.flush(); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2); + expect(api.sendMessage).toHaveBeenCalledTimes(2); expect(stream.sendMayHaveLanded?.()).toBe(false); expect(stream.messageId()).toBe(17); }); @@ -452,7 +450,7 @@ describe("createTelegramDraftStream", () => { it("treats message-is-not-modified edits as delivered", async () => { const api = createMockDraftApi(); - api.raw.editMessageText.mockRejectedValueOnce( + api.editMessageText.mockRejectedValueOnce( Object.assign( new Error("Call to 'editMessageText' failed! (400: Bad Request: message is not modified)"), { error_code: 400 }, @@ -468,18 +466,14 @@ describe("createTelegramDraftStream", () => { stream.update("Hello more"); await stream.flush(); - expect(api.raw.editMessageText).toHaveBeenCalledTimes(2); - expect(api.raw.editMessageText).toHaveBeenLastCalledWith({ - chat_id: 123, - message_id: 17, - rich_message: { html: markdownToTelegramRichHtml("Hello more") }, - }); + expect(api.editMessageText).toHaveBeenCalledTimes(2); + expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello more"); expect(warn).not.toHaveBeenCalled(); }); it("retries the preview edit after a transient network failure", async () => { const api = createMockDraftApi(); - api.raw.editMessageText.mockRejectedValueOnce( + api.editMessageText.mockRejectedValueOnce( Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }), ); const warn = vi.fn(); @@ -495,12 +489,8 @@ describe("createTelegramDraftStream", () => { await stream.flush(); - expect(api.raw.editMessageText).toHaveBeenCalledTimes(2); - expect(api.raw.editMessageText).toHaveBeenLastCalledWith({ - chat_id: 123, - message_id: 17, - rich_message: { html: markdownToTelegramRichHtml("Hello again") }, - }); + expect(api.editMessageText).toHaveBeenCalledTimes(2); + expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello again"); expect(stream.lastDeliveredText?.()).toBe("Hello again"); }); @@ -508,7 +498,7 @@ describe("createTelegramDraftStream", () => { vi.useFakeTimers(); try { const api = createMockDraftApi(); - api.raw.editMessageText.mockRejectedValueOnce( + api.editMessageText.mockRejectedValueOnce( Object.assign( new Error("Call to 'editMessageText' failed! (429: Too Many Requests: retry after 1)"), { error_code: 429, parameters: { retry_after: 1 } }, @@ -522,17 +512,13 @@ describe("createTelegramDraftStream", () => { await stream.flush(); stream.update("Hello more"); await stream.flush(); - expect(api.raw.editMessageText).toHaveBeenCalledTimes(1); + expect(api.editMessageText).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1100); await stream.flush(); - expect(api.raw.editMessageText).toHaveBeenCalledTimes(2); - expect(api.raw.editMessageText).toHaveBeenLastCalledWith({ - chat_id: 123, - message_id: 17, - rich_message: { html: markdownToTelegramRichHtml("Hello more") }, - }); + expect(api.editMessageText).toHaveBeenCalledTimes(2); + expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello more"); } finally { vi.useRealTimers(); } @@ -540,7 +526,7 @@ describe("createTelegramDraftStream", () => { it("stops the preview after repeated retryable edit failures", async () => { const api = createMockDraftApi(); - api.raw.editMessageText.mockRejectedValue( + api.editMessageText.mockRejectedValue( Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }), ); const warn = vi.fn(); @@ -555,35 +541,32 @@ describe("createTelegramDraftStream", () => { await stream.flush(); await stream.flush(); - expect(api.raw.editMessageText).toHaveBeenCalledTimes(4); + expect(api.editMessageText).toHaveBeenCalledTimes(4); expect(warn).toHaveBeenCalledWith("telegram stream preview failed: read ECONNRESET"); }); - it("supports rendered previews with rich HTML", async () => { + it("supports rendered previews with HTML parse mode", async () => { const api = createMockDraftApi(); const stream = createTelegramDraftStream({ api: api as unknown as Bot["api"], chatId: 123, - renderText: (text) => ({ text: `${text}`, richMessage: { html: `${text}` } }), + renderText: (text) => ({ text: `${text}`, parseMode: "HTML" }), }); stream.update("hello"); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledWith({ - chat_id: 123, - rich_message: { html: "hello" }, + expect(api.sendMessage).toHaveBeenCalledWith(123, "hello", { + parse_mode: "HTML", }); stream.update("hello again"); await stream.flush(); - expect(api.raw.editMessageText).toHaveBeenCalledWith({ - chat_id: 123, - message_id: 17, - rich_message: { html: "hello again" }, + expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "hello again", { + parse_mode: "HTML", }); }); - it("uses caller-provided rich previews", async () => { + it("sends caller-provided rich previews through standard text transport", async () => { const api = createMockDraftApi(); const stream = createDraftStream(api); @@ -596,13 +579,10 @@ describe("createTelegramDraftStream", () => { }); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledWith({ - chat_id: 123, - rich_message: { - html: "Shelling
🛠️ Exec", - skip_entity_detection: true, - }, + expect(api.sendMessage).toHaveBeenCalledWith(123, "Shelling
🛠️ Exec", { + parse_mode: "HTML", }); + expect(api.raw.sendRichMessage).not.toHaveBeenCalled(); stream.updatePreview({ text: "Shelling\n\n`🛠️ Exec`\n• _Checking files_", @@ -613,43 +593,76 @@ describe("createTelegramDraftStream", () => { }); await stream.flush(); + expect(api.editMessageText).toHaveBeenCalledWith( + 123, + 17, + "Shelling
🛠️ Exec
Checking files", + { parse_mode: "HTML" }, + ); + expect(api.raw.editMessageText).not.toHaveBeenCalled(); + }); + + it("uses rich send and edit for previews when explicitly enabled", async () => { + const api = createMockDraftApi(); + const stream = createDraftStream(api, { richMessages: true }); + + stream.updatePreview({ + text: "Plan", + richMessage: { html: "

Plan

A
" }, + }); + await stream.flush(); + + expect(api.raw.sendRichMessage).toHaveBeenCalledWith({ + chat_id: 123, + rich_message: { html: "

Plan

A
" }, + }); + expect(api.sendMessage).not.toHaveBeenCalled(); + + stream.updatePreview({ + text: "Plan updated", + richMessage: { html: "

Plan updated

B
" }, + }); + await stream.flush(); + expect(api.raw.editMessageText).toHaveBeenCalledWith({ chat_id: 123, message_id: 17, - rich_message: { - html: "Shelling
🛠️ Exec
Checking files", - skip_entity_detection: true, - }, + rich_message: { html: "

Plan updated

B
" }, }); + expect(api.editMessageText).not.toHaveBeenCalled(); }); - it("keeps rich rendered previews above the old text-message limit", async () => { - const richApi = { - sendRichMessage: vi.fn(async () => ({ message_id: 17 })), - editMessageText: vi.fn(async () => true), - }; - const api = { - ...createMockDraftApi(), - raw: richApi, - }; + it("clamps rich previews to the block limit", async () => { + const api = createMockDraftApi(); + const text = Array.from({ length: 501 }, (_, index) => `paragraph ${index}`).join("\n\n"); + const stream = createDraftStream(api, { richMessages: true }); + + stream.update(text); + await stream.flush(); + + const calls = api.raw.sendRichMessage.mock.calls as unknown[][]; + const params = calls[0]?.[0] as { rich_message?: TelegramInputRichMessage } | undefined; + const richMessage = params?.rich_message; + expect(richMessage?.html).toContain("paragraph 499"); + expect(richMessage?.html).not.toContain("paragraph 500"); + }); + + it("clamps rendered previews to the text-message limit", async () => { + const api = createMockDraftApi(); const text = `# Long\n\n${"rich line\n".repeat(600)}`; const stream = createTelegramDraftStream({ api: api as unknown as Bot["api"], chatId: 123, - renderText: (value) => ({ - text: value, - richMessage: { html: markdownToTelegramRichHtml(value) }, - }), + renderText: (value) => ({ text: value }), }); stream.update(text); await stream.flush(); - expect(richApi.sendRichMessage).toHaveBeenCalledWith({ - chat_id: 123, - rich_message: { html: markdownToTelegramRichHtml(text.trimEnd()) }, - }); - expect(api.sendMessage).not.toHaveBeenCalled(); + expect(api.sendMessage).toHaveBeenCalledTimes(1); + const sentText = requireSendMessageCallText(api, 0); + expect(sentText.length).toBeLessThanOrEqual(4000); + expect(sentText.startsWith("# Long\n\nrich line")).toBe(true); }); it("keeps non-final overflow in one editable preview", async () => { @@ -662,9 +675,9 @@ describe("createTelegramDraftStream", () => { stream.update("Hello world foo bar baz qux"); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); - expectNthRichSend(api, 1, "Hello world"); - expectRichEdit(api, "Hello world foo bar"); + expect(api.sendMessage).toHaveBeenCalledTimes(1); + expectNthPreviewSend(api, 1, "Hello world"); + expectPreviewEdit(api, "Hello world foo bar"); expect(onSupersededPreview).not.toHaveBeenCalled(); expect(stream.lastDeliveredText?.()).toBe("Hello world foo bar"); }); @@ -682,14 +695,14 @@ describe("createTelegramDraftStream", () => { stream.update("Hello world foo bar baz qux"); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); - expectRichEdit(api, "Hello world foo bar"); + expect(api.sendMessage).toHaveBeenCalledTimes(1); + expectPreviewEdit(api, "Hello world foo bar"); expect(onSupersededPreview).not.toHaveBeenCalled(); }); it("continues in a new message when a final rendered preview crosses maxChars", async () => { const api = createMockDraftApi(); - api.raw.sendRichMessage + api.sendMessage .mockResolvedValueOnce({ message_id: 17 }) .mockResolvedValueOnce({ message_id: 42 }); const stream = createDraftStream(api, { maxChars: 20 }); @@ -699,9 +712,9 @@ describe("createTelegramDraftStream", () => { stream.update("Hello world foo bar baz qux"); await stream.stop(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2); - expectNthRichSend(api, 1, "Hello world"); - expectNthRichSend(api, 2, "foo bar baz qux"); + expect(api.sendMessage).toHaveBeenCalledTimes(2); + expectNthPreviewSend(api, 1, "Hello world"); + expectNthPreviewSend(api, 2, "foo bar baz qux"); }); it("clamps a first oversized non-final preview", async () => { @@ -711,14 +724,14 @@ describe("createTelegramDraftStream", () => { stream.update("1234567890ABCDEFGHIJ"); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); - expectNthRichSend(api, 1, "1234567890"); + expect(api.sendMessage).toHaveBeenCalledTimes(1); + expectNthPreviewSend(api, 1, "1234567890"); expect(stream.lastDeliveredText?.()).toBe("1234567890"); }); it("finalizes overflow that was hidden by a clamped non-final preview", async () => { const api = createMockDraftApi(); - api.raw.sendRichMessage + api.sendMessage .mockResolvedValueOnce({ message_id: 17 }) .mockResolvedValueOnce({ message_id: 42 }); const onSupersededPreview = vi.fn(); @@ -731,9 +744,9 @@ describe("createTelegramDraftStream", () => { await stream.flush(); await stream.stop(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2); - expectNthRichSend(api, 1, "1234567890"); - expectNthRichSend(api, 2, "ABCDEFGHIJ"); + expect(api.sendMessage).toHaveBeenCalledTimes(2); + expectNthPreviewSend(api, 1, "1234567890"); + expectNthPreviewSend(api, 2, "ABCDEFGHIJ"); expect(stream.lastDeliveredText?.()).toBe("1234567890ABCDEFGHIJ"); expect(onSupersededPreview).toHaveBeenCalledWith( expect.objectContaining({ @@ -745,7 +758,7 @@ describe("createTelegramDraftStream", () => { it("continues finalizing more than two overflow chunks after a clamped preview", async () => { const api = createMockDraftApi(); - api.raw.sendRichMessage + api.sendMessage .mockResolvedValueOnce({ message_id: 17 }) .mockResolvedValueOnce({ message_id: 42 }) .mockResolvedValueOnce({ message_id: 43 }); @@ -755,16 +768,16 @@ describe("createTelegramDraftStream", () => { await stream.flush(); await stream.stop(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(3); - expectNthRichSend(api, 1, "1234567890"); - expectNthRichSend(api, 2, "ABCDEFGHIJ"); - expectNthRichSend(api, 3, "KLMNOPQRST"); + expect(api.sendMessage).toHaveBeenCalledTimes(3); + expectNthPreviewSend(api, 1, "1234567890"); + expectNthPreviewSend(api, 2, "ABCDEFGHIJ"); + expectNthPreviewSend(api, 3, "KLMNOPQRST"); expect(stream.lastDeliveredText?.()).toBe("1234567890ABCDEFGHIJKLMNOPQRST"); }); it("retains final overflow preview pages", async () => { const api = createMockDraftApi(); - api.raw.sendRichMessage + api.sendMessage .mockResolvedValueOnce({ message_id: 17 }) .mockResolvedValueOnce({ message_id: 42 }); const onSupersededPreview = vi.fn(); @@ -798,8 +811,8 @@ describe("createTelegramDraftStream", () => { chatId: 123, maxChars: 100, renderText: () => ({ - text: "short raw text", - richMessage: { html: `${"<".repeat(120)}` }, + text: `${"<".repeat(120)}`, + parseMode: "HTML", }), warn, }); @@ -807,8 +820,8 @@ describe("createTelegramDraftStream", () => { stream.update("short raw text"); await stream.flush(); - expect(api.raw.sendRichMessage).not.toHaveBeenCalled(); - expect(api.raw.editMessageText).not.toHaveBeenCalled(); + expect(api.sendMessage).not.toHaveBeenCalled(); + expect(api.editMessageText).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalledWith("telegram stream preview stopped (text length 127 > 100)"); }); }); @@ -841,7 +854,7 @@ describe("draft stream initial message debounce", () => { await stream.stop(); await stream.flush(); - expectRichSend(api, "Y"); + expectPreviewSend(api, "Y"); }); it("sends immediately on stop() with short sentence", async () => { @@ -852,7 +865,7 @@ describe("draft stream initial message debounce", () => { await stream.stop(); await stream.flush(); - expectRichSend(api, "Ok."); + expectPreviewSend(api, "Ok."); }); }); @@ -864,7 +877,7 @@ describe("draft stream initial message debounce", () => { stream.update("Processing"); await stream.flush(); - expect(api.raw.sendRichMessage).not.toHaveBeenCalled(); + expect(api.sendMessage).not.toHaveBeenCalled(); }); it("does not send a first message when discard() supersedes a short partial", async () => { @@ -875,8 +888,8 @@ describe("draft stream initial message debounce", () => { await stream.discard?.(); await stream.flush(); - expect(api.raw.sendRichMessage).not.toHaveBeenCalled(); - expect(api.raw.editMessageText).not.toHaveBeenCalled(); + expect(api.sendMessage).not.toHaveBeenCalled(); + expect(api.editMessageText).not.toHaveBeenCalled(); }); it("sends first message when reaching threshold", async () => { @@ -886,7 +899,7 @@ describe("draft stream initial message debounce", () => { stream.update("I am processing your request.."); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalled(); + expect(api.sendMessage).toHaveBeenCalled(); }); it("works with longer text above threshold", async () => { @@ -896,7 +909,7 @@ describe("draft stream initial message debounce", () => { stream.update("I am processing your request, please wait a moment"); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalled(); + expect(api.sendMessage).toHaveBeenCalled(); }); }); @@ -907,18 +920,18 @@ describe("draft stream initial message debounce", () => { stream.update("I am processing your request.."); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); + expect(api.sendMessage).toHaveBeenCalledTimes(1); stream.update("I am processing your request.. and summarizing"); await stream.flush(); - expect(api.raw.editMessageText).toHaveBeenCalled(); - expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1); + expect(api.editMessageText).toHaveBeenCalled(); + expect(api.sendMessage).toHaveBeenCalledTimes(1); }); }); describe("default behavior without debounce params", () => { - it("sends rich markdown immediately without minInitialChars set", async () => { + it("sends plain preview text immediately without minInitialChars set", async () => { const api = createMockApi(); const stream = createTelegramDraftStream({ api: api as unknown as Bot["api"], @@ -928,7 +941,7 @@ describe("draft stream initial message debounce", () => { stream.update("Hi"); await stream.flush(); - expectRichSend(api, "Hi"); + expectPreviewSend(api, "Hi"); }); }); }); diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index 110c83dd61aa..4da41af7c358 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -6,6 +6,7 @@ import { } from "openclaw/plugin-sdk/channel-outbound"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js"; +import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js"; import { isRecoverableTelegramNetworkError, isSafeToRetrySendError, @@ -14,17 +15,20 @@ import { isTelegramRateLimitError, readTelegramRetryAfterMs, } from "./network-errors.js"; +import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; import { normalizeTelegramReplyToMessageId } from "./outbound-params.js"; import { buildTelegramRichMarkdown, - TELEGRAM_RICH_TEXT_LIMIT, getTelegramRichRawApi, + isTelegramRichMessageWithinStructuralLimits, + TELEGRAM_RICH_TEXT_LIMIT, type TelegramInputRichMessage, type TelegramSendRichMessageParams, } from "./rich-message.js"; -const TELEGRAM_STREAM_MAX_CHARS = TELEGRAM_RICH_TEXT_LIMIT; +const TELEGRAM_STREAM_MAX_CHARS = TELEGRAM_TEXT_CHUNK_LIMIT; const DEFAULT_THROTTLE_MS = 1000; +const TELEGRAM_PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; // Retryable preview failures keep the latest text pending for the next throttle // tick; cap consecutive misses so a persistent outage stops the preview instead // of warn-spamming for the rest of the run. @@ -55,7 +59,8 @@ export type TelegramDraftStream = { export type TelegramDraftPreview = { text: string; - richMessage: TelegramInputRichMessage; + parseMode?: "HTML"; + richMessage?: TelegramInputRichMessage; }; type SupersededTelegramPreview = { @@ -65,29 +70,76 @@ type SupersededTelegramPreview = { retain?: boolean; }; +type TelegramDraftTransportPreview = { + plainText: string; + text: string; + parseMode?: "HTML"; +}; + function renderTelegramDraftPreview( text: string, renderText: ((text: string) => TelegramDraftPreview) | undefined, ): TelegramDraftPreview { const trimmed = text.trimEnd(); - return ( - renderText?.(trimmed) ?? { text: trimmed, richMessage: buildTelegramRichMarkdown(trimmed) } - ); + return renderText?.(trimmed) ?? { text: trimmed }; +} + +function isTelegramHtmlParseError(err: unknown): boolean { + return TELEGRAM_PARSE_ERR_RE.test(formatErrorMessage(err)); +} + +function normalizeTelegramDraftTransportPreview( + preview: TelegramDraftPreview, +): TelegramDraftTransportPreview { + if (preview.richMessage?.html) { + return { + text: preview.richMessage.html, + parseMode: "HTML", + plainText: preview.text, + }; + } + if (preview.richMessage?.markdown) { + return { + text: renderTelegramHtmlText(preview.richMessage.markdown), + parseMode: "HTML", + plainText: preview.text, + }; + } + if (preview.parseMode === "HTML") { + return { + text: preview.text, + parseMode: "HTML", + plainText: telegramHtmlToPlainTextFallback(preview.text), + }; + } + return { + text: preview.text, + plainText: preview.text, + }; } function telegramDraftPreviewKey(preview: TelegramDraftPreview): string { - return JSON.stringify(preview.richMessage); + return JSON.stringify({ + text: preview.text, + parseMode: preview.parseMode ?? "plain", + richMessage: preview.richMessage, + }); } -function telegramDraftPreviewPayloadLength(preview: TelegramDraftPreview): number { - const richMessage = preview.richMessage; - return richMessage.html !== undefined ? richMessage.html.length : richMessage.markdown.length; +function telegramDraftRichPayloadLength(preview: TelegramDraftPreview): number { + const sourceMessage = preview.richMessage ?? { markdown: preview.text }; + if (!isTelegramRichMessageWithinStructuralLimits(sourceMessage)) { + return TELEGRAM_RICH_TEXT_LIMIT + 1; + } + const richMessage = preview.richMessage ?? buildTelegramRichMarkdown(preview.text); + return richMessage.html?.length ?? richMessage.markdown?.length ?? 0; } function findTelegramDraftChunkLength( text: string, maxChars: number, renderText: ((text: string) => TelegramDraftPreview) | undefined, + richMessages: boolean, ): number { let best = 0; let low = 1; @@ -95,7 +147,11 @@ function findTelegramDraftChunkLength( while (low <= high) { const mid = Math.floor((low + high) / 2); const preview = renderTelegramDraftPreview(text.slice(0, mid), renderText); - if (preview.text.trimEnd() && telegramDraftPreviewPayloadLength(preview) <= maxChars) { + const renderedText = normalizeTelegramDraftTransportPreview(preview).text.trimEnd(); + const payloadLength = richMessages + ? telegramDraftRichPayloadLength(preview) + : renderedText.length; + if (renderedText && payloadLength <= maxChars) { best = mid; low = mid + 1; } else { @@ -111,6 +167,7 @@ export function createTelegramDraftStream(params: { maxChars?: number; thread?: TelegramThreadSpec | null; replyToMessageId?: number; + richMessages?: boolean; throttleMs?: number; /** Minimum chars before sending first message (debounce for push notifications) */ minInitialChars?: number; @@ -121,16 +178,25 @@ export function createTelegramDraftStream(params: { log?: (message: string) => void; warn?: (message: string) => void; }): TelegramDraftStream { - const maxChars = Math.min( - params.maxChars ?? TELEGRAM_STREAM_MAX_CHARS, - TELEGRAM_STREAM_MAX_CHARS, - ); + const richMessages = params.richMessages === true; + const transportLimit = richMessages ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_STREAM_MAX_CHARS; + const maxChars = Math.min(params.maxChars ?? transportLimit, transportLimit); const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS); const minInitialChars = params.minInitialChars; const chatId = params.chatId; const threadParams = buildTelegramThreadParams(params.thread); const replyToMessageId = normalizeTelegramReplyToMessageId(params.replyToMessageId); - const richReplyParams: Omit = + const sendMessageParams = + replyToMessageId != null + ? { + ...threadParams, + reply_parameters: { + message_id: replyToMessageId, + allow_sending_without_reply: true, + }, + } + : (threadParams ?? {}); + const richMessageParams: Omit = replyToMessageId != null ? { ...threadParams, @@ -159,25 +225,60 @@ export function createTelegramDraftStream(params: { sendGeneration: number; }; const sendRenderedMessage = async (preview: TelegramDraftPreview) => { - const richRawApi = getTelegramRichRawApi(params.api); - return await richRawApi.sendRichMessage({ - chat_id: chatId, - rich_message: preview.richMessage, - ...richReplyParams, - }); + if (richMessages) { + return await getTelegramRichRawApi(params.api).sendRichMessage({ + chat_id: chatId, + rich_message: preview.richMessage ?? buildTelegramRichMarkdown(preview.text), + ...richMessageParams, + }); + } + const transportPreview = normalizeTelegramDraftTransportPreview(preview); + const sendPlain = async () => + await params.api.sendMessage(chatId, transportPreview.plainText, sendMessageParams); + if (transportPreview.parseMode !== "HTML") { + return await sendPlain(); + } + try { + return await params.api.sendMessage(chatId, transportPreview.text, { + parse_mode: "HTML" as const, + ...sendMessageParams, + }); + } catch (err) { + if (!isTelegramHtmlParseError(err)) { + throw err; + } + return await sendPlain(); + } }; const sendMessageTransportPreview = async ({ preview, sendGeneration, }: PreviewSendParams): Promise => { + const transportPreview = normalizeTelegramDraftTransportPreview(preview); if (typeof streamMessageId === "number") { streamVisibleSinceMs ??= Date.now(); - const richRawApi = getTelegramRichRawApi(params.api); - await richRawApi.editMessageText({ - chat_id: chatId, - message_id: streamMessageId, - rich_message: preview.richMessage, - }); + if (richMessages) { + await getTelegramRichRawApi(params.api).editMessageText({ + chat_id: chatId, + message_id: streamMessageId, + rich_message: preview.richMessage ?? buildTelegramRichMarkdown(preview.text), + }); + return true; + } + if (transportPreview.parseMode === "HTML") { + try { + await params.api.editMessageText(chatId, streamMessageId, transportPreview.text, { + parse_mode: "HTML" as const, + }); + } catch (err) { + if (!isTelegramHtmlParseError(err)) { + throw err; + } + await params.api.editMessageText(chatId, streamMessageId, transportPreview.plainText); + } + } else { + await params.api.editMessageText(chatId, streamMessageId, transportPreview.text); + } return true; } messageSendAttempted = true; @@ -239,15 +340,23 @@ export function createTelegramDraftStream(params: { deliveredTextOffset === 0 && lastRequestedPreview?.text === trimmed ? lastRequestedPreview : renderTelegramDraftPreview(currentText, params.renderText); - const renderedText = rendered.text.trimEnd(); + const transportPreview = normalizeTelegramDraftTransportPreview(rendered); + const renderedText = transportPreview.text.trimEnd(); + const renderedPayloadLength = richMessages + ? telegramDraftRichPayloadLength(rendered) + : renderedText.length; const renderedPreview = { ...rendered, text: renderedText }; const renderedPreviewKey = telegramDraftPreviewKey(renderedPreview); - const renderedPayloadLength = telegramDraftPreviewPayloadLength(renderedPreview); if (!renderedText) { return false; } if (renderedPayloadLength > maxChars) { - const chunkLength = findTelegramDraftChunkLength(currentText, maxChars, params.renderText); + const chunkLength = findTelegramDraftChunkLength( + currentText, + maxChars, + params.renderText, + richMessages, + ); if (!streamState.final) { if (chunkLength > 0) { return await sendOrEditStreamMessage( diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index a0731c43804f..715181efbf05 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -226,6 +226,35 @@ const TELEGRAM_ATTR_HTML_TAG_PATTERNS = new Map([ const TELEGRAM_CODE_LANGUAGE_ATTR_PATTERN = /^\s+class="language-[^"]+"\s*$/; const TELEGRAM_RICH_TEXT_TABLE_COLUMN_LIMIT = 20; const TELEGRAM_VOID_HTML_TAGS = new Set(["br", "hr", "img", "input", "tg-map"]); +const TELEGRAM_RICH_BLOCK_HTML_TAGS = new Set([ + "aside", + "audio", + "blockquote", + "details", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "img", + "li", + "ol", + "p", + "pre", + "table", + "tg-collage", + "tg-map", + "tg-math-block", + "tg-slideshow", + "tr", + "ul", + "video", +]); +const TELEGRAM_RICH_MEDIA_HTML_TAGS = new Set(["audio", "img", "video"]); const TELEGRAM_RICH_SIMPLE_HTML_TAGS = new Set([ ...TELEGRAM_SIMPLE_HTML_TAGS, "a", @@ -689,6 +718,49 @@ export function sanitizeTelegramRichHtml(html: string): string { ); } +export function limitTelegramRichHtmlNesting(html: string, maxDepth: number): string { + const normalizedMaxDepth = Math.max(1, Math.floor(maxDepth)); + const stack: Array<{ name: string; kept: boolean }> = []; + let keptDepth = 0; + let output = ""; + let lastIndex = 0; + + HTML_TAG_PATTERN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = HTML_TAG_PATTERN.exec(html)) !== null) { + output += html.slice(lastIndex, match.index); + const rawTag = match[0]; + const isClosing = match[1] === "")); + + if (isClosing) { + const entryIndex = stack.findLastIndex((entry) => entry.name === tagName); + if (entryIndex >= 0) { + const [entry] = stack.splice(entryIndex, 1); + if (entry?.kept) { + keptDepth = Math.max(0, keptDepth - 1); + output += rawTag; + } + } + } else if (isSelfClosing) { + if (tagName === "br" || keptDepth < normalizedMaxDepth) { + output += rawTag; + } + } else { + const kept = keptDepth < normalizedMaxDepth; + stack.push({ name: tagName, kept }); + if (kept) { + keptDepth += 1; + output += rawTag; + } + } + lastIndex = HTML_TAG_PATTERN.lastIndex; + } + return output + html.slice(lastIndex); +} + function normalizeTelegramRichMediaBlock(block: string): string { const normalized = block .trim() @@ -925,6 +997,8 @@ type TelegramHtmlTag = { name: string; openTag: string; closeTag: string; + richBlock: boolean; + richMedia: boolean; }; const TELEGRAM_SELF_CLOSING_HTML_TAGS = TELEGRAM_VOID_HTML_TAGS; @@ -945,6 +1019,13 @@ function buildTelegramHtmlCloseSuffixLength(tags: TelegramHtmlTag[]): number { return tags.reduce((total, tag) => total + tag.closeTag.length, 0); } +function isTelegramRichBlockHtmlTag(rawTag: string, tagName: string): boolean { + return ( + TELEGRAM_RICH_BLOCK_HTML_TAGS.has(tagName) || + (tagName === "a" && /\sname="[^"]+"/i.test(rawTag)) + ); +} + function findTelegramHtmlEntityEnd(text: string, start: number): number { if (text[start] !== "&") { return -1; @@ -1018,22 +1099,34 @@ function popTelegramHtmlTag(tags: TelegramHtmlTag[], name: string): void { } } -export function splitTelegramHtmlChunks(html: string, limit: number): string[] { +export function splitTelegramHtmlChunks( + html: string, + limit: number, + options: { blockLimit?: number; mediaLimit?: number } = {}, +): string[] { if (!html) { return []; } const normalizedLimit = Math.max(1, Math.floor(limit)); - if (html.length <= normalizedLimit) { + const blockLimit = + options.blockLimit == null ? undefined : Math.max(1, Math.floor(options.blockLimit)); + const mediaLimit = + options.mediaLimit == null ? undefined : Math.max(1, Math.floor(options.mediaLimit)); + if (html.length <= normalizedLimit && blockLimit === undefined && mediaLimit === undefined) { return [html]; } const chunks: string[] = []; const openTags: TelegramHtmlTag[] = []; let current = ""; + let currentBlockCount = 0; + let currentMediaCount = 0; let chunkHasPayload = false; const resetCurrent = () => { current = buildTelegramHtmlOpenPrefix(openTags); + currentBlockCount = openTags.filter((tag) => tag.richBlock).length; + currentMediaCount = openTags.filter((tag) => tag.richMedia).length; chunkHasPayload = false; }; @@ -1096,16 +1189,24 @@ export function splitTelegramHtmlChunks(html: string, limit: number): string[] { const isSelfClosing = !isClosing && (TELEGRAM_SELF_CLOSING_HTML_TAGS.has(tagName) || rawTag.trimEnd().endsWith("/>")); + const isRichBlock = !isClosing && isTelegramRichBlockHtmlTag(rawTag, tagName); + const isRichMedia = + !isClosing && + (tagName === "figure" || + (TELEGRAM_RICH_MEDIA_HTML_TAGS.has(tagName) && + !openTags.some((tag) => tag.name === "figure"))); if (!isClosing) { const nextCloseLength = isSelfClosing ? 0 : ``.length; if ( chunkHasPayload && - current.length + - rawTag.length + - buildTelegramHtmlCloseSuffixLength(openTags) + - nextCloseLength > - normalizedLimit + ((blockLimit !== undefined && isRichBlock && currentBlockCount >= blockLimit) || + (mediaLimit !== undefined && isRichMedia && currentMediaCount >= mediaLimit) || + current.length + + rawTag.length + + buildTelegramHtmlCloseSuffixLength(openTags) + + nextCloseLength > + normalizedLimit) ) { flushCurrent(); } @@ -1115,6 +1216,12 @@ export function splitTelegramHtmlChunks(html: string, limit: number): string[] { if (isSelfClosing) { chunkHasPayload = true; } + if (isRichBlock) { + currentBlockCount += 1; + } + if (isRichMedia) { + currentMediaCount += 1; + } if (isClosing) { popTelegramHtmlTag(openTags, tagName); } else if (!isSelfClosing) { @@ -1122,6 +1229,8 @@ export function splitTelegramHtmlChunks(html: string, limit: number): string[] { name: tagName, openTag: rawTag, closeTag: ``, + richBlock: isRichBlock, + richMedia: isRichMedia, }); } lastIndex = tagEnd; diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index 15090c3aaacd..7758fd29f57e 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -505,11 +505,12 @@ describe("telegramOutbound", () => { cfg: {} as never, to: "12345", text: "hello", - formatting: { parseMode: "HTML" }, + formatting: { parseMode: "HTML", tableMode: "bullets" }, deps: { sendTelegram: sendMessageTelegramMock }, }); const options = lastCallOptions(sendMessageTelegramMock, "12345", "hello"); expect(options.textMode).toBe("html"); + expect(options.tableMode).toBe("bullets"); }; const proveMedia = async () => { sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-media", chatId: "12345" }); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 9b94e184505d..5949a678c677 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -13,6 +13,7 @@ import { normalizeMessagePresentation, renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime"; +import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking"; import { resolvePayloadMediaUrls, sendPayloadMediaSequenceOrFallback, @@ -20,12 +21,12 @@ import { import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; +import { splitTelegramHtmlChunks } from "./format.js"; import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; -import { splitTelegramRichTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js"; import { normalizeTelegramOutboundTarget, parseTelegramTarget } from "./targets.js"; -export const TELEGRAM_TEXT_CHUNK_LIMIT = TELEGRAM_RICH_TEXT_LIMIT; +export const TELEGRAM_TEXT_CHUNK_LIMIT = 4000; export const TELEGRAM_POLL_OPTION_LIMIT = 10; type TelegramSendFn = typeof import("./send.js").sendMessageTelegram; @@ -53,12 +54,9 @@ function chunkTelegramOutboundText( limit: number, ctx?: { formatting?: OutboundDeliveryFormattingOptions }, ): string[] { - return splitTelegramRichTextChunks({ - text, - textLimit: limit, - textMode: ctx?.formatting?.parseMode === "HTML" ? "html" : "markdown", - chunkMode: ctx?.formatting?.chunkMode ?? "length", - }); + return ctx?.formatting?.parseMode === "HTML" + ? splitTelegramHtmlChunks(text, limit) + : chunkMarkdownTextWithMode(text, limit, ctx?.formatting?.chunkMode ?? "length"); } async function resolveTelegramSendContext(params: { @@ -77,6 +75,7 @@ async function resolveTelegramSendContext(params: { cfg: NonNullable["cfg"]; verbose: false; textMode?: "html"; + tableMode?: OutboundDeliveryFormattingOptions["tableMode"]; messageThreadId?: number; replyToMessageId?: number; accountId?: string; @@ -96,6 +95,7 @@ async function resolveTelegramSendContext(params: { silent: params.silent, gatewayClientScopes: params.gatewayClientScopes, ...(params.formatting?.parseMode === "HTML" ? { textMode: "html" as const } : {}), + tableMode: params.formatting?.tableMode, }, }; } @@ -251,9 +251,7 @@ export function createTelegramOutboundAdapter( }); }, resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => - typeof fallbackLimit === "number" - ? Math.min(fallbackLimit, TELEGRAM_RICH_TEXT_LIMIT) - : TELEGRAM_RICH_TEXT_LIMIT, + typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, pollMaxOptions: TELEGRAM_POLL_OPTION_LIMIT, supportsPollDurationSeconds: true, supportsAnonymousPolls: true, diff --git a/extensions/telegram/src/rich-message.ts b/extensions/telegram/src/rich-message.ts index cce76917afcc..d74895c3456e 100644 --- a/extensions/telegram/src/rich-message.ts +++ b/extensions/telegram/src/rich-message.ts @@ -11,6 +11,8 @@ import type { import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts"; import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking"; import { + escapeTelegramHtml, + limitTelegramRichHtmlNesting, markdownToTelegramRichHtml, sanitizeTelegramRichHtml, splitTelegramHtmlChunks, @@ -25,6 +27,8 @@ type TelegramRichMessageReplyMarkup = export const TELEGRAM_RICH_TEXT_LIMIT = 32_768; export const TELEGRAM_RICH_BLOCK_LIMIT = 500; +export const TELEGRAM_RICH_MEDIA_LIMIT = 50; +export const TELEGRAM_RICH_NESTING_LIMIT = 16; export type TelegramInputRichMessage = | { @@ -49,7 +53,7 @@ export type TelegramRichTextMode = "markdown" | "html"; export type TelegramRichTextChunk = { text: string; - textMode: TelegramRichTextMode; + textMode: "html"; plainText: string; }; @@ -166,7 +170,7 @@ export function buildTelegramRichHtml( html: string, options?: TelegramRichMessageOptions, ): TelegramInputRichMessage { - const safeHtml = sanitizeTelegramRichHtml(html); + const safeHtml = prepareTelegramRichHtml(html); return options?.skipEntityDetection === true ? { html: safeHtml, skip_entity_detection: true } : { html: safeHtml }; @@ -182,6 +186,59 @@ export function buildTelegramRichMessage( : buildTelegramRichMarkdown(text, options); } +function prepareTelegramRichHtml(html: string): string { + return limitTelegramRichHtmlNesting(sanitizeTelegramRichHtml(html), TELEGRAM_RICH_NESTING_LIMIT); +} + +const TELEGRAM_RICH_HTML_CHUNK_LIMITS = { + blockLimit: TELEGRAM_RICH_BLOCK_LIMIT, + mediaLimit: TELEGRAM_RICH_MEDIA_LIMIT, +} as const; + +function splitPreparedTelegramRichHtml(params: { + html: string; + sourceFallback: string; + textLimit: number; +}): string[] { + try { + const chunks = splitTelegramHtmlChunks( + params.html, + params.textLimit, + TELEGRAM_RICH_HTML_CHUNK_LIMITS, + ); + if (chunks.length > 0) { + return chunks; + } + } catch { + // Fall through to readable source text when rich planning cannot preserve the payload. + } + return splitTelegramHtmlChunks(escapeTelegramHtml(params.sourceFallback), params.textLimit); +} + +export function isTelegramRichMessageWithinStructuralLimits( + message: TelegramInputRichMessage, +): boolean { + if (message.markdown !== undefined) { + if (splitTelegramRichMarkdownBlocks(message.markdown, TELEGRAM_RICH_BLOCK_LIMIT).length > 1) { + return false; + } + return ( + splitTelegramHtmlChunks( + prepareTelegramRichHtml(markdownToTelegramRichHtml(message.markdown)), + TELEGRAM_RICH_TEXT_LIMIT, + TELEGRAM_RICH_HTML_CHUNK_LIMITS, + ).length <= 1 + ); + } + return ( + splitTelegramHtmlChunks( + prepareTelegramRichHtml(message.html), + TELEGRAM_RICH_TEXT_LIMIT, + TELEGRAM_RICH_HTML_CHUNK_LIMITS, + ).length <= 1 + ); +} + type RichMarkdownFenceSpan = { start: number; end: number; @@ -352,7 +409,11 @@ export function splitTelegramRichTextChunks(params: { chunkMode: ChunkMode; }): string[] { return params.textMode === "html" - ? splitTelegramHtmlChunks(sanitizeTelegramRichHtml(params.text), params.textLimit) + ? splitTelegramHtmlChunks( + prepareTelegramRichHtml(params.text), + params.textLimit, + TELEGRAM_RICH_HTML_CHUNK_LIMITS, + ) : splitTelegramRichMarkdownChunks(params.text, params.textLimit, params.chunkMode); } @@ -365,15 +426,26 @@ export function splitTelegramRichMessageTextChunks(params: { skipEntityDetection?: boolean; }): TelegramRichTextChunk[] { const renderMarkdownChunk = (chunk: string) => - markdownToTelegramRichHtml(chunk, { - tableMode: params.tableMode, - skipEntityDetection: params.skipEntityDetection, - }); + prepareTelegramRichHtml( + markdownToTelegramRichHtml(chunk, { + tableMode: params.tableMode, + skipEntityDetection: params.skipEntityDetection, + }), + ); const htmlChunks = params.textMode === "html" - ? splitTelegramHtmlChunks(sanitizeTelegramRichHtml(params.text), params.textLimit) + ? splitPreparedTelegramRichHtml({ + html: prepareTelegramRichHtml(params.text), + sourceFallback: params.text, + textLimit: params.textLimit, + }) : splitTelegramRichMarkdownChunks(params.text, params.textLimit, params.chunkMode).flatMap( - (chunk) => splitTelegramHtmlChunks(renderMarkdownChunk(chunk), params.textLimit), + (chunk) => + splitPreparedTelegramRichHtml({ + html: renderMarkdownChunk(chunk), + sourceFallback: chunk, + textLimit: params.textLimit, + }), ); return htmlChunks.map((chunk) => ({ text: chunk, diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index bc8d99fc552f..02820156e6c7 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -8,7 +8,7 @@ import { } from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { markdownToTelegramHtml, markdownToTelegramRichHtml } from "./format.js"; +import { markdownToTelegramHtml } from "./format.js"; import { buildTelegramConversationContext, createTelegramMessageCache, @@ -65,11 +65,6 @@ const { } = telegramSendModule; const sendMessageTelegramImpl = sendMessageTelegramImported; -type RichSendCallParams = { - rich_message?: { markdown?: string; html?: string }; - reply_markup?: unknown; -}; - type RichRawTextTestApi = Omit & { raw?: { sendRichMessage?: (params: { @@ -91,8 +86,8 @@ function richTextForTest(richMessage: { markdown?: string; html?: string }): str : (richMessage.html ?? ""); } -function richSendCallParams(): RichSendCallParams[] { - return botRawApi.sendRichMessage.mock.calls.map(([params]) => params); +function sendMessageTexts(mockFn: typeof botApi.sendMessage): string[] { + return mockFn.mock.calls.map((call) => String(call[1] ?? "")); } function withRichRawTextTestApi( @@ -129,7 +124,7 @@ const sendMessageTelegram: typeof sendMessageTelegramImpl = async (to, text, opt : opts, ); -const TELEGRAM_TEST_CFG = { channels: { telegram: { markdown: { tables: "block" as const } } } }; +const TELEGRAM_TEST_CFG = {}; let sentMessageStore: NonNullable[0]>; function markdownTable(columns: number): string { @@ -142,6 +137,22 @@ function markdownTable(columns: number): string { .join("\n"); } +function markdownTableWithRows(rows: number): string { + return [ + "| Name | Value |", + "| --- | --- |", + ...Array.from({ length: rows }, (_, index) => `| row ${index} | ${index} |`), + ].join("\n"); +} + +function countTelegramRichHtmlBlocks(html: string): number { + return ( + html.match( + /<(?:aside|audio|blockquote|details|figure|footer|h[1-6]|hr|img|li|ol|p|pre|table|tg-collage|tg-map|tg-math-block|tg-slideshow|tr|ul|video)\b/gi, + )?.length ?? 0 + ); +} + beforeEach(() => { resetPluginStateStoreForTests({ closeDatabase: false }); installTelegramStateRuntimeForTest(); @@ -882,13 +893,15 @@ describe("sendMessageTelegram", () => { expect(res.messageId).toBe("44"); }); - it("skips rich entity detection when link previews are disabled", async () => { + it("disables link previews on the text send path", async () => { const cases = [ { name: "html send succeeds", text: "hi", sendMessage: vi.fn().mockResolvedValue({ message_id: 7, chat: { id: "123" } }), - expectedCalls: [["123", "hi", { parse_mode: "HTML", skip_entity_detection: true }]], + expectedCalls: [ + ["123", "hi", { parse_mode: "HTML", link_preview_options: { is_disabled: true } }], + ], }, ] as const; for (const testCase of cases) { @@ -908,7 +921,7 @@ describe("sendMessageTelegram", () => { } }); - it("sends Markdown durable text as Telegram rich HTML", async () => { + it("sends formatted HTML for durable text", async () => { botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); await sendMessageTelegram("123", "**hi**", { @@ -916,13 +929,122 @@ describe("sendMessageTelegram", () => { token: "tok", }); - expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({ - chat_id: "123", - rich_message: { html: "hi" }, + expect(botApi.sendMessage).toHaveBeenCalledWith("123", "hi", { + parse_mode: "HTML", }); + expect(botRawApi.sendRichMessage).not.toHaveBeenCalled(); }); - it("sends complex Markdown through Telegram rich HTML", async () => { + it("sends native rich tables when explicitly enabled", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + const markdown = markdownTable(3); + + await sendMessageTelegram("123", markdown, { + cfg: { + channels: { + telegram: { + richMessages: true, + markdown: { tables: "block" }, + }, + }, + }, + token: "tok", + }); + + expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1); + const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message; + expect(richMessage?.html).toContain(""); + }); + + it.each([ + { + name: "list", + text: `
    ${Array.from({ length: 501 }, (_, index) => `
  • item ${index}
  • `).join("")}
`, + textMode: "html" as const, + terminalText: "item 500", + }, + { + name: "table", + text: markdownTableWithRows(501), + textMode: "markdown" as const, + terminalText: "row 500", + }, + ])("chunks rich $name output at Telegram's block limit", async (testCase) => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + + await sendMessageTelegram("123", testCase.text, { + cfg: { + channels: { + telegram: { + richMessages: true, + markdown: { tables: "block" }, + }, + }, + }, + token: "tok", + textMode: testCase.textMode, + }); + + expect(botRawApi.sendRichMessage.mock.calls.length).toBeGreaterThan(1); + const htmlChunks = botRawApi.sendRichMessage.mock.calls.map( + (call) => call[0]?.rich_message.html ?? "", + ); + for (const html of htmlChunks) { + expect(countTelegramRichHtmlBlocks(html)).toBeLessThanOrEqual(500); + } + expect(htmlChunks.join("\n")).toContain(testCase.terminalText); + }); + + it("chunks rich media at Telegram's attachment limit", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + const html = Array.from( + { length: 51 }, + (_, index) => `image ${index}`, + ).join(""); + + await sendMessageTelegram("123", html, { + cfg: { channels: { telegram: { richMessages: true } } }, + token: "tok", + textMode: "html", + }); + + expect(botRawApi.sendRichMessage.mock.calls.length).toBe(2); + for (const call of botRawApi.sendRichMessage.mock.calls) { + const richHtml = call[0]?.rich_message.html ?? ""; + expect(richHtml.match(/ { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + const html = `${"".repeat(20)}nested
line${"
".repeat(20)}`; + + await sendMessageTelegram("123", html, { + cfg: { channels: { telegram: { richMessages: true } } }, + token: "tok", + textMode: "html", + }); + + expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1); + const richHtml = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.html ?? ""; + expect(richHtml.match(//g)?.length ?? 0).toBe(16); + expect(richHtml).toContain("nested
line"); + }); + + it("preserves nonempty Markdown when rich rendering is empty", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + const markdown = "[reference]: https://example.com"; + + await sendMessageTelegram("123", markdown, { + cfg: { channels: { telegram: { richMessages: true } } }, + token: "tok", + }); + + expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1); + expect(botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.html).toBe(markdown); + }); + + it("renders complex markdown into HTML text", async () => { botApi.sendMessage.mockResolvedValue({ message_id: 46, chat: { id: "123" } }); const markdown = [ "# Heading", @@ -941,123 +1063,47 @@ describe("sendMessageTelegram", () => { token: "tok", }); - expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({ - chat_id: "123", - rich_message: { html: markdownToTelegramRichHtml(markdown) }, - }); + expect(botApi.sendMessage).toHaveBeenCalledTimes(1); + const [chatId, sentText, sentOptions] = botApi.sendMessage.mock.calls.at(-1) ?? []; + expect(chatId).toBe("123"); + expect(String(sentText)).toContain("
"); + expect(String(sentText)).toContain("spoiler"); + expect(String(sentText)).toContain('link'); + expect(sentOptions).toEqual({ parse_mode: "HTML" }); + expect(botRawApi.sendRichMessage).not.toHaveBeenCalled(); }); - it("does not pass currency through Telegram Rich Markdown math parsing", async () => { + it("renders markdown media syntax on the text path", async () => { botApi.sendMessage.mockResolvedValue({ message_id: 47, chat: { id: "123" } }); - const text = - "10Y realistic strong outcome: ~$400-600K TC, top end ($800K+) gated on frontier lab equity."; - await sendMessageTelegram("123", text, { + await sendMessageTelegram("123", "See ![diagram](https://example.com/diagram.png)", { cfg: TELEGRAM_TEST_CFG, token: "tok", }); - expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({ - chat_id: "123", - rich_message: { - html: markdownToTelegramRichHtml(text), - }, - }); - const richMessage = richSendCallParams()[0]?.rich_message; - expect(richMessage?.html).toContain("$400-600K"); - expect(richMessage?.html).toContain("($800K+)"); - expect(richMessage?.markdown).toBeUndefined(); + expect(botApi.sendMessage).toHaveBeenCalledWith("123", "See diagram", { parse_mode: "HTML" }); + expect(botRawApi.sendRichMessage).not.toHaveBeenCalled(); }); - it("preserves line breaks outside fenced code through rich HTML", async () => { - botApi.sendMessage.mockResolvedValue({ message_id: 47, chat: { id: "123" } }); - const markdown = [ - "Status: ok | mode", - "Models: ready", - "", - "```", - "a", - "b", - "```", - "Tail", - ].join("\n"); - - await sendMessageTelegram("123", markdown, { - cfg: TELEGRAM_TEST_CFG, - token: "tok", - }); - - expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({ - chat_id: "123", - rich_message: { html: markdownToTelegramRichHtml(markdown) }, - }); - }); - - it("isolates supported rich HTML media tags as blocks", async () => { + it("escapes HTML media tags on the text path", async () => { botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } }); - const html = 'See'; - const expectedHtml = - 'See\n\n
'; - await sendMessageTelegram("123", html, { + await sendMessageTelegram("123", 'See', { cfg: TELEGRAM_TEST_CFG, token: "tok", textMode: "html", }); - expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({ - chat_id: "123", - rich_message: { html: expectedHtml }, - }); - }); - - it("preserves supported Telegram rich HTML structures", async () => { - botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } }); - const html = [ - "

Plan

", - "
More

Hidden

", - "
A
B
", - '
diagram
', - "x^2 + y^2", - ].join(""); - - await sendMessageTelegram("123", html, { - cfg: TELEGRAM_TEST_CFG, - token: "tok", - textMode: "html", - }); - - const renderedHtml = richSendCallParams()[0]?.rich_message?.html ?? ""; - expect(renderedHtml).toContain("

Plan

"); - expect(renderedHtml).toContain("
More

Hidden

"); - expect(renderedHtml).toContain(""); - expect(renderedHtml).toContain( - '\n\n
diagram
\n\n', + expect(botApi.sendMessage).toHaveBeenCalledWith( + "123", + 'See<img src="https://example.com/diagram.png">', + { parse_mode: "HTML" }, ); - expect(renderedHtml).toContain("x^2 + y^2"); + expect(botRawApi.sendRichMessage).not.toHaveBeenCalled(); }); - it("sends raw rich HTML tags through Telegram rich HTML", async () => { + it("keeps markdown tables within Telegram's HTML text path", async () => { botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } }); - const markdown = [ - 'Diagram', - "
MoreHidden
", - "1", - '', - ].join(" "); - - await sendMessageTelegram("123", markdown, { - cfg: TELEGRAM_TEST_CFG, - token: "tok", - }); - - expect(richSendCallParams()[0]?.rich_message).toEqual({ - html: markdownToTelegramRichHtml(markdown), - }); - }); - - it("sends Markdown tables within Telegram's column limit as rich HTML tables", async () => { - botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } }); const markdown = markdownTable(20); await sendMessageTelegram("123", markdown, { @@ -1065,37 +1111,13 @@ describe("sendMessageTelegram", () => { token: "tok", }); - expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({ - chat_id: "123", - rich_message: { html: markdownToTelegramRichHtml(markdown) }, - }); - expect(richSendCallParams()[0]?.rich_message?.html).toContain("
"); + expect(botApi.sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("| H1 | H2 |"); + expect(botRawApi.sendRichMessage).not.toHaveBeenCalled(); }); - it("does not auto-linkify Markdown URLs when link previews are disabled", async () => { + it("wraps wide markdown tables for the HTML text path", async () => { botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } }); - const cfg = { - channels: { - telegram: { - markdown: { tables: "block" as const }, - linkPreview: false, - }, - }, - }; - - await sendMessageTelegram("123", "https://example.com", { - cfg, - token: "tok", - }); - - expect(richSendCallParams()[0]?.rich_message).toEqual({ - html: "https://example.com", - skip_entity_detection: true, - }); - }); - - it("renders wide Markdown tables as code blocks when they exceed Telegram's column limit", async () => { - botApi.sendMessage.mockResolvedValue({ message_id: 51, chat: { id: "123" } }); const markdown = markdownTable(21); await sendMessageTelegram("123", markdown, { @@ -1103,15 +1125,15 @@ describe("sendMessageTelegram", () => { token: "tok", }); - expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({ - chat_id: "123", - rich_message: { html: markdownToTelegramRichHtml(markdown) }, - }); - expect(richSendCallParams()[0]?.rich_message?.html).toContain("
");
+    expect(botApi.sendMessage).toHaveBeenCalledTimes(1);
+    const sent = sendMessageTexts(botApi.sendMessage).join("");
+    expect(sent).toContain("
");
+    expect(sent).toContain("| H21 |");
+    expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
   });
 
-  it("renders fenced wide Markdown tables as code in rich HTML", async () => {
-    botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } });
+  it("leaves wide fenced tables intact on the HTML text path", async () => {
+    botApi.sendMessage.mockResolvedValue({ message_id: 51, chat: { id: "123" } });
     const markdown = `~~~\n${markdownTable(25)}\n~~~`;
 
     await sendMessageTelegram("123", markdown, {
@@ -1119,13 +1141,12 @@ describe("sendMessageTelegram", () => {
       token: "tok",
     });
 
-    expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
-      chat_id: "123",
-      rich_message: { html: markdownToTelegramRichHtml(markdown) },
-    });
+    expect(botApi.sendMessage).toHaveBeenCalledTimes(1);
+    expect(sendMessageTexts(botApi.sendMessage).join("")).toContain(markdownTable(25));
+    expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
   });
 
-  it("falls back only wide Markdown tables outside fences", async () => {
+  it("wraps only wide markdown tables outside fences on the HTML text path", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } });
     const fencedTable = markdownTable(25);
     const outsideTable = markdownTable(21);
@@ -1136,29 +1157,30 @@ describe("sendMessageTelegram", () => {
       token: "tok",
     });
 
-    expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
-      chat_id: "123",
-      rich_message: { html: markdownToTelegramRichHtml(markdown) },
-    });
+    expect(botApi.sendMessage).toHaveBeenCalledTimes(1);
+    const sent = sendMessageTexts(botApi.sendMessage).join("");
+    expect(sent).toContain("Before");
+    expect(sent).toContain(fencedTable);
+    expect(sent).toContain("
");
+    expect(sent).toContain("| H21 |");
+    expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
   });
 
-  it("chunks long rich HTML when source text exceeds the message limit", async () => {
+  it("sends medium markdown text as one HTML message", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 53, chat: { id: "123" } });
-    const line = "**section** with _style_ and `code`";
-    const markdown = `# Long\n\n${`${line}\n`.repeat(2000)}`;
+    const markdown = `# Long\n\n${"**section** with _style_ and `code`\n".repeat(800)}`;
 
     await sendMessageTelegram("123", markdown, {
       cfg: TELEGRAM_TEST_CFG,
       token: "tok",
     });
 
-    const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
-    expect(chunks.length).toBeGreaterThan(1);
-    expect(chunks.every((chunk) => chunk.length <= 32_768)).toBe(true);
-    expect(chunks.join("").match(/section<\/b>/g)).toHaveLength(2000);
+    expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
+    expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("section");
+    expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
   });
 
-  it("chunks rich HTML above the Bot API rich message limit", async () => {
+  it("chunks markdown above the Telegram text-message limit", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 54, chat: { id: "123" } });
     const markdown = `# Long\n\n${"**section** with _style_ and `code`\n".repeat(3000)}`;
 
@@ -1167,14 +1189,15 @@ describe("sendMessageTelegram", () => {
       token: "tok",
     });
 
-    expect(botRawApi.sendRichMessage.mock.calls.length).toBeGreaterThan(1);
-    const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
-    expect(chunks.at(0)).toContain("Long");
-    expect(chunks.join("").match(/section<\/b>/g)).toHaveLength(3000);
-    expect(chunks.every((chunk) => chunk.length <= 32_768)).toBe(true);
+    expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
+    const chunks = sendMessageTexts(botApi.sendMessage);
+    const joinedChunks = chunks.join("");
+    expect(joinedChunks).toContain("Long");
+    expect(joinedChunks).toContain("section");
+    expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
   });
 
-  it("chunks long inline Markdown as bounded rich HTML", async () => {
+  it("chunks long inline markdown through the HTML text path", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } });
     const markdown = `**${"A".repeat(70_000)}**`;
 
@@ -1183,14 +1206,13 @@ describe("sendMessageTelegram", () => {
       token: "tok",
     });
 
-    const chunks = richSendCallParams().map((params) => params.rich_message);
+    const chunks = sendMessageTexts(botApi.sendMessage);
     expect(chunks.length).toBeGreaterThan(1);
-    expect(chunks.every((chunk) => chunk?.markdown === undefined)).toBe(true);
-    expect(chunks.every((chunk) => (chunk?.html ?? "").length <= 32_768)).toBe(true);
-    expect(chunks.map((chunk) => chunk?.html ?? "").join("")).toContain("A".repeat(100));
+    expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
+    expect(chunks.join("")).toContain("A");
   });
 
-  it("chunks rich markdown above Telegram's rich block limit", async () => {
+  it("chunks long markdown paragraphs on the text path", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 53, chat: { id: "123" } });
     const markdown = Array.from({ length: 900 }, (_, index) => `Paragraph ${index + 1}`).join(
       "\n\n",
@@ -1201,13 +1223,12 @@ describe("sendMessageTelegram", () => {
       token: "tok",
     });
 
-    const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
-    expect(chunks).toHaveLength(2);
-    expect(chunks.every((chunk) => (chunk.match(/Paragraph \d+/g)?.length ?? 0) <= 500)).toBe(true);
-    expect(chunks.join("").match(/Paragraph \d+/g)).toHaveLength(900);
+    const chunks = sendMessageTexts(botApi.sendMessage);
+    expect(chunks.length).toBeGreaterThan(1);
+    expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
   });
 
-  it("chunks rich markdown headings above Telegram's rich block limit", async () => {
+  it("chunks long markdown headings on the text path", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 54, chat: { id: "123" } });
     const markdown = Array.from({ length: 600 }, (_, index) => `# Heading ${index + 1}`).join("\n");
 
@@ -1216,13 +1237,12 @@ describe("sendMessageTelegram", () => {
       token: "tok",
     });
 
-    const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
-    expect(chunks).toHaveLength(2);
-    expect(chunks.at(0)?.match(/Heading \d+/g)).toHaveLength(500);
-    expect(chunks.at(1)?.match(/Heading \d+/g)).toHaveLength(100);
+    const chunks = sendMessageTexts(botApi.sendMessage);
+    expect(chunks.length).toBeGreaterThan(1);
+    expect(chunks.join("")).toContain("Heading 600");
   });
 
-  it("keeps long rich markdown lists intact", async () => {
+  it("keeps long markdown lists on the text path", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 55, chat: { id: "123" } });
     const markdown = Array.from({ length: 600 }, (_, index) => `- Item ${index + 1}`).join("\n");
 
@@ -1231,14 +1251,12 @@ describe("sendMessageTelegram", () => {
       token: "tok",
     });
 
-    expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
-    expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
-      chat_id: "123",
-      rich_message: { html: markdownToTelegramRichHtml(markdown) },
-    });
+    expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
+    expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("Item 600");
+    expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
   });
 
-  it("keeps tall rich markdown tables intact", async () => {
+  it("keeps tall markdown tables on the text path", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 56, chat: { id: "123" } });
     const markdown = [
       "| Name | Value |",
@@ -1251,14 +1269,12 @@ describe("sendMessageTelegram", () => {
       token: "tok",
     });
 
-    expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
-    expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
-      chat_id: "123",
-      rich_message: { html: markdownToTelegramRichHtml(markdown) },
-    });
+    expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
+    expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("Row 600");
+    expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
   });
 
-  it("does not split rich block chunks on blank lines inside fences", async () => {
+  it("does not split fenced blocks unnecessarily on the text path", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 57, chat: { id: "123" } });
     const markdown = `~~~txt\n${Array.from({ length: 900 }, (_, index) => `line ${index + 1}`).join(
       "\n\n",
@@ -1269,14 +1285,12 @@ describe("sendMessageTelegram", () => {
       token: "tok",
     });
 
-    expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
-    expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
-      chat_id: "123",
-      rich_message: { html: markdownToTelegramRichHtml(markdown) },
-    });
+    expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
+    expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("line 900");
+    expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
   });
 
-  it("does not split rich heading chunks inside fences", async () => {
+  it("does not split fenced headings unnecessarily on the text path", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 58, chat: { id: "123" } });
     const markdown = `~~~md\n${Array.from(
       { length: 600 },
@@ -1288,14 +1302,12 @@ describe("sendMessageTelegram", () => {
       token: "tok",
     });
 
-    expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
-    expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
-      chat_id: "123",
-      rich_message: { html: markdownToTelegramRichHtml(markdown) },
-    });
+    expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
+    expect(sendMessageTexts(botApi.sendMessage).join("")).toContain("Literal heading 600");
+    expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
   });
 
-  it("chunks long rich markdown fences into bounded rich HTML chunks", async () => {
+  it("chunks long fenced markdown into bounded text chunks", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 59, chat: { id: "123" } });
     const markdown = `~~~ts\n${"const value = 1;\n".repeat(5000)}~~~`;
 
@@ -1304,13 +1316,12 @@ describe("sendMessageTelegram", () => {
       token: "tok",
     });
 
-    const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
+    const chunks = sendMessageTexts(botApi.sendMessage);
     expect(chunks.length).toBeGreaterThan(1);
-    expect(chunks.every((chunk) => chunk.length <= 32_768)).toBe(true);
-    expect(chunks.join("")).toContain("const value = 1;");
+    expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
   });
 
-  it("chunks explicit rich HTML above the Bot API rich message limit", async () => {
+  it("chunks explicit HTML above the Telegram text-message limit", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 60, chat: { id: "123" } });
     const html = `${"A".repeat(70_000)}`;
 
@@ -1321,42 +1332,14 @@ describe("sendMessageTelegram", () => {
       buttons: [[{ text: "OK", callback_data: "ok" }]],
     });
 
-    expect(botRawApi.sendRichMessage.mock.calls.length).toBeGreaterThan(1);
-    const calls = richSendCallParams();
-    expect(calls.every((params) => (params.rich_message?.html ?? "").length <= 32_768)).toBe(true);
-    expect(calls.at(0)?.rich_message?.html).toMatch(/^A/);
-    expect(calls.at(-1)?.rich_message?.html).toMatch(/A<\/b>$/);
-    expect(calls.slice(0, -1).every((params) => params.reply_markup === undefined)).toBe(true);
-    expect(calls.at(-1)?.reply_markup).toEqual({
+    expect(botApi.sendMessage.mock.calls.length).toBeGreaterThan(1);
+    const lastParams = botApi.sendMessage.mock.calls.at(-1)?.[2];
+    expect(sendMessageTexts(botApi.sendMessage).every((chunk) => chunk.length <= 4000)).toBe(true);
+    expect(requireRecord(lastParams, "last sendMessage params").reply_markup).toEqual({
       inline_keyboard: [[{ text: "OK", callback_data: "ok" }]],
     });
   });
 
-  it("chunks explicit rich HTML after media normalization", async () => {
-    botApi.sendMessage.mockResolvedValue({ message_id: 61, chat: { id: "123" } });
-    const img = '';
-    const html = img.repeat(3);
-    const cfg = {
-      channels: {
-        telegram: {
-          markdown: { tables: "block" as const },
-          textChunkLimit: html.length + 5,
-        },
-      },
-    };
-
-    await sendMessageTelegram("123", html, {
-      cfg,
-      token: "tok",
-      textMode: "html",
-    });
-
-    const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
-    expect(chunks.length).toBeGreaterThan(1);
-    expect(chunks.every((chunk) => chunk.length <= html.length + 5)).toBe(true);
-    expect(chunks.join("")).toContain("
"); - }); - it("fails when Telegram text send returns no message_id", async () => { const sendMessage = vi.fn().mockResolvedValue({ chat: { id: "123" }, @@ -1612,7 +1595,7 @@ describe("sendMessageTelegram", () => { expectMediaSendCall(firstMockCall(sendPhoto, "send photo call"), "send photo call", chatId, { caption: undefined, }); - expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(2); expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === "HTML")).toBe(true); expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toContain("A"); expect(res.messageId).toBe("74"); @@ -1918,7 +1901,16 @@ describe("sendMessageTelegram", () => { chatId, testCase.expectedVideoNote, ); - expect(sendMessage).toHaveBeenCalledWith(chatId, testCase.text, testCase.expectedMessage); + expect(sendMessage).toHaveBeenCalledWith(chatId, testCase.text, { + ...testCase.expectedMessage, + ...(testCase.expectedMessage?.reply_parameters + ? { + reply_to_message_id: 999, + allow_sending_without_reply: true, + reply_parameters: undefined, + } + : {}), + }); } }); @@ -2593,7 +2585,7 @@ describe("sendMessageTelegram", () => { expect(logs).toContain("accountId=ops"); expect(logs).toContain(`chatId=${chatId}`); expect(logs).toContain("messageId=321"); - expect(logs).toContain("operation=sendRichMessage"); + expect(logs).toContain("operation=sendMessage"); expect(logs).toContain("threadId=271"); expect(logs).toContain("replyToMessageId=123"); expect(logs).toContain("silent=true"); @@ -2797,10 +2789,10 @@ describe("sendMessageTelegram", () => { buttons: [[{ text: "OK", callback_data: "ok" }]], }); - expect(sendMessage).toHaveBeenCalledTimes(1); - const firstCall = firstMockCall(sendMessage, "first sendMessage call"); - const firstParams = requireRecord(firstCall[2], "first sendMessage params"); - expect(firstParams.reply_markup).toEqual({ + expect(sendMessage.mock.calls.length).toBeGreaterThan(1); + const lastCall = sendMessage.mock.calls.at(-1); + const lastParams = requireRecord(lastCall?.[2], "last sendMessage params"); + expect(lastParams.reply_markup).toEqual({ inline_keyboard: [[{ text: "OK", callback_data: "ok" }]], }); expect(res.messageId).toBe("91"); @@ -2820,13 +2812,15 @@ describe("sendMessageTelegram", () => { buttons: [[{ text: "OK", callback_data: "ok" }]], }); - expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage.mock.calls.length).toBeGreaterThan(1); const firstCall = firstMockCall(sendMessage, "first sendMessage call"); const firstParams = requireRecord(firstCall[2], "first sendMessage params"); const firstText = requireString(firstCall[1], "first sendMessage text"); expect(firstParams.parse_mode).toBe("HTML"); expect(firstText).toContain("A"); - expect(firstParams.reply_markup).toEqual({ + const lastCall = sendMessage.mock.calls.at(-1); + const lastParams = requireRecord(lastCall?.[2], "last sendMessage params"); + expect(lastParams.reply_markup).toEqual({ inline_keyboard: [[{ text: "OK", callback_data: "ok" }]], }); expect(res.messageId).toBe("91"); @@ -3116,10 +3110,8 @@ describe("shared send behaviors", () => { }); expect(sendMessage).toHaveBeenCalledWith(chatId, "reply text", { parse_mode: "HTML", - reply_parameters: { - message_id: 100, - allow_sending_without_reply: true, - }, + reply_to_message_id: 100, + allow_sending_without_reply: true, }); }, }, @@ -3450,7 +3442,7 @@ describe("editMessageTelegram", () => { expect(botApi.editMessageText).toHaveBeenCalledTimes(2); }); - it("edits Markdown text as Telegram rich HTML", async () => { + it("edits text with formatted HTML", async () => { botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); await editMessageTelegram("123", 1, "**edited**", { @@ -3458,14 +3450,13 @@ describe("editMessageTelegram", () => { cfg: {}, }); - expect(botRawApi.editMessageText).toHaveBeenCalledWith({ - chat_id: "123", - message_id: 1, - rich_message: { html: "edited" }, + expect(botApi.editMessageText).toHaveBeenCalledWith("123", 1, "edited", { + parse_mode: "HTML", }); + expect(botRawApi.editMessageText).not.toHaveBeenCalled(); }); - it("edits complex Markdown text as Telegram rich HTML", async () => { + it("edits complex text as formatted HTML", async () => { botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); const markdown = ["## Updated", "", "- **bold**", "- _italic_", "", "`code`"].join("\n"); @@ -3474,14 +3465,19 @@ describe("editMessageTelegram", () => { cfg: {}, }); - expect(botRawApi.editMessageText).toHaveBeenCalledWith({ - chat_id: "123", - message_id: 1, - rich_message: { html: markdownToTelegramRichHtml(markdown) }, - }); + expect(botApi.editMessageText).toHaveBeenCalledTimes(1); + const [chatId, messageId, sentText, sentOptions] = + botApi.editMessageText.mock.calls.at(-1) ?? []; + expect(chatId).toBe("123"); + expect(messageId).toBe(1); + expect(String(sentText)).toContain("Updated"); + expect(String(sentText)).toContain("bold"); + expect(String(sentText)).toContain("italic"); + expect(sentOptions).toEqual({ parse_mode: "HTML" }); + expect(botRawApi.editMessageText).not.toHaveBeenCalled(); }); - it("skips rich entity detection for rich text edits when link previews are disabled", async () => { + it("disables link previews for text edits", async () => { botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); await editMessageTelegram("123", 1, "https://example.com", { @@ -3490,15 +3486,16 @@ describe("editMessageTelegram", () => { linkPreview: false, }); - expect(botRawApi.editMessageText).toHaveBeenCalledTimes(1); - expect(botRawApi.editMessageText).toHaveBeenCalledWith({ - chat_id: "123", - message_id: 1, - rich_message: { - html: "https://example.com", - skip_entity_detection: true, + expect(botApi.editMessageText).toHaveBeenCalledWith( + "123", + 1, + 'https://example.com', + { + parse_mode: "HTML", + link_preview_options: { is_disabled: true }, }, - }); + ); + expect(botRawApi.editMessageText).not.toHaveBeenCalled(); }); }); diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 824081521de8..363c1340dd67 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -3,6 +3,7 @@ import * as grammy from "grammy"; import { type ApiClientOptions, Bot, HttpError } from "grammy"; import type { ReactionType, ReactionTypeEmoji } from "grammy/types"; import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime"; +import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts"; import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime"; import { formatUncaughtError } from "openclaw/plugin-sdk/error-runtime"; import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core"; @@ -21,13 +22,15 @@ import type { TelegramInlineButtons } from "./button-types.js"; import { splitTelegramCaption } from "./caption.js"; import { asTelegramClientFetch, createTelegramClientFetch } from "./client-fetch.js"; import { resolveTelegramTransport } from "./fetch.js"; -import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js"; +import { + renderTelegramHtmlText, + splitTelegramHtmlChunks, + telegramHtmlToPlainTextFallback, +} from "./format.js"; import { buildInlineKeyboard } from "./inline-keyboard.js"; import { isRecoverableTelegramNetworkError, isSafeToRetrySendError, - isTelegramMessageHasNoTextError, - isTelegramMessageNotModifiedError, isTelegramRateLimitError, isTelegramServerError, } from "./network-errors.js"; @@ -74,7 +77,9 @@ export { buildInlineKeyboard } from "./inline-keyboard.js"; type TelegramApi = Bot["api"]; export type TelegramApiOverride = Partial; +type TelegramSendMessageParams = Parameters[2]; type TelegramSendPollParams = Parameters[3]; +type TelegramEditMessageTextParams = Parameters[3]; type TelegramEditMessageCaptionParams = Parameters[2]; type TelegramCreateForumTopicParams = NonNullable[2]>; type TelegramThreadScopedParams = { @@ -97,6 +102,7 @@ type TelegramSendOpts = { api?: TelegramApiOverride; retry?: RetryConfig; textMode?: "markdown" | "html"; + tableMode?: MarkdownTableMode; /** Send audio as voice message instead of audio file. Defaults to false. */ asVoice?: boolean; /** Send video as video note instead of regular video. Defaults to false. */ @@ -173,6 +179,42 @@ function resolveTelegramMessageIdOrThrow( throw new Error(`Telegram ${context} returned no message_id`); } +function splitTelegramPlainTextChunks(text: string, limit: number): string[] { + if (!text) { + return []; + } + const normalizedLimit = Math.max(1, Math.floor(limit)); + const chunks: string[] = []; + for (let start = 0; start < text.length; start += normalizedLimit) { + chunks.push(text.slice(start, start + normalizedLimit)); + } + return chunks; +} + +function splitTelegramPlainTextFallback(text: string, chunkCount: number, limit: number): string[] { + if (!text) { + return []; + } + const normalizedLimit = Math.max(1, Math.floor(limit)); + const fixedChunks = splitTelegramPlainTextChunks(text, normalizedLimit); + if (chunkCount <= 1 || fixedChunks.length >= chunkCount) { + return fixedChunks; + } + const chunks: string[] = []; + let offset = 0; + for (let index = 0; index < chunkCount; index += 1) { + const remainingChars = text.length - offset; + const remainingChunks = chunkCount - index; + const nextChunkLength = + remainingChunks === 1 + ? remainingChars + : Math.min(normalizedLimit, Math.ceil(remainingChars / remainingChunks)); + chunks.push(text.slice(offset, offset + nextChunkLength)); + offset += nextChunkLength; + } + return chunks; +} + function logTelegramOutboundSendOk(params: TelegramOutboundSuccessLogParams): void { const parts = [ "telegram outbound send ok", @@ -200,6 +242,9 @@ function logTelegramOutboundSendOk(params: TelegramOutboundSuccessLogParams): vo } const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; +const MESSAGE_NOT_MODIFIED_RE = + /400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i; +const MESSAGE_HAS_NO_TEXT_RE = /400:\s*Bad Request:\s*there is no text in the message to edit/i; const MESSAGE_DELETE_NOOP_RE = /message to delete not found|message can't be deleted|MESSAGE_ID_INVALID|MESSAGE_DELETE_FORBIDDEN/i; const CHAT_NOT_FOUND_RE = /400: Bad Request: chat not found/i; @@ -389,6 +434,14 @@ function normalizeMessageId(raw: string | number): number { throw new Error("Message id is required for Telegram actions"); } +function isTelegramMessageNotModifiedError(err: unknown): boolean { + return MESSAGE_NOT_MODIFIED_RE.test(formatErrorMessage(err)); +} + +function isTelegramMessageHasNoTextError(err: unknown): boolean { + return MESSAGE_HAS_NO_TEXT_RE.test(formatErrorMessage(err)); +} + function isTelegramMessageDeleteNoopError(err: unknown): boolean { return MESSAGE_DELETE_NOOP_RE.test(formatErrorMessage(err)); } @@ -601,47 +654,74 @@ export async function sendMessageTelegram( }); const textMode = opts.textMode ?? "markdown"; - const tableMode = resolveMarkdownTableMode({ - cfg, - channel: "telegram", - accountId: account.accountId, - supportsBlockTables: true, - }); - const richMessageOptions = { - skipEntityDetection: account.config.linkPreview === false, - tableMode, - }; + const useRichMessages = account.config.richMessages === true; + const tableMode = + opts.tableMode ?? + resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: account.accountId, + supportsBlockTables: useRichMessages, + }); const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode }); - const textLimit = Math.min( - resolveTextChunkLimit(cfg, "telegram", account.accountId, { - fallbackLimit: TELEGRAM_RICH_TEXT_LIMIT, - }), - TELEGRAM_RICH_TEXT_LIMIT, - ); - const chunkMode = resolveChunkMode(cfg, "telegram", account.accountId); + // Resolve link preview setting from config (default: enabled). + const linkPreviewEnabled = account.config.linkPreview ?? true; + const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; + + type TelegramTextChunk = { + plainText: string; + htmlText?: string; + }; const sendTelegramTextChunk = async ( - chunk: TelegramRichTextChunk, - params?: TelegramRichMessageContextParams, + chunk: TelegramTextChunk, + params?: TelegramSendMessageParams, ) => { - const richRawApi = getTelegramRichRawApi(api); - const richParams = { - ...params, + const baseParams = params ? { ...params } : {}; + if (linkPreviewOptions) { + baseParams.link_preview_options = linkPreviewOptions; + } + const plainParams: TelegramSendMessageParams = { + ...baseParams, ...(opts.silent === true ? { disable_notification: true } : {}), }; - const result = await requestWithChatNotFound( - () => - richRawApi.sendRichMessage({ - chat_id: chatId, - rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, richMessageOptions), - ...richParams, - }), - "richMessage", - ); + const hasPlainParams = Object.keys(plainParams).length > 0; + const requestPlain = (label: string) => + requestWithChatNotFound( + () => + hasPlainParams + ? api.sendMessage(chatId, chunk.plainText, plainParams) + : api.sendMessage(chatId, chunk.plainText), + label, + ); + const result = !chunk.htmlText + ? await requestPlain("message") + : await withTelegramHtmlParseFallback({ + label: "message", + verbose: opts.verbose, + requestHtml: (label) => + requestWithChatNotFound( + () => + api.sendMessage(chatId, chunk.htmlText ?? chunk.plainText, { + parse_mode: "HTML" as const, + ...plainParams, + }), + label, + ), + requestPlain, + }); return { result, acceptedParams: params }; }; const buildTextParams = (isLastChunk: boolean) => + hasThreadParams || (isLastChunk && replyMarkup) + ? { + ...threadParams, + ...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}), + } + : undefined; + + const buildRichTextParams = (isLastChunk: boolean) => hasRichThreadParams || (isLastChunk && replyMarkup) ? { ...richThreadParams, @@ -650,7 +730,7 @@ export async function sendMessageTelegram( : undefined; const sendTelegramTextChunks = async ( - chunks: TelegramRichTextChunk[], + chunks: TelegramTextChunk[], context: string, ): Promise<{ messageId: string; chatId: string }> => { let lastMessageId = ""; @@ -689,7 +769,7 @@ export async function sendMessageTelegram( accountId: account.accountId, chatId: lastChatId, messageId: lastMessageId, - operation: "sendRichMessage", + operation: "sendMessage", deliveryKind: "text", messageThreadId: lastAcceptedParams?.message_thread_id, replyToMessageId: opts.replyToMessageId, @@ -700,19 +780,117 @@ export async function sendMessageTelegram( return { messageId: lastMessageId, chatId: lastChatId }; }; - const buildChunkedTextPlan = (rawText: string): TelegramRichTextChunk[] => { + const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => { + const htmlText = renderHtmlText(rawText); + const fallbackText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : rawText; + let htmlChunks: string[]; + try { + htmlChunks = splitTelegramHtmlChunks(htmlText, 4000); + } catch (error) { + logVerbose( + `telegram ${context} failed HTML chunk planning, retrying as plain text: ${formatErrorMessage( + error, + )}`, + ); + return splitTelegramPlainTextChunks(fallbackText, 4000).map((plainText) => ({ plainText })); + } + const fixedPlainTextChunks = splitTelegramPlainTextChunks(fallbackText, 4000); + if (fixedPlainTextChunks.length > htmlChunks.length) { + logVerbose( + `telegram ${context} plain-text fallback needs more chunks than HTML; sending plain text`, + ); + return fixedPlainTextChunks.map((plainText) => ({ plainText })); + } + const plainTextChunks = splitTelegramPlainTextFallback(fallbackText, htmlChunks.length, 4000); + return htmlChunks.map((htmlTextLocal, index) => ({ + htmlText: htmlTextLocal, + plainText: plainTextChunks[index] ?? htmlTextLocal, + })); + }; + + const sendChunkedText = async (rawText: string, context: string) => + useRichMessages + ? await sendTelegramRichTextChunks(buildRichTextPlan(rawText), context) + : await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context); + + const buildRichTextPlan = (rawText: string): TelegramRichTextChunk[] => { + const textLimit = Math.min( + resolveTextChunkLimit(cfg, "telegram", account.accountId, { + fallbackLimit: TELEGRAM_RICH_TEXT_LIMIT, + }), + TELEGRAM_RICH_TEXT_LIMIT, + ); return splitTelegramRichMessageTextChunks({ text: rawText, textLimit, textMode, - chunkMode, + chunkMode: resolveChunkMode(cfg, "telegram", account.accountId), tableMode, - skipEntityDetection: richMessageOptions.skipEntityDetection, + skipEntityDetection: account.config.linkPreview === false, }); }; - const sendChunkedText = async (rawText: string, context: string) => - await sendTelegramTextChunks(buildChunkedTextPlan(rawText), context); + const sendTelegramRichTextChunks = async ( + chunks: TelegramRichTextChunk[], + context: string, + ): Promise<{ messageId: string; chatId: string }> => { + const richRawApi = getTelegramRichRawApi(api); + let lastMessageId = ""; + let lastChatId = chatId; + let lastAcceptedParams: TelegramRichMessageContextParams | undefined; + let sentChunkCount = 0; + for (let index = 0; index < chunks.length; index += 1) { + const chunk = chunks[index]; + if (!chunk) { + continue; + } + const acceptedParams = buildRichTextParams(index === chunks.length - 1); + const result = await requestWithChatNotFound( + () => + richRawApi.sendRichMessage({ + chat_id: chatId, + rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, { + skipEntityDetection: account.config.linkPreview === false, + tableMode, + }), + ...acceptedParams, + ...(opts.silent === true ? { disable_notification: true } : {}), + }), + "richMessage", + ); + const messageId = resolveTelegramMessageIdOrThrow(result, context); + recordSentMessage(chatId, messageId, cfg); + await recordOutboundMessageForPromptContext({ + cfg, + account, + chatId, + message: result, + messageId, + text: chunk.plainText, + ...(acceptedParams?.message_thread_id !== undefined + ? { messageThreadId: acceptedParams.message_thread_id } + : {}), + }); + lastMessageId = String(messageId); + lastChatId = String(result?.chat?.id ?? chatId); + lastAcceptedParams = acceptedParams; + sentChunkCount += 1; + } + if (lastMessageId) { + logTelegramOutboundSendOk({ + accountId: account.accountId, + chatId: lastChatId, + messageId: lastMessageId, + operation: "sendRichMessage", + deliveryKind: "text", + messageThreadId: lastAcceptedParams?.message_thread_id, + replyToMessageId: opts.replyToMessageId, + silent: opts.silent, + chunkCount: sentChunkCount, + }); + } + return { messageId: lastMessageId, chatId: lastChatId }; + }; async function shouldSendTelegramImageAsPhoto(buffer: Buffer): Promise { try { @@ -1350,19 +1528,22 @@ export async function editMessageTelegram( ) => requestWithDiag(fn, label, shouldLog ? { shouldLog } : undefined); const textMode = opts.textMode ?? "markdown"; + const useRichMessages = account.config.richMessages === true; const tableMode = resolveMarkdownTableMode({ cfg, channel: "telegram", accountId: account.accountId, - supportsBlockTables: true, + supportsBlockTables: useRichMessages, }); const htmlText = renderTelegramHtmlText(text, { textMode, tableMode }); const plainText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : text; - const richRawApi = getTelegramRichRawApi(api); - const richMessage = buildTelegramRichMessage(text, textMode, { - skipEntityDetection: opts.linkPreview === false, - tableMode, - }); + const richRawApi = useRichMessages ? getTelegramRichRawApi(api) : undefined; + const richMessage = useRichMessages + ? buildTelegramRichMessage(text, textMode, { + skipEntityDetection: opts.linkPreview === false, + tableMode, + }) + : undefined; // Reply markup semantics: // - buttons === undefined → don't send reply_markup (keep existing) @@ -1372,10 +1553,22 @@ export async function editMessageTelegram( const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined; const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined; - const textEditParams: Pick = {}; + const textEditParams: TelegramEditMessageTextParams = { + parse_mode: "HTML", + }; + if (opts.linkPreview === false) { + textEditParams.link_preview_options = { is_disabled: true }; + } if (replyMarkup !== undefined) { textEditParams.reply_markup = replyMarkup; } + const plainTextParams: TelegramEditMessageTextParams = {}; + if (opts.linkPreview === false) { + plainTextParams.link_preview_options = { is_disabled: true }; + } + if (replyMarkup !== undefined) { + plainTextParams.reply_markup = replyMarkup; + } const captionEditParams: TelegramEditMessageCaptionParams = { caption: htmlText, parse_mode: "HTML", @@ -1390,18 +1583,42 @@ export async function editMessageTelegram( plainCaptionParams.reply_markup = replyMarkup; } - const performTextEdit = () => - requestWithEditShouldLog( - () => - richRawApi.editMessageText({ - chat_id: chatId, - message_id: messageId, - rich_message: richMessage, - ...textEditParams, - }), - "editMessage", - (err) => !isTelegramMessageNotModifiedError(err), - ); + const performTextEdit = () => { + if (richRawApi && richMessage) { + const richEditParams: Pick = + replyMarkup === undefined ? {} : { reply_markup: replyMarkup }; + return requestWithEditShouldLog( + () => + richRawApi.editMessageText({ + chat_id: chatId, + message_id: messageId, + rich_message: richMessage, + ...richEditParams, + }), + "editMessage", + (err) => !isTelegramMessageNotModifiedError(err), + ); + } + return withTelegramHtmlParseFallback({ + label: "editMessage", + verbose: opts.verbose, + requestHtml: (retryLabel) => + requestWithEditShouldLog( + () => api.editMessageText(chatId, messageId, htmlText, textEditParams), + retryLabel, + (err) => !isTelegramMessageNotModifiedError(err), + ), + requestPlain: (retryLabel) => + requestWithEditShouldLog( + () => + Object.keys(plainTextParams).length > 0 + ? api.editMessageText(chatId, messageId, plainText, plainTextParams) + : api.editMessageText(chatId, messageId, plainText), + retryLabel, + (plainErr) => !isTelegramMessageNotModifiedError(plainErr), + ), + }); + }; const performCaptionEdit = () => withTelegramHtmlParseFallback({ diff --git a/extensions/telegram/src/telegram-outbound.test.ts b/extensions/telegram/src/telegram-outbound.test.ts index 80f78978d4b9..df0c7360378b 100644 --- a/extensions/telegram/src/telegram-outbound.test.ts +++ b/extensions/telegram/src/telegram-outbound.test.ts @@ -1,5 +1,5 @@ -// Telegram tests cover telegram outbound plugin behavior. import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking"; +// Telegram tests cover telegram outbound plugin behavior. import { describe, expect, it } from "vitest"; import { splitTelegramHtmlChunks } from "./format.js"; import { telegramOutbound } from "./outbound-adapter.js"; @@ -19,13 +19,13 @@ describe("telegramPlugin outbound", () => { it("uses static outbound contract when Telegram runtime is uninitialized", () => { clearTelegramRuntime(); const text = `${"hello\n".repeat(1200)}tail`; - const expected = chunkMarkdownTextWithMode(text, 32_768, "length"); + const expected = chunkMarkdownTextWithMode(text, 4000, "length"); - expect(telegramOutbound.chunker?.(text, 32_768)).toEqual(expected); + expect(telegramOutbound.chunker?.(text, 4000)).toEqual(expected); expect(telegramOutbound.deliveryMode).toBe("direct"); expect(telegramOutbound.chunkerMode).toBe("markdown"); expect(telegramOutbound.chunkedTextFormatting).toBeUndefined(); - expect(telegramOutbound.textChunkLimit).toBe(32_768); + expect(telegramOutbound.textChunkLimit).toBe(4000); expect(telegramOutbound.presentationCapabilities?.limits?.text?.markdownDialect).toBe( "markdown", ); @@ -43,7 +43,7 @@ describe("telegramPlugin outbound", () => { expect(telegramOutbound.chunker?.(text, 4000)).toEqual([text]); }); - it("keeps markdown tables intact for rich message parsing", () => { + it("preserves markdown tables for the configured delivery renderer", () => { clearTelegramRuntime(); const text = ["| Name | Value |", "|------|-------|", "| A | 1 |"].join("\n"); @@ -54,63 +54,66 @@ describe("telegramPlugin outbound", () => { expect(chunks).toEqual([text]); }); - it("keeps wide markdown tables for rich HTML rendering", () => { + it("keeps wide markdown tables as visible text in the HTML text path", () => { clearTelegramRuntime(); const text = markdownTable(21); - const chunks = telegramOutbound.chunker?.(text, 32_768); + const chunks = telegramOutbound.chunker?.(text, 4000); - expect(chunks).toEqual([text]); + expect(chunks).toHaveLength(1); + expect(chunks?.[0]).toContain("| H21 |"); + expect(chunks?.[0]).toContain("| 1 | 2 | 3 |"); }); - it("keeps fenced and unfenced wide markdown tables for rich HTML rendering", () => { + it("preserves both fenced and unfenced wide tables as visible text", () => { clearTelegramRuntime(); const fencedTable = markdownTable(25); const outsideTable = markdownTable(21); const text = ["Before", "~~~", fencedTable, "~~~", "After", outsideTable].join("\n"); - const chunks = telegramOutbound.chunker?.(text, 32_768); + const chunks = telegramOutbound.chunker?.(text, 4000); - expect(chunks).toEqual([text]); + expect(chunks).toHaveLength(1); + expect(chunks?.[0]).toContain("Before"); + expect(chunks?.[0]).toContain("After"); + expect(chunks?.[0]).toContain(fencedTable); + expect(chunks?.[0]).toContain(outsideTable); }); - it("chunks rich markdown by Telegram's block limit", () => { + it("chunks long markdown paragraphs by the Telegram text-message limit", () => { clearTelegramRuntime(); const text = Array.from({ length: 900 }, (_, index) => `Paragraph ${index + 1}`).join("\n\n"); - const chunks = telegramOutbound.chunker?.(text, 32_768); + const chunks = telegramOutbound.chunker?.(text, 4000); - expect(chunks).toHaveLength(2); - expect( - chunks?.every( - (chunk) => chunk.split(/\n[\t ]*\n+/).filter((block) => block.trim()).length <= 500, - ), - ).toBe(true); - expect(chunks?.join("\n\n")).toBe(text); + expect((chunks?.length ?? 0) > 1).toBe(true); + expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true); + expect(chunks?.join("")).toContain("Paragraph 900"); }); - it("chunks rich markdown headings by Telegram's block limit", () => { + it("chunks long markdown headings by the Telegram text-message limit", () => { clearTelegramRuntime(); const text = Array.from({ length: 600 }, (_, index) => `# Heading ${index + 1}`).join("\n"); - const chunks = telegramOutbound.chunker?.(text, 32_768); + const chunks = telegramOutbound.chunker?.(text, 4000); - expect(chunks).toHaveLength(2); - expect(chunks?.at(0)?.match(/^# /gm)).toHaveLength(500); - expect(chunks?.at(1)?.match(/^# /gm)).toHaveLength(100); - expect(chunks?.join("\n")).toBe(text); + expect((chunks?.length ?? 0) > 1).toBe(true); + expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true); + expect(chunks?.join("")).toContain("Heading 600"); }); - it("keeps long rich markdown lists intact", () => { + it("chunks long markdown lists by the Telegram text-message limit", () => { clearTelegramRuntime(); const text = Array.from({ length: 600 }, (_, index) => `- Item ${index + 1}`).join("\n"); - const chunks = telegramOutbound.chunker?.(text, 32_768); + const chunks = telegramOutbound.chunker?.(text, 4000); - expect(chunks).toEqual([text]); + expect((chunks?.length ?? 0) > 1).toBe(true); + expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true); + expect(chunks?.join("")).toContain("Item 600"); }); - it("keeps tall rich markdown tables intact", () => { + it("chunks tall markdown tables by the Telegram text-message limit", () => { clearTelegramRuntime(); const text = [ "| Name | Value |", @@ -118,8 +121,10 @@ describe("telegramPlugin outbound", () => { ...Array.from({ length: 600 }, (_, index) => `| Row ${index + 1} | ${index + 1} |`), ].join("\n"); - const chunks = telegramOutbound.chunker?.(text, 32_768); + const chunks = telegramOutbound.chunker?.(text, 4000); - expect(chunks).toEqual([text]); + expect((chunks?.length ?? 0) > 1).toBe(true); + expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true); + expect(chunks?.join("")).toContain("Row 600"); }); }); diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 53ee8fa21cba..be17fdec728f 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -28,11 +28,11 @@ const RAW_BUNDLED_CHANNEL_CONFIG_METADATA = [ 'an"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"historyLimit":{"type":"number"},"name":{"type":"string"},"prompt":{"type":"string"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device; more setup (David Reagans: \\"Hop on Discord.\\").","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"configPath":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"apiMode":{"type":"string","enum":["auto","native","container"]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"configPath":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state."},"configPath":{"label":"Signal CLI Config Path","help":"Optional directory passed to signal-cli via --config when the service needs a non-default signal-cli data path."}}},{"pluginId":"slack","channelId":"slack","channelEnvVars":["SLACK_APP_TOKEN","SLACK_BOT_TOKEN","SLACK_USER_TOKEN"],"label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"mode":{"default":"socket","type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,', '127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"mode":{"type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":', '[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["mode","webhookPath","userTokenReadOnly","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"dm.policy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"] (legacy: channels.slack.dm.allowFrom)."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Slack Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Slack channel IDs. Native Slack @mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Slack Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Slack Mention Pattern Allowlist","help":"Slack channel IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Slack Mention Pattern Denylist","help":"Slack channel IDs where configured regex mention patterns are disabled. Native @mentions still trigger."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"botLoopProtection":{"label":"Slack Bot Loop Protection","help":"Sliding-window guard for Slack bot-to-bot loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch."},"botLoopProtection.enabled":{"label":"Slack Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Slack Bot Loop Events per Window","help":"Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Slack Bot Loop Window Seconds","help":"Sliding window length for counting bot-pair messages. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Slack Bot Loop Cooldown Seconds","help":"How long to suppress the bot pair after it exceeds the budget. Default: 60."},"socketMode":{"label":"Slack Socket Mode Transport","help":"Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior."},"socketMode.clientPingTimeout":{"label":"Slack Socket Mode Pong Timeout","help":"Milliseconds the Slack SDK waits for a pong after its client ping before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling."},"socketMode.serverPingTimeout":{"label":"Slack Socket Mode Server Ping Timeout","help":"Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale."},"socketMode.pingPongLoggingEnabled":{"label":"Slack Socket Mode Ping/Pong Logging","help":"Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"capabilities.interactiveReplies":{"label":"Slack Interactive Replies","help":"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Slack Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.nativeTaskCards":{"label":"Slack Native Progress Task Cards","help":"Opt in to Slack native task-card progress updates when channels.slack.streaming.mode=\\"progress\\" and streaming.nativeTransport is enabled. Default: false."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."},"thread.requireExplicitMention":{"label":"Slack Thread Require Explicit Mention","help":"If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false)."}}},{"pluginId":"sms","channelId":"sms","order":88,"channelEnvVars":["SMS_ALLOWED_USERS","SMS_PUBLIC_WEBHOOK_URL","SMS_WEBHOOK_PATH","TWILIO_ACCOUNT_SID","TWILIO_AUTH_TOKEN","TWILIO_MESSAGING_SERVICE_SID","TWILIO_PHONE_NUMBER","TWILIO_SMS_FROM"],"label":"SMS","description":"Twilio-backed SMS with inbound webhooks and outbound replies.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"nam', - 'e":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["dmPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"SMS","help":"Twilio SMS channel configuration for inbound webhooks and outbound text replies."},"accountSid":{"label":"Twilio Account SID","help":"Twilio Account SID used for SMS outbound API calls."},"authToken":{"label":"Twilio Auth Token","help":"Twilio Auth Token used to sign webhook validation and SMS outbound API calls."},"fromNumber":{"label":"SMS From Number","help":"Twilio SMS-capable phone number in E.164 format, for example +15551234567."},"messagingServiceSid":{"label":"Twilio Messaging Service SID","help":"Twilio Messaging Service SID to use instead of a dedicated fromNumber."},"defaultTo":{"label":"SMS Default To Number","help":"Optional default outbound phone number used when a send flow omits an explicit SMS target."},"publicWebhookUrl":{"label":"SMS Public Webhook URL","help":"Public URL configured in Twilio for incoming messages. Must match Twilio\'s signed URL exactly."},"webhookPath":{"label":"SMS Webhook Path","help":"Gateway HTTP path that receives Twilio incoming-message webhooks. Use a distinct path per account."},"dmPolicy":{"label":"SMS DM Policy","help":"Direct SMS access control (\\"pairing\\" recommended). \\"open\\" requires channels.sms.allowFrom=[\\"*\\"]."},"allowFrom":{"label":"SMS Allow From","help":"Allowed sender phone numbers in E.164 format, or * when dmPolicy is open."},"textChunkLimit":{"label":"SMS Text Chunk Limit","help":"Maximum characters per outbound SMS chunk before OpenClaw splits long replies."}}},{"pluginId":"synology-chat","channelId":"synology-chat","order":90,"channelEnvVars":["OPENCLAW_BOT_NAME","SYNOLOGY_ALLOWED_USER_IDS","SYNOLOGY_CHAT_INCOMING_URL","SYNOLOGY_CHAT_TOKEN","SYNOLOGY_NAS_HOST","SYNOLOGY_RATE_LIMIT"],"label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","channelEnvVars":["TELEGRAM_BOT_TOKEN"],"label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"includeGroupHistoryContext":{"type":"string","enum":["none","mention-only","recent"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties', - '":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"includeGroupHistoryContext":{"type":"string","enum":["none","mention-only","recent"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"T', - 'elegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Telegram Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Telegram group chat IDs or chatId:topic:threadId topic IDs. Native Telegram bot mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Telegram Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Telegram Mention Pattern Allowlist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Telegram Mention Pattern Denylist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are disabled. Native bot mentions still trigger."},"includeGroupHistoryContext":{"label":"Telegram Group History Context","help":"Controls prior Telegram group messages included in model context: \\"mention-only\\" keeps messages addressed to the bot and bot replies (default), \\"recent\\" includes recent room history, and \\"none\\" disables group history context."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Telegram Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.commentary":{"label":"Telegram Progress Commentary","help":"Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged."},"retry.attempts":{"label":"Telegram Retry Attempts","help":"Max retry attempts for outbound Telegram API calls (default: 3)."},"retry.minDelayMs":{"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls."},"retry.maxDelayMs":{"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls."},"retry.jitter":{"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"timeoutSeconds":{"label":"Telegram API Timeout (seconds)","help":"Max seconds before Telegram API requests are aborted (default: 500 per grammY)."},"mediaGroupFlushMs":{"label":"Telegram Media Group Flush (ms)","help":"Milliseconds to buffer Telegram albums/media groups before dispatching them as one inbound message. Default: 500."},"pollingStallThresholdMs":{"label":"Telegram Polling Stall Threshold (ms)","help":"Milliseconds without completed Telegram getUpdates liveness before the polling watchdog restarts the polling runner. Default: 120000."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths inside these roots are read directly; all other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","order":90,"label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false}},{"pluginId":"twitch","channelId":"twitch","aliases":["twitch-chat"],"channelEnvVars":["OPENCLAW_TWITCH_ACCESS_TOKEN"],"label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":', - '{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","debounceMs","mediaMaxMb"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"WhatsApp Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected WhatsApp conversation IDs such as 123@g.us."},"mentionPatterns.mode":{"label":"WhatsApp Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"WhatsApp Mention Pattern Allowlist","help":"WhatsApp conversation IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"WhatsApp Mention Pattern Denylist","help":"WhatsApp conversation IDs where configured regex mention patterns are disabled."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","aliases":["zl"],"order":80,"channelEnvVars":["ZALO_BOT_TOKEN","ZALO_WEBHOOK_SECRET"],"label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","aliases":["zlu"],"order":85,"channelEnvVars":["ZALOUSER_PROFILE","ZCA_PROFILE"],"label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile"', - ':{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', + 'e":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["dmPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"SMS","help":"Twilio SMS channel configuration for inbound webhooks and outbound text replies."},"accountSid":{"label":"Twilio Account SID","help":"Twilio Account SID used for SMS outbound API calls."},"authToken":{"label":"Twilio Auth Token","help":"Twilio Auth Token used to sign webhook validation and SMS outbound API calls."},"fromNumber":{"label":"SMS From Number","help":"Twilio SMS-capable phone number in E.164 format, for example +15551234567."},"messagingServiceSid":{"label":"Twilio Messaging Service SID","help":"Twilio Messaging Service SID to use instead of a dedicated fromNumber."},"defaultTo":{"label":"SMS Default To Number","help":"Optional default outbound phone number used when a send flow omits an explicit SMS target."},"publicWebhookUrl":{"label":"SMS Public Webhook URL","help":"Public URL configured in Twilio for incoming messages. Must match Twilio\'s signed URL exactly."},"webhookPath":{"label":"SMS Webhook Path","help":"Gateway HTTP path that receives Twilio incoming-message webhooks. Use a distinct path per account."},"dmPolicy":{"label":"SMS DM Policy","help":"Direct SMS access control (\\"pairing\\" recommended). \\"open\\" requires channels.sms.allowFrom=[\\"*\\"]."},"allowFrom":{"label":"SMS Allow From","help":"Allowed sender phone numbers in E.164 format, or * when dmPolicy is open."},"textChunkLimit":{"label":"SMS Text Chunk Limit","help":"Maximum characters per outbound SMS chunk before OpenClaw splits long replies."}}},{"pluginId":"synology-chat","channelId":"synology-chat","order":90,"channelEnvVars":["OPENCLAW_BOT_NAME","SYNOLOGY_ALLOWED_USER_IDS","SYNOLOGY_CHAT_INCOMING_URL","SYNOLOGY_CHAT_TOKEN","SYNOLOGY_NAS_HOST","SYNOLOGY_RATE_LIMIT"],"label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","channelEnvVars":["TELEGRAM_BOT_TOKEN"],"label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"includeGroupHistoryContext":{"type":"string","enum":["none","mention-only","recent"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"richMessages":{"type":"boolean"},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type"', + ':"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"includeGroupHistoryContext":{"type":"string","enum":["none","mention-only","recent"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"richMessages":{"type":"boolean"},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conf', + 'licts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Telegram Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Telegram group chat IDs or chatId:topic:threadId topic IDs. Native Telegram bot mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Telegram Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Telegram Mention Pattern Allowlist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Telegram Mention Pattern Denylist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are disabled. Native bot mentions still trigger."},"includeGroupHistoryContext":{"label":"Telegram Group History Context","help":"Controls prior Telegram group messages included in model context: \\"mention-only\\" keeps messages addressed to the bot and bot replies (default), \\"recent\\" includes recent room history, and \\"none\\" disables group history context."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"richMessages":{"label":"Telegram Rich Messages","help":"Opt into Bot API 10.1 rich text sends and edits, including native tables and rich media. Default: false because some current Telegram clients render these messages as unsupported."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Telegram Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.commentary":{"label":"Telegram Progress Commentary","help":"Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged."},"retry.attempts":{"label":"Telegram Retry Attempts","help":"Max retry attempts for outbound Telegram API calls (default: 3)."},"retry.minDelayMs":{"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls."},"retry.maxDelayMs":{"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls."},"retry.jitter":{"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"timeoutSeconds":{"label":"Telegram API Timeout (seconds)","help":"Max seconds before Telegram API requests are aborted (default: 500 per grammY)."},"mediaGroupFlushMs":{"label":"Telegram Media Group Flush (ms)","help":"Milliseconds to buffer Telegram albums/media groups before dispatching them as one inbound message. Default: 500."},"pollingStallThresholdMs":{"label":"Telegram Polling Stall Threshold (ms)","help":"Milliseconds without completed Telegram getUpdates liveness before the polling watchdog restarts the polling runner. Default: 120000."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths inside these roots are read directly; all other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","order":90,"label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false}},{"pluginId":"twitch","channelId":"twitch","aliases":["twitch-chat"],"channelEnvVars":["OPENCLAW_TWITCH_ACCESS_TOKEN"],"label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities"', + ':{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","debounceMs","mediaMaxMb"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"WhatsApp Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected WhatsApp conversation IDs such as 123@g.us."},"mentionPatterns.mode":{"label":"WhatsApp Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"WhatsApp Mention Pattern Allowlist","help":"WhatsApp conversation IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"WhatsApp Mention Pattern Denylist","help":"WhatsApp conversation IDs where configured regex mention patterns are disabled."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","aliases":["zl"],"order":80,"channelEnvVars":["ZALO_BOT_TOKEN","ZALO_WEBHOOK_SECRET"],"label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","aliases":["zlu"],"order":85,"channelEnvVars":["ZALOUSER_PROFILE","ZCA_PROFILE"],"label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"str', + 'ing"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', ].join(""); export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = JSON.parse( diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 3120c347c40b..b1d9599d867a 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -165,6 +165,11 @@ export type TelegramAccountConfig = { dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** + * Use Telegram Bot API 10.1 rich messages for text sends and edits. + * Default: false until Telegram clients render rich messages consistently. + */ + richMessages?: boolean; /** Streaming + chunking settings. Prefer this nested shape over legacy flat keys. */ streaming?: TelegramPreviewStreamingConfig; mediaMaxMb?: number; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index a434d62abc2c..07e1a88227fd 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -281,6 +281,7 @@ export const TelegramAccountSchemaBase = z dms: z.record(z.string(), DmConfigSchema.optional()).optional(), direct: z.record(z.string(), TelegramDirectSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), + richMessages: z.boolean().optional(), streaming: TelegramPreviewStreamingConfigSchema.optional(), mediaMaxMb: z.number().positive().optional(), timeoutSeconds: z.number().int().positive().optional(),