mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:58:38 +00:00
refactor: simplify Telegram preview streaming to single boolean (#22012)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: a4017d3b94
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Security/Unused Dependencies: remove unused plugin-local `openclaw` devDependencies from `extensions/open-prose`, `extensions/lobster`, and `extensions/llm-task` after removing this dependency from build-time requirements. (#22495) Thanks @vincentkoc.
|
- Security/Unused Dependencies: remove unused plugin-local `openclaw` devDependencies from `extensions/open-prose`, `extensions/lobster`, and `extensions/llm-task` after removing this dependency from build-time requirements. (#22495) Thanks @vincentkoc.
|
||||||
- Agents/Subagents: default subagent spawn depth now uses shared `maxSpawnDepth=2`, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204.
|
- Agents/Subagents: default subagent spawn depth now uses shared `maxSpawnDepth=2`, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204.
|
||||||
- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin.
|
- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin.
|
||||||
|
- Telegram/Streaming: simplify preview streaming config to `channels.telegram.streaming` (boolean), auto-map legacy `streamMode` values, and remove block-vs-partial preview branching. (#22012) thanks @obviyus.
|
||||||
- iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.
|
- iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.
|
||||||
- iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky.
|
- iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky.
|
||||||
- iOS/Tests: cover IPv4-mapped IPv6 loopback in manual TLS policy tests for connect validation paths. (#22045) Thanks @mbelinky.
|
- iOS/Tests: cover IPv4-mapped IPv6 loopback in manual TLS policy tests for connect validation paths. (#22045) Thanks @mbelinky.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ title: grammY
|
|||||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls).
|
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls).
|
||||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`.
|
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`.
|
||||||
- **Live stream preview:** optional `channels.telegram.streamMode` sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming.
|
- **Live stream preview:** optional `channels.telegram.streaming` sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming.
|
||||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||||
|
|
||||||
Open questions
|
Open questions
|
||||||
|
|||||||
@@ -226,21 +226,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
|||||||
|
|
||||||
Requirement:
|
Requirement:
|
||||||
|
|
||||||
- `channels.telegram.streamMode` is not `"off"` (default: `"partial"`)
|
- `channels.telegram.streaming` is `true` (default)
|
||||||
|
- legacy `channels.telegram.streamMode` values are auto-mapped to `streaming`
|
||||||
Modes:
|
|
||||||
|
|
||||||
- `off`: no live preview
|
|
||||||
- `partial`: frequent preview updates from partial text
|
|
||||||
- `block`: chunked preview updates using `channels.telegram.draftChunk`
|
|
||||||
|
|
||||||
`draftChunk` defaults for `streamMode: "block"`:
|
|
||||||
|
|
||||||
- `minChars: 200`
|
|
||||||
- `maxChars: 800`
|
|
||||||
- `breakPreference: "paragraph"`
|
|
||||||
|
|
||||||
`maxChars` is clamped by `channels.telegram.textChunkLimit`.
|
|
||||||
|
|
||||||
This works in direct chats and groups/topics.
|
This works in direct chats and groups/topics.
|
||||||
|
|
||||||
@@ -248,7 +235,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
|||||||
|
|
||||||
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
|
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
|
||||||
|
|
||||||
`streamMode` is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming.
|
Preview streaming is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming.
|
||||||
|
|
||||||
Telegram-only reasoning stream:
|
Telegram-only reasoning stream:
|
||||||
|
|
||||||
@@ -721,7 +708,7 @@ Primary reference:
|
|||||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||||
- `channels.telegram.streamMode`: `off | partial | block` (live stream preview).
|
- `channels.telegram.streaming`: `true | false` (live stream preview; default: true).
|
||||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts.
|
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts.
|
||||||
@@ -745,7 +732,7 @@ Telegram-specific high-signal fields:
|
|||||||
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`
|
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`
|
||||||
- command/menu: `commands.native`, `customCommands`
|
- command/menu: `commands.native`, `customCommands`
|
||||||
- threading/replies: `replyToMode`
|
- threading/replies: `replyToMode`
|
||||||
- streaming: `streamMode` (preview), `draftChunk`, `blockStreaming`
|
- streaming: `streaming` (preview), `blockStreaming`
|
||||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
|
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
|
||||||
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy`
|
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy`
|
||||||
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
|
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ This maps to:
|
|||||||
|
|
||||||
**Channel note:** For non-Telegram channels, block streaming is **off unless**
|
**Channel note:** For non-Telegram channels, block streaming is **off unless**
|
||||||
`*.blockStreaming` is explicitly set to `true`. Telegram can stream a live preview
|
`*.blockStreaming` is explicitly set to `true`. Telegram can stream a live preview
|
||||||
(`channels.telegram.streamMode`) without block replies.
|
(`channels.telegram.streaming`) without block replies.
|
||||||
|
|
||||||
Config location reminder: the `blockStreaming*` defaults live under
|
Config location reminder: the `blockStreaming*` defaults live under
|
||||||
`agents.defaults`, not the root config.
|
`agents.defaults`, not the root config.
|
||||||
@@ -110,11 +110,7 @@ Config location reminder: the `blockStreaming*` defaults live under
|
|||||||
Telegram is the only channel with live preview streaming:
|
Telegram is the only channel with live preview streaming:
|
||||||
|
|
||||||
- Uses Bot API `sendMessage` (first update) + `editMessageText` (subsequent updates).
|
- Uses Bot API `sendMessage` (first update) + `editMessageText` (subsequent updates).
|
||||||
- `channels.telegram.streamMode: "partial" | "block" | "off"`.
|
- `channels.telegram.streaming: true | false` (default: `true`).
|
||||||
- `partial`: preview updates with latest stream text.
|
|
||||||
- `block`: preview updates in chunked blocks (same chunker rules).
|
|
||||||
- `off`: no preview streaming.
|
|
||||||
- Preview chunk config (only for `streamMode: "block"`): `channels.telegram.draftChunk` (defaults: `minChars: 200`, `maxChars: 800`).
|
|
||||||
- Preview streaming is separate from block streaming.
|
- Preview streaming is separate from block streaming.
|
||||||
- When Telegram block streaming is explicitly enabled, preview streaming is skipped to avoid double-streaming.
|
- When Telegram block streaming is explicitly enabled, preview streaming is skipped to avoid double-streaming.
|
||||||
- Text-only finals are applied by editing the preview message in place.
|
- Text-only finals are applied by editing the preview message in place.
|
||||||
@@ -124,8 +120,7 @@ Telegram is the only channel with live preview streaming:
|
|||||||
```
|
```
|
||||||
Telegram
|
Telegram
|
||||||
└─ sendMessage (temporary preview message)
|
└─ sendMessage (temporary preview message)
|
||||||
├─ streamMode=partial → edit latest text
|
└─ streaming=true → edit latest text
|
||||||
└─ streamMode=block → chunker + edit updates
|
|
||||||
└─ final text-only reply → final edit on same message
|
└─ final text-only reply → final edit on same message
|
||||||
└─ fallback: cleanup preview + normal final delivery (media/complex)
|
└─ fallback: cleanup preview + normal final delivery (media/complex)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -151,12 +151,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
|||||||
historyLimit: 50,
|
historyLimit: 50,
|
||||||
replyToMode: "first", // off | first | all
|
replyToMode: "first", // off | first | all
|
||||||
linkPreview: true,
|
linkPreview: true,
|
||||||
streamMode: "partial", // off | partial | block
|
streaming: true, // live preview on/off (default true)
|
||||||
draftChunk: {
|
|
||||||
minChars: 200,
|
|
||||||
maxChars: 800,
|
|
||||||
breakPreference: "paragraph", // paragraph | newline | sentence
|
|
||||||
},
|
|
||||||
actions: { reactions: true, sendMessage: true },
|
actions: { reactions: true, sendMessage: true },
|
||||||
reactionNotifications: "own", // off | own | all
|
reactionNotifications: "own", // off | own | all
|
||||||
mediaMaxMb: 5,
|
mediaMaxMb: 5,
|
||||||
|
|||||||
@@ -378,11 +378,46 @@ describe("legacy config detection", () => {
|
|||||||
expect(res.config.channels?.telegram?.groupPolicy).toBe("allowlist");
|
expect(res.config.channels?.telegram?.groupPolicy).toBe("allowlist");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
it("defaults telegram.streamMode to partial when telegram section exists", async () => {
|
it("defaults telegram.streaming to true when telegram section exists", async () => {
|
||||||
const res = validateConfigObject({ channels: { telegram: {} } });
|
const res = validateConfigObject({ channels: { telegram: {} } });
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
expect(res.config.channels?.telegram?.streamMode).toBe("partial");
|
expect(res.config.channels?.telegram?.streaming).toBe(true);
|
||||||
|
expect(res.config.channels?.telegram?.streamMode).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("migrates legacy telegram.streamMode=off to streaming=false", async () => {
|
||||||
|
const res = validateConfigObject({ channels: { telegram: { streamMode: "off" } } });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (res.ok) {
|
||||||
|
expect(res.config.channels?.telegram?.streaming).toBe(false);
|
||||||
|
expect(res.config.channels?.telegram?.streamMode).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("migrates legacy telegram.streamMode=block to streaming=true", async () => {
|
||||||
|
const res = validateConfigObject({ channels: { telegram: { streamMode: "block" } } });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (res.ok) {
|
||||||
|
expect(res.config.channels?.telegram?.streaming).toBe(true);
|
||||||
|
expect(res.config.channels?.telegram?.streamMode).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("migrates legacy telegram.accounts.*.streamMode to streaming", async () => {
|
||||||
|
const res = validateConfigObject({
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
accounts: {
|
||||||
|
ops: {
|
||||||
|
streamMode: "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (res.ok) {
|
||||||
|
expect(res.config.channels?.telegram?.accounts?.ops?.streaming).toBe(false);
|
||||||
|
expect(res.config.channels?.telegram?.accounts?.ops?.streamMode).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => {
|
it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => {
|
||||||
|
|||||||
@@ -391,14 +391,8 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).",
|
"Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).",
|
||||||
"channels.telegram.dmPolicy":
|
"channels.telegram.dmPolicy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].',
|
||||||
"channels.telegram.streamMode":
|
"channels.telegram.streaming":
|
||||||
"Live stream preview mode for Telegram replies (off | partial | block). Separate from block streaming; uses sendMessage + editMessageText.",
|
"Enable Telegram live stream preview via message edits (default: true; legacy streamMode auto-maps here).",
|
||||||
"channels.telegram.draftChunk.minChars":
|
|
||||||
'Minimum chars before emitting a Telegram stream preview update when channels.telegram.streamMode="block" (default: 200).',
|
|
||||||
"channels.telegram.draftChunk.maxChars":
|
|
||||||
'Target max size for a Telegram stream preview chunk when channels.telegram.streamMode="block" (default: 800; clamped to channels.telegram.textChunkLimit).',
|
|
||||||
"channels.telegram.draftChunk.breakPreference":
|
|
||||||
"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.",
|
|
||||||
"channels.discord.streamMode":
|
"channels.discord.streamMode":
|
||||||
"Live stream preview mode for Discord replies (off | partial | block). Separate from block streaming; uses sendMessage + editMessage.",
|
"Live stream preview mode for Discord replies (off | partial | block). Separate from block streaming; uses sendMessage + editMessage.",
|
||||||
"channels.discord.draftChunk.minChars":
|
"channels.discord.draftChunk.minChars":
|
||||||
|
|||||||
@@ -263,10 +263,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
...IRC_FIELD_LABELS,
|
...IRC_FIELD_LABELS,
|
||||||
"channels.telegram.botToken": "Telegram Bot Token",
|
"channels.telegram.botToken": "Telegram Bot Token",
|
||||||
"channels.telegram.dmPolicy": "Telegram DM Policy",
|
"channels.telegram.dmPolicy": "Telegram DM Policy",
|
||||||
"channels.telegram.streamMode": "Telegram Stream Mode",
|
"channels.telegram.streaming": "Telegram Streaming",
|
||||||
"channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars",
|
|
||||||
"channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars",
|
|
||||||
"channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference",
|
|
||||||
"channels.telegram.retry.attempts": "Telegram Retry Attempts",
|
"channels.telegram.retry.attempts": "Telegram Retry Attempts",
|
||||||
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
|
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
|
||||||
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
||||||
|
|||||||
@@ -95,13 +95,15 @@ export type TelegramAccountConfig = {
|
|||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
chunkMode?: "length" | "newline";
|
chunkMode?: "length" | "newline";
|
||||||
|
/** Enable live stream preview via message edits (default: true). */
|
||||||
|
streaming?: boolean;
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Chunking config for Telegram stream previews in `streamMode: "block"`. */
|
/** @deprecated Legacy chunking config from `streamMode: "block"`; ignored after migration. */
|
||||||
draftChunk?: BlockStreamingChunkConfig;
|
draftChunk?: BlockStreamingChunkConfig;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
/** Telegram stream preview mode (off|partial|block). Default: partial. */
|
/** @deprecated Legacy key; migrated automatically to `streaming` boolean. */
|
||||||
streamMode?: "off" | "partial" | "block";
|
streamMode?: "off" | "partial" | "block";
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
/** Telegram API client timeout in seconds (grammY ApiClientOptions). */
|
/** Telegram API client timeout in seconds (grammY ApiClientOptions). */
|
||||||
|
|||||||
@@ -99,6 +99,27 @@ const validateTelegramCustomCommands = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeTelegramStreamingConfig(value: {
|
||||||
|
streaming?: boolean;
|
||||||
|
streamMode?: "off" | "partial" | "block";
|
||||||
|
}) {
|
||||||
|
if (typeof value.streaming === "boolean") {
|
||||||
|
delete value.streamMode;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.streamMode === "off") {
|
||||||
|
value.streaming = false;
|
||||||
|
delete value.streamMode;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.streamMode === "partial" || value.streamMode === "block") {
|
||||||
|
value.streaming = true;
|
||||||
|
delete value.streamMode;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
value.streaming = true;
|
||||||
|
}
|
||||||
|
|
||||||
export const TelegramAccountSchemaBase = z
|
export const TelegramAccountSchemaBase = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
@@ -122,10 +143,12 @@ export const TelegramAccountSchemaBase = z
|
|||||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
|
streaming: z.boolean().optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
draftChunk: BlockStreamingChunkSchema.optional(),
|
draftChunk: BlockStreamingChunkSchema.optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"),
|
// Legacy key kept for automatic migration to `streaming`.
|
||||||
|
streamMode: z.enum(["off", "partial", "block"]).optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
retry: RetryConfigSchema,
|
retry: RetryConfigSchema,
|
||||||
@@ -159,6 +182,7 @@ export const TelegramAccountSchemaBase = z
|
|||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => {
|
export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => {
|
||||||
|
normalizeTelegramStreamingConfig(value);
|
||||||
requireOpenAllowFrom({
|
requireOpenAllowFrom({
|
||||||
policy: value.dmPolicy,
|
policy: value.dmPolicy,
|
||||||
allowFrom: value.allowFrom,
|
allowFrom: value.allowFrom,
|
||||||
@@ -173,6 +197,7 @@ export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((valu
|
|||||||
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
||||||
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
|
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
|
||||||
}).superRefine((value, ctx) => {
|
}).superRefine((value, ctx) => {
|
||||||
|
normalizeTelegramStreamingConfig(value);
|
||||||
requireOpenAllowFrom({
|
requireOpenAllowFrom({
|
||||||
policy: value.dmPolicy,
|
policy: value.dmPolicy,
|
||||||
allowFrom: value.allowFrom,
|
allowFrom: value.allowFrom,
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
|||||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a higher initial debounce threshold in block stream mode", async () => {
|
it("uses immediate preview updates for legacy block stream mode", async () => {
|
||||||
const draftStream = createDraftStream();
|
const draftStream = createDraftStream();
|
||||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||||
@@ -209,7 +209,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
|||||||
|
|
||||||
expect(createTelegramDraftStream).toHaveBeenCalledWith(
|
expect(createTelegramDraftStream).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
minInitialChars: 30,
|
minInitialChars: 1,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -445,7 +445,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forces new message when new assistant message starts after previous output", async () => {
|
it("does not force new message for legacy block stream mode", async () => {
|
||||||
const draftStream = createDraftStream(999);
|
const draftStream = createDraftStream(999);
|
||||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||||
@@ -464,8 +464,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
|||||||
|
|
||||||
await dispatchWithContext({ context: createContext(), streamMode: "block" });
|
await dispatchWithContext({ context: createContext(), streamMode: "block" });
|
||||||
|
|
||||||
// Should force new message when assistant message starts after previous output
|
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
|
||||||
expect(draftStream.forceNewMessage).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not force new message in partial mode when assistant message restarts", async () => {
|
it("does not force new message in partial mode when assistant message restarts", async () => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
modelSupportsVision,
|
modelSupportsVision,
|
||||||
} from "../agents/model-catalog.js";
|
} from "../agents/model-catalog.js";
|
||||||
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||||
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
|
|
||||||
import { resolveChunkMode } from "../auto-reply/chunk.js";
|
import { resolveChunkMode } from "../auto-reply/chunk.js";
|
||||||
import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js";
|
import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js";
|
||||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||||
@@ -26,7 +25,6 @@ import type { TelegramBotOptions } from "./bot.js";
|
|||||||
import { deliverReplies } from "./bot/delivery.js";
|
import { deliverReplies } from "./bot/delivery.js";
|
||||||
import type { TelegramStreamMode } from "./bot/types.js";
|
import type { TelegramStreamMode } from "./bot/types.js";
|
||||||
import type { TelegramInlineButtons } from "./button-types.js";
|
import type { TelegramInlineButtons } from "./button-types.js";
|
||||||
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
|
||||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||||
import { renderTelegramHtmlText } from "./format.js";
|
import { renderTelegramHtmlText } from "./format.js";
|
||||||
import {
|
import {
|
||||||
@@ -143,21 +141,20 @@ export const dispatchTelegramMessage = async ({
|
|||||||
});
|
});
|
||||||
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
|
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
|
||||||
const streamReasoningDraft = resolvedReasoningLevel === "stream";
|
const streamReasoningDraft = resolvedReasoningLevel === "stream";
|
||||||
|
const previewStreamingEnabled = streamMode !== "off";
|
||||||
const canStreamAnswerDraft =
|
const canStreamAnswerDraft =
|
||||||
streamMode !== "off" && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning;
|
previewStreamingEnabled && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning;
|
||||||
const canStreamReasoningDraft = canStreamAnswerDraft || streamReasoningDraft;
|
const canStreamReasoningDraft = canStreamAnswerDraft || streamReasoningDraft;
|
||||||
const draftReplyToMessageId =
|
const draftReplyToMessageId =
|
||||||
replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined;
|
replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined;
|
||||||
const draftMinInitialChars =
|
const draftMinInitialChars =
|
||||||
streamMode === "partial" || streamReasoningDraft ? 1 : DRAFT_MIN_INITIAL_CHARS;
|
previewStreamingEnabled || streamReasoningDraft ? 1 : DRAFT_MIN_INITIAL_CHARS;
|
||||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
||||||
type LaneName = "answer" | "reasoning";
|
type LaneName = "answer" | "reasoning";
|
||||||
type DraftLaneState = {
|
type DraftLaneState = {
|
||||||
stream: ReturnType<typeof createTelegramDraftStream> | undefined;
|
stream: ReturnType<typeof createTelegramDraftStream> | undefined;
|
||||||
lastPartialText: string;
|
lastPartialText: string;
|
||||||
draftText: string;
|
|
||||||
hasStreamedMessage: boolean;
|
hasStreamedMessage: boolean;
|
||||||
chunker: EmbeddedBlockChunker | undefined;
|
|
||||||
};
|
};
|
||||||
const createDraftLane = (enabled: boolean): DraftLaneState => {
|
const createDraftLane = (enabled: boolean): DraftLaneState => {
|
||||||
const stream = enabled
|
const stream = enabled
|
||||||
@@ -173,16 +170,10 @@ export const dispatchTelegramMessage = async ({
|
|||||||
warn: logVerbose,
|
warn: logVerbose,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
const chunker =
|
|
||||||
stream && streamMode === "block"
|
|
||||||
? new EmbeddedBlockChunker(resolveTelegramDraftStreamingChunking(cfg, route.accountId))
|
|
||||||
: undefined;
|
|
||||||
return {
|
return {
|
||||||
stream,
|
stream,
|
||||||
lastPartialText: "",
|
lastPartialText: "",
|
||||||
draftText: "",
|
|
||||||
hasStreamedMessage: false,
|
hasStreamedMessage: false,
|
||||||
chunker,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const lanes: Record<LaneName, DraftLaneState> = {
|
const lanes: Record<LaneName, DraftLaneState> = {
|
||||||
@@ -207,9 +198,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
};
|
};
|
||||||
const resetDraftLaneState = (lane: DraftLaneState) => {
|
const resetDraftLaneState = (lane: DraftLaneState) => {
|
||||||
lane.lastPartialText = "";
|
lane.lastPartialText = "";
|
||||||
lane.draftText = "";
|
|
||||||
lane.hasStreamedMessage = false;
|
lane.hasStreamedMessage = false;
|
||||||
lane.chunker?.reset();
|
|
||||||
};
|
};
|
||||||
const updateDraftFromPartial = (lane: DraftLaneState, text: string | undefined) => {
|
const updateDraftFromPartial = (lane: DraftLaneState, text: string | undefined) => {
|
||||||
const laneStream = lane.stream;
|
const laneStream = lane.stream;
|
||||||
@@ -221,46 +210,18 @@ export const dispatchTelegramMessage = async ({
|
|||||||
}
|
}
|
||||||
// Mark that we've received streaming content (for forceNewMessage decision).
|
// Mark that we've received streaming content (for forceNewMessage decision).
|
||||||
lane.hasStreamedMessage = true;
|
lane.hasStreamedMessage = true;
|
||||||
if (streamMode === "partial") {
|
// Some providers briefly emit a shorter prefix snapshot (for example
|
||||||
// Some providers briefly emit a shorter prefix snapshot (for example
|
// "Sure." -> "Sure" -> "Sure."). Keep the longer preview to avoid
|
||||||
// "Sure." -> "Sure" -> "Sure."). Keep the longer preview to avoid
|
// visible punctuation flicker.
|
||||||
// visible punctuation flicker.
|
if (
|
||||||
if (
|
lane.lastPartialText &&
|
||||||
lane.lastPartialText &&
|
lane.lastPartialText.startsWith(text) &&
|
||||||
lane.lastPartialText.startsWith(text) &&
|
text.length < lane.lastPartialText.length
|
||||||
text.length < lane.lastPartialText.length
|
) {
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lane.lastPartialText = text;
|
|
||||||
laneStream.update(text);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let delta = text;
|
|
||||||
if (text.startsWith(lane.lastPartialText)) {
|
|
||||||
delta = text.slice(lane.lastPartialText.length);
|
|
||||||
} else {
|
|
||||||
// Streaming buffer reset (or non-monotonic stream). Start fresh.
|
|
||||||
lane.chunker?.reset();
|
|
||||||
lane.draftText = "";
|
|
||||||
}
|
|
||||||
lane.lastPartialText = text;
|
lane.lastPartialText = text;
|
||||||
if (!delta) {
|
laneStream.update(text);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!lane.chunker) {
|
|
||||||
lane.draftText = text;
|
|
||||||
laneStream.update(lane.draftText);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lane.chunker.append(delta);
|
|
||||||
lane.chunker.drain({
|
|
||||||
force: false,
|
|
||||||
emit: (chunk) => {
|
|
||||||
lane.draftText += chunk;
|
|
||||||
laneStream.update(lane.draftText);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const ingestDraftLaneSegments = (text: string | undefined) => {
|
const ingestDraftLaneSegments = (text: string | undefined) => {
|
||||||
for (const segment of splitTextIntoLaneSegments(text)) {
|
for (const segment of splitTextIntoLaneSegments(text)) {
|
||||||
@@ -275,31 +236,18 @@ export const dispatchTelegramMessage = async ({
|
|||||||
if (!lane.stream) {
|
if (!lane.stream) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (lane.chunker?.hasBuffered()) {
|
|
||||||
lane.chunker.drain({
|
|
||||||
force: true,
|
|
||||||
emit: (chunk) => {
|
|
||||||
lane.draftText += chunk;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
lane.chunker.reset();
|
|
||||||
if (lane.draftText) {
|
|
||||||
lane.stream.update(lane.draftText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await lane.stream.flush();
|
await lane.stream.flush();
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableBlockStreaming =
|
const disableBlockStreaming = !previewStreamingEnabled
|
||||||
streamMode === "off"
|
? true
|
||||||
? true
|
: forceBlockStreamingForReasoning
|
||||||
: forceBlockStreamingForReasoning
|
? false
|
||||||
? false
|
: typeof telegramCfg.blockStreaming === "boolean"
|
||||||
: typeof telegramCfg.blockStreaming === "boolean"
|
? !telegramCfg.blockStreaming
|
||||||
? !telegramCfg.blockStreaming
|
: canStreamAnswerDraft
|
||||||
: canStreamAnswerDraft
|
? true
|
||||||
? true
|
: undefined;
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -395,8 +343,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
linkPreview: telegramCfg.linkPreview,
|
linkPreview: telegramCfg.linkPreview,
|
||||||
replyQuoteText,
|
replyQuoteText,
|
||||||
};
|
};
|
||||||
const getLanePreviewText = (lane: DraftLaneState) =>
|
const getLanePreviewText = (lane: DraftLaneState) => lane.lastPartialText;
|
||||||
streamMode === "block" ? lane.draftText : lane.lastPartialText;
|
|
||||||
const tryUpdatePreviewForLane = async (params: {
|
const tryUpdatePreviewForLane = async (params: {
|
||||||
lane: DraftLaneState;
|
lane: DraftLaneState;
|
||||||
laneName: LaneName;
|
laneName: LaneName;
|
||||||
@@ -449,7 +396,6 @@ export const dispatchTelegramMessage = async ({
|
|||||||
});
|
});
|
||||||
if (updateLaneSnapshot) {
|
if (updateLaneSnapshot) {
|
||||||
lane.lastPartialText = text;
|
lane.lastPartialText = text;
|
||||||
lane.draftText = text;
|
|
||||||
}
|
}
|
||||||
deliveryState.delivered = true;
|
deliveryState.delivered = true;
|
||||||
return true;
|
return true;
|
||||||
@@ -684,10 +630,6 @@ export const dispatchTelegramMessage = async ({
|
|||||||
onAssistantMessageStart: answerLane.stream
|
onAssistantMessageStart: answerLane.stream
|
||||||
? () => {
|
? () => {
|
||||||
reasoningStepState.resetForNextStep();
|
reasoningStepState.resetForNextStep();
|
||||||
// Keep answer blocks separated in block mode; partial mode keeps one answer lane.
|
|
||||||
if (streamMode === "block" && answerLane.hasStreamedMessage) {
|
|
||||||
answerLane.stream?.forceNewMessage();
|
|
||||||
}
|
|
||||||
resetDraftLaneState(answerLane);
|
resetDraftLaneState(answerLane);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -154,11 +154,18 @@ export function buildTypingThreadParams(messageThreadId?: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTelegramStreamMode(telegramCfg?: {
|
export function resolveTelegramStreamMode(telegramCfg?: {
|
||||||
|
streaming?: boolean;
|
||||||
streamMode?: TelegramStreamMode;
|
streamMode?: TelegramStreamMode;
|
||||||
}): TelegramStreamMode {
|
}): TelegramStreamMode {
|
||||||
|
if (typeof telegramCfg?.streaming === "boolean") {
|
||||||
|
return telegramCfg.streaming ? "partial" : "off";
|
||||||
|
}
|
||||||
const raw = telegramCfg?.streamMode?.trim().toLowerCase();
|
const raw = telegramCfg?.streamMode?.trim().toLowerCase();
|
||||||
if (raw === "off" || raw === "partial" || raw === "block") {
|
if (raw === "off") {
|
||||||
return raw;
|
return "off";
|
||||||
|
}
|
||||||
|
if (raw === "partial" || raw === "block") {
|
||||||
|
return "partial";
|
||||||
}
|
}
|
||||||
return "partial";
|
return "partial";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user