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:
Ayaan Zaidi
2026-02-21 15:19:13 +05:30
committed by GitHub
parent e1cb73cdeb
commit 677384c519
13 changed files with 116 additions and 137 deletions

View File

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

View File

@@ -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).
- **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`.
- **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.
Open questions

View File

@@ -226,21 +226,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
Requirement:
- `channels.telegram.streamMode` is not `"off"` (default: `"partial"`)
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`.
- `channels.telegram.streaming` is `true` (default)
- legacy `channels.telegram.streamMode` values are auto-mapped to `streaming`
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.
`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:
@@ -721,7 +708,7 @@ Primary reference:
- `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.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.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.
@@ -745,7 +732,7 @@ Telegram-specific high-signal fields:
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`
- command/menu: `commands.native`, `customCommands`
- threading/replies: `replyToMode`
- streaming: `streamMode` (preview), `draftChunk`, `blockStreaming`
- streaming: `streaming` (preview), `blockStreaming`
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy`
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`

View File

@@ -100,7 +100,7 @@ This maps to:
**Channel note:** For non-Telegram channels, block streaming is **off unless**
`*.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
`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:
- Uses Bot API `sendMessage` (first update) + `editMessageText` (subsequent updates).
- `channels.telegram.streamMode: "partial" | "block" | "off"`.
- `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`).
- `channels.telegram.streaming: true | false` (default: `true`).
- Preview streaming is separate from block 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.
@@ -124,8 +120,7 @@ Telegram is the only channel with live preview streaming:
```
Telegram
└─ sendMessage (temporary preview message)
─ streamMode=partial → edit latest text
└─ streamMode=block → chunker + edit updates
─ streaming=true → edit latest text
└─ final text-only reply → final edit on same message
└─ fallback: cleanup preview + normal final delivery (media/complex)
```

View File

@@ -151,12 +151,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
historyLimit: 50,
replyToMode: "first", // off | first | all
linkPreview: true,
streamMode: "partial", // off | partial | block
draftChunk: {
minChars: 200,
maxChars: 800,
breakPreference: "paragraph", // paragraph | newline | sentence
},
streaming: true, // live preview on/off (default true)
actions: { reactions: true, sendMessage: true },
reactionNotifications: "own", // off | own | all
mediaMaxMb: 5,

View File

@@ -378,11 +378,46 @@ describe("legacy config detection", () => {
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: {} } });
expect(res.ok).toBe(true);
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 () => {

View File

@@ -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).",
"channels.telegram.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].',
"channels.telegram.streamMode":
"Live stream preview mode for Telegram replies (off | partial | block). Separate from block streaming; uses sendMessage + editMessageText.",
"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.telegram.streaming":
"Enable Telegram live stream preview via message edits (default: true; legacy streamMode auto-maps here).",
"channels.discord.streamMode":
"Live stream preview mode for Discord replies (off | partial | block). Separate from block streaming; uses sendMessage + editMessage.",
"channels.discord.draftChunk.minChars":

View File

@@ -263,10 +263,7 @@ export const FIELD_LABELS: Record<string, string> = {
...IRC_FIELD_LABELS,
"channels.telegram.botToken": "Telegram Bot Token",
"channels.telegram.dmPolicy": "Telegram DM Policy",
"channels.telegram.streamMode": "Telegram Stream Mode",
"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.streaming": "Telegram Streaming",
"channels.telegram.retry.attempts": "Telegram Retry Attempts",
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",

View File

@@ -95,13 +95,15 @@ export type TelegramAccountConfig = {
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Enable live stream preview via message edits (default: true). */
streaming?: boolean;
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Chunking config for Telegram stream previews in `streamMode: "block"`. */
/** @deprecated Legacy chunking config from `streamMode: "block"`; ignored after migration. */
draftChunk?: BlockStreamingChunkConfig;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Telegram stream preview mode (off|partial|block). Default: partial. */
/** @deprecated Legacy key; migrated automatically to `streaming` boolean. */
streamMode?: "off" | "partial" | "block";
mediaMaxMb?: number;
/** Telegram API client timeout in seconds (grammY ApiClientOptions). */

View File

@@ -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
.object({
name: z.string().optional(),
@@ -122,10 +143,12 @@ export const TelegramAccountSchemaBase = z
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
streaming: z.boolean().optional(),
blockStreaming: z.boolean().optional(),
draftChunk: BlockStreamingChunkSchema.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(),
timeoutSeconds: z.number().int().positive().optional(),
retry: RetryConfigSchema,
@@ -159,6 +182,7 @@ export const TelegramAccountSchemaBase = z
.strict();
export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => {
normalizeTelegramStreamingConfig(value);
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
@@ -173,6 +197,7 @@ export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((valu
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
normalizeTelegramStreamingConfig(value);
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,

View File

@@ -193,7 +193,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
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();
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -209,7 +209,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(createTelegramDraftStream).toHaveBeenCalledWith(
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);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -464,8 +464,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext(), streamMode: "block" });
// Should force new message when assistant message starts after previous output
expect(draftStream.forceNewMessage).toHaveBeenCalled();
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
});
it("does not force new message in partial mode when assistant message restarts", async () => {

View File

@@ -6,7 +6,6 @@ import {
modelSupportsVision,
} from "../agents/model-catalog.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 { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.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 type { TelegramStreamMode } from "./bot/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import { renderTelegramHtmlText } from "./format.js";
import {
@@ -143,21 +141,20 @@ export const dispatchTelegramMessage = async ({
});
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
const streamReasoningDraft = resolvedReasoningLevel === "stream";
const previewStreamingEnabled = streamMode !== "off";
const canStreamAnswerDraft =
streamMode !== "off" && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning;
previewStreamingEnabled && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning;
const canStreamReasoningDraft = canStreamAnswerDraft || streamReasoningDraft;
const draftReplyToMessageId =
replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined;
const draftMinInitialChars =
streamMode === "partial" || streamReasoningDraft ? 1 : DRAFT_MIN_INITIAL_CHARS;
previewStreamingEnabled || streamReasoningDraft ? 1 : DRAFT_MIN_INITIAL_CHARS;
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
type LaneName = "answer" | "reasoning";
type DraftLaneState = {
stream: ReturnType<typeof createTelegramDraftStream> | undefined;
lastPartialText: string;
draftText: string;
hasStreamedMessage: boolean;
chunker: EmbeddedBlockChunker | undefined;
};
const createDraftLane = (enabled: boolean): DraftLaneState => {
const stream = enabled
@@ -173,16 +170,10 @@ export const dispatchTelegramMessage = async ({
warn: logVerbose,
})
: undefined;
const chunker =
stream && streamMode === "block"
? new EmbeddedBlockChunker(resolveTelegramDraftStreamingChunking(cfg, route.accountId))
: undefined;
return {
stream,
lastPartialText: "",
draftText: "",
hasStreamedMessage: false,
chunker,
};
};
const lanes: Record<LaneName, DraftLaneState> = {
@@ -207,9 +198,7 @@ export const dispatchTelegramMessage = async ({
};
const resetDraftLaneState = (lane: DraftLaneState) => {
lane.lastPartialText = "";
lane.draftText = "";
lane.hasStreamedMessage = false;
lane.chunker?.reset();
};
const updateDraftFromPartial = (lane: DraftLaneState, text: string | undefined) => {
const laneStream = lane.stream;
@@ -221,46 +210,18 @@ export const dispatchTelegramMessage = async ({
}
// Mark that we've received streaming content (for forceNewMessage decision).
lane.hasStreamedMessage = true;
if (streamMode === "partial") {
// Some providers briefly emit a shorter prefix snapshot (for example
// "Sure." -> "Sure" -> "Sure."). Keep the longer preview to avoid
// visible punctuation flicker.
if (
lane.lastPartialText &&
lane.lastPartialText.startsWith(text) &&
text.length < lane.lastPartialText.length
) {
return;
}
lane.lastPartialText = text;
laneStream.update(text);
// Some providers briefly emit a shorter prefix snapshot (for example
// "Sure." -> "Sure" -> "Sure."). Keep the longer preview to avoid
// visible punctuation flicker.
if (
lane.lastPartialText &&
lane.lastPartialText.startsWith(text) &&
text.length < lane.lastPartialText.length
) {
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;
if (!delta) {
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);
},
});
laneStream.update(text);
};
const ingestDraftLaneSegments = (text: string | undefined) => {
for (const segment of splitTextIntoLaneSegments(text)) {
@@ -275,31 +236,18 @@ export const dispatchTelegramMessage = async ({
if (!lane.stream) {
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();
};
const disableBlockStreaming =
streamMode === "off"
? true
: forceBlockStreamingForReasoning
? false
: typeof telegramCfg.blockStreaming === "boolean"
? !telegramCfg.blockStreaming
: canStreamAnswerDraft
? true
: undefined;
const disableBlockStreaming = !previewStreamingEnabled
? true
: forceBlockStreamingForReasoning
? false
: typeof telegramCfg.blockStreaming === "boolean"
? !telegramCfg.blockStreaming
: canStreamAnswerDraft
? true
: undefined;
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
@@ -395,8 +343,7 @@ export const dispatchTelegramMessage = async ({
linkPreview: telegramCfg.linkPreview,
replyQuoteText,
};
const getLanePreviewText = (lane: DraftLaneState) =>
streamMode === "block" ? lane.draftText : lane.lastPartialText;
const getLanePreviewText = (lane: DraftLaneState) => lane.lastPartialText;
const tryUpdatePreviewForLane = async (params: {
lane: DraftLaneState;
laneName: LaneName;
@@ -449,7 +396,6 @@ export const dispatchTelegramMessage = async ({
});
if (updateLaneSnapshot) {
lane.lastPartialText = text;
lane.draftText = text;
}
deliveryState.delivered = true;
return true;
@@ -684,10 +630,6 @@ export const dispatchTelegramMessage = async ({
onAssistantMessageStart: answerLane.stream
? () => {
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);
}
: undefined,

View File

@@ -154,11 +154,18 @@ export function buildTypingThreadParams(messageThreadId?: number) {
}
export function resolveTelegramStreamMode(telegramCfg?: {
streaming?: boolean;
streamMode?: TelegramStreamMode;
}): TelegramStreamMode {
if (typeof telegramCfg?.streaming === "boolean") {
return telegramCfg.streaming ? "partial" : "off";
}
const raw = telegramCfg?.streamMode?.trim().toLowerCase();
if (raw === "off" || raw === "partial" || raw === "block") {
return raw;
if (raw === "off") {
return "off";
}
if (raw === "partial" || raw === "block") {
return "partial";
}
return "partial";
}