From c5488ea5774a4636fc50c47575ce122a317fc52b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 12:05:39 -0700 Subject: [PATCH] fix(telegram): expose media group flush config --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- docs/channels/telegram.md | 3 +- .../telegram/src/bot-handlers.runtime.ts | 5 +- ...te-telegram-bot.channel-post-media.test.ts | 58 ++++++++++++++++++- extensions/telegram/src/config-schema.test.ts | 18 ++++++ extensions/telegram/src/config-ui-hints.ts | 4 ++ ...ndled-channel-config-metadata.generated.ts | 24 ++++++++ src/config/types.telegram.ts | 2 + src/config/zod-schema.providers-core.ts | 9 +++ 10 files changed, 123 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ccab638189..fff7799af26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/usage: serve `usage.cost` and `sessions.usage` from a durable transcript aggregate cache with lock-safe background refreshes and localized stale-cache status, so large usage views avoid repeated full scans. (#76650) Thanks @Marvinthebored. +- Telegram: add `channels.telegram.mediaGroupFlushMs` at the top level and per account so operators can tune album buffering instead of being stuck with the hard-coded 500ms media-group flush window. Fixes #76149. Thanks @vincentkoc. - Config/messages: coerce boolean `messages.visibleReplies` and `messages.groupChat.visibleReplies` values to the documented enum modes so an intuitive toggle no longer invalidates config and drops channel startup. Fixes #75390. Thanks @scottgl9. - Feishu: accept and honor `channels.feishu.blockStreaming` at the top level and per account, while keeping the legacy default off so Feishu cards no longer reject documented config or silently drop block replies. Fixes #75555. Thanks @vincentkoc. - Google Chat: normalize custom Google auth transport headers before google-auth/gaxios interceptors run, restoring webhook token verification when certificate retrieval expects Fetch `Headers`. Fixes #76742. Thanks @donbowman. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 3f928e64247..81a54e3c04b 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -c1de046645b03b1ec47ec41811b67c0e7ad5460842b54416a47757ef22b9b17e config-baseline.json +df881d10bfb3d1ba0439e5984117dde70b5f7e856696f25c7f4b5c978a38f841 config-baseline.json f945a060012b3e7c675fb3ea0c5f18996cdcc06c9ec6cead389e04791a529ce9 config-baseline.core.json -76979aba007500abc52b970da76b6512291916739c29d6a3f4218772d1a31186 config-baseline.channel.json +09a952cf734a5b4a30f760e570c0f106d54aa8e74bf439dd4d07013f9f7607e4 config-baseline.channel.json 245aa98aabc6c2e3c57a69e639c2fb10d84a7e1e1b3bcdadc340fa61ca998287 config-baseline.plugin.json diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 551e49e83c7..c32cb4159dc 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -729,6 +729,7 @@ curl "https://api.telegram.org/bot/getUpdates" - `channels.telegram.textChunkLimit` default is 4000. - `channels.telegram.chunkMode="newline"` prefers paragraph boundaries (blank lines) before length splitting. - `channels.telegram.mediaMaxMb` (default 100) caps inbound and outbound Telegram media size. + - `channels.telegram.mediaGroupFlushMs` (default 500) controls how long Telegram albums/media groups are buffered before OpenClaw dispatches them as one inbound message. Increase it if album parts arrive late; decrease it to reduce album reply latency. - `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies). Bot clients clamp configured values below the 60-second outbound text/typing request guard so grammY does not abort visible reply delivery before OpenClaw's transport guard and fallback can run. Long polling still uses a 45-second `getUpdates` request guard so idle polls are not abandoned indefinitely. - `channels.telegram.pollingStallThresholdMs` defaults to `120000`; tune between `30000` and `600000` only for false-positive polling-stall restarts. - group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables. @@ -950,7 +951,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels - threading/replies: `replyToMode`, `dm.threadReplies`, `direct.*.threadReplies` - streaming: `streaming` (preview), `streaming.preview.toolProgress`, `blockStreaming` - formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix` -- media/network: `mediaMaxMb`, `timeoutSeconds`, `pollingStallThresholdMs`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy` +- 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` - actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker` diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index a9187e8056a..00be09bef1b 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -148,7 +148,10 @@ export const registerTelegramHandlers = ({ typeof opts.testTimings?.mediaGroupFlushMs === "number" && Number.isFinite(opts.testTimings.mediaGroupFlushMs) ? Math.max(10, Math.floor(opts.testTimings.mediaGroupFlushMs)) - : MEDIA_GROUP_TIMEOUT_MS; + : typeof telegramCfg.mediaGroupFlushMs === "number" && + Number.isFinite(telegramCfg.mediaGroupFlushMs) + ? Math.max(10, Math.floor(telegramCfg.mediaGroupFlushMs)) + : MEDIA_GROUP_TIMEOUT_MS; const mediaGroupBuffer = new Map(); let mediaGroupProcessing: Promise = Promise.resolve(); diff --git a/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts b/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts index 41b9113215f..56db3a95c4d 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts @@ -45,9 +45,18 @@ function getChannelPostHandler() { return getOnHandler("channel_post") as (ctx: Record) => Promise; } +function getChannelPostHandlerWithRuntimeTimings() { + createTelegramBot({ token: "tok" }); + return getOnHandler("channel_post") as (ctx: Record) => Promise; +} + function resolveFlushTimer(setTimeoutSpy: ReturnType) { + return resolveFlushTimerForDelay(setTimeoutSpy, TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs); +} + +function resolveFlushTimerForDelay(setTimeoutSpy: ReturnType, delayMs: number) { const flushTimerCallIndex = setTimeoutSpy.mock.calls.findLastIndex( - (call: Parameters) => call[1] === TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs, + (call: Parameters) => call[1] === delayMs, ); const flushTimer = flushTimerCallIndex >= 0 @@ -104,6 +113,15 @@ async function flushChannelPostMediaGroup(setTimeoutSpy: ReturnType, + delayMs: number, +) { + const flushTimer = resolveFlushTimerForDelay(setTimeoutSpy, delayMs); + expect(flushTimer).toBeTypeOf("function"); + await flushTimer?.(); +} + async function queueChannelPostAlbum( handler: ReturnType, params: { @@ -181,6 +199,44 @@ describe("createTelegramBot channel_post media", () => { } }); + it("honors configured mediaGroupFlushMs for channel_post albums", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + mediaGroupFlushMs: 75, + groups: { + "-100777111222": { + enabled: true, + requireMention: false, + }, + }, + }, + }, + }); + + const fetchSpy = createImageFetchSpy(); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + try { + const handler = getChannelPostHandlerWithRuntimeTimings(); + await queueChannelPostAlbum(handler, { + caption: "configured album", + mediaGroupId: "channel-album-configured", + firstMessageId: 211, + secondMessageId: 212, + }); + expect(replySpy).not.toHaveBeenCalled(); + await flushChannelPostMediaGroupForDelay(setTimeoutSpy, 75); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0]?.[0] as { Body?: string }; + expect(payload.Body).toContain("configured album"); + } finally { + setTimeoutSpy.mockRestore(); + fetchSpy.mockRestore(); + } + }); + it("coalesces channel_post near-limit text fragments into one message", async () => { setOpenChannelPostConfig(); diff --git a/extensions/telegram/src/config-schema.test.ts b/extensions/telegram/src/config-schema.test.ts index c8d351a2781..2984829f98b 100644 --- a/extensions/telegram/src/config-schema.test.ts +++ b/extensions/telegram/src/config-schema.test.ts @@ -66,6 +66,24 @@ describe("telegram custom commands schema", () => { } }); + it("accepts mediaGroupFlushMs overrides per account", () => { + const res = TelegramConfigSchema.safeParse({ + mediaGroupFlushMs: 750, + accounts: { ops: { mediaGroupFlushMs: 1500 } }, + }); + + expect(res.success).toBe(true); + if (res.success) { + expect(res.data.mediaGroupFlushMs).toBe(750); + expect(res.data.accounts?.ops?.mediaGroupFlushMs).toBe(1500); + } + }); + + it("rejects mediaGroupFlushMs outside the supported flush bounds", () => { + expectTelegramConfigIssue({ mediaGroupFlushMs: 9 }, "mediaGroupFlushMs"); + expectTelegramConfigIssue({ mediaGroupFlushMs: 60_001 }, "mediaGroupFlushMs"); + }); + it("accepts DM thread reply policy overrides", () => { const res = TelegramConfigSchema.safeParse({ dm: { threadReplies: "off" }, diff --git a/extensions/telegram/src/config-ui-hints.ts b/extensions/telegram/src/config-ui-hints.ts index cc5f4b89b43..ee38028adbb 100644 --- a/extensions/telegram/src/config-ui-hints.ts +++ b/extensions/telegram/src/config-ui-hints.ts @@ -101,6 +101,10 @@ export const telegramChannelConfigUiHints = { 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.", diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 8686901b230..0b117949d5f 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -4172,6 +4172,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "string", enum: ["length", "newline"], }, + blockStreaming: { + type: "boolean", + }, blockStreamingCoalesce: { type: "object", properties: { @@ -4800,6 +4803,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "string", enum: ["length", "newline"], }, + blockStreaming: { + type: "boolean", + }, blockStreamingCoalesce: { type: "object", properties: { @@ -14128,6 +14134,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ 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, @@ -15190,6 +15203,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ 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, @@ -15594,6 +15614,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ 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.", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 2a516893557..e96d0c82f02 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -152,6 +152,8 @@ export type TelegramAccountConfig = { mediaMaxMb?: number; /** Telegram API client timeout in seconds (grammY ApiClientOptions). */ timeoutSeconds?: number; + /** Buffer window for Telegram media groups/albums before dispatching them as one inbound message. Default: 500ms. */ + mediaGroupFlushMs?: number; /** Telegram polling watchdog threshold in milliseconds. Default: 120000. */ pollingStallThresholdMs?: number; /** Retry policy for outbound Telegram API calls. */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 5e044fb1fcb..9c29c6ae366 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -252,6 +252,15 @@ export const TelegramAccountSchemaBase = z streaming: ChannelPreviewStreamingConfigSchema.optional(), mediaMaxMb: z.number().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), + mediaGroupFlushMs: z + .number() + .int() + .min(10) + .max(60_000) + .optional() + .describe( + "Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.", + ), pollingStallThresholdMs: z.number().int().min(30_000).max(600_000).optional(), retry: RetryConfigSchema, network: z