fix(telegram): expose media group flush config

This commit is contained in:
Vincent Koc
2026-05-03 12:05:39 -07:00
parent 52257fd05e
commit c5488ea577
10 changed files with 123 additions and 5 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -729,6 +729,7 @@ curl "https://api.telegram.org/bot<bot_token>/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<TOKEN>`)
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
- actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker`

View File

@@ -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<string, MediaGroupEntry>();
let mediaGroupProcessing: Promise<void> = Promise.resolve();

View File

@@ -45,9 +45,18 @@ function getChannelPostHandler() {
return getOnHandler("channel_post") as (ctx: Record<string, unknown>) => Promise<void>;
}
function getChannelPostHandlerWithRuntimeTimings() {
createTelegramBot({ token: "tok" });
return getOnHandler("channel_post") as (ctx: Record<string, unknown>) => Promise<void>;
}
function resolveFlushTimer(setTimeoutSpy: ReturnType<typeof vi.spyOn>) {
return resolveFlushTimerForDelay(setTimeoutSpy, TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs);
}
function resolveFlushTimerForDelay(setTimeoutSpy: ReturnType<typeof vi.spyOn>, delayMs: number) {
const flushTimerCallIndex = setTimeoutSpy.mock.calls.findLastIndex(
(call: Parameters<typeof setTimeout>) => call[1] === TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs,
(call: Parameters<typeof setTimeout>) => call[1] === delayMs,
);
const flushTimer =
flushTimerCallIndex >= 0
@@ -104,6 +113,15 @@ async function flushChannelPostMediaGroup(setTimeoutSpy: ReturnType<typeof vi.sp
await flushTimer?.();
}
async function flushChannelPostMediaGroupForDelay(
setTimeoutSpy: ReturnType<typeof vi.spyOn>,
delayMs: number,
) {
const flushTimer = resolveFlushTimerForDelay(setTimeoutSpy, delayMs);
expect(flushTimer).toBeTypeOf("function");
await flushTimer?.();
}
async function queueChannelPostAlbum(
handler: ReturnType<typeof getChannelPostHandler>,
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();

View File

@@ -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" },

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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. */

View File

@@ -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