feat(telegram): default streaming preview to partial

This commit is contained in:
Peter Steinberger
2026-03-02 23:02:50 +00:00
parent 2287d1ec13
commit fc1787fd4b
8 changed files with 188 additions and 107 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Telegram/DM streaming: use `sendMessageDraft` for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
- Telegram/voice mention gating: add optional `disableAudioPreflight` on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.
- Hooks/message lifecycle: add internal hook events `message:transcribed` and `message:preprocessed`, plus richer outbound `message:sent` context (`isGroup`, `groupId`) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.
- Telegram/Streaming defaults: default `channels.telegram.streaming` to `partial` (from `off`) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
- README/Contributors: rank contributor avatars by composite score (commits + merged PRs + code LOC), excluding docs-only LOC to prevent bulk-generated files from inflating rankings. (#23970) Thanks @tyler6204.

View File

@@ -230,23 +230,31 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
## Feature reference
<AccordionGroup>
<Accordion title="Live stream preview (message edits)">
OpenClaw can stream partial replies by sending a temporary Telegram message and editing it as text arrives.
<Accordion title="Live stream preview (native drafts + message edits)">
OpenClaw can stream partial replies in real time:
- direct chats: Telegram native draft streaming via `sendMessageDraft`
- groups/topics: preview message + `editMessageText`
Requirement:
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `off`)
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
- `progress` maps to `partial` on Telegram (compat with cross-channel naming)
- legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped
This works in direct chats and groups/topics.
Telegram enabled `sendMessageDraft` for all bots in Bot API 9.5 (March 1, 2026).
For text-only replies, OpenClaw keeps the same preview message and performs a final edit in place (no second message).
For text-only replies:
- DM: OpenClaw updates the draft in place (no extra preview message)
- group/topic: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
Preview streaming is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming.
If native draft transport is unavailable/rejected, OpenClaw automatically falls back to `sendMessage` + `editMessageText`.
Telegram-only reasoning stream:
- `/reasoning stream` sends reasoning to the live preview while generating
@@ -751,7 +759,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.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`; `block` is legacy preview mode compatibility).
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). In DMs, `partial` uses native `sendMessageDraft` when available.
- `channels.telegram.mediaMaxMb`: inbound Telegram media download/processing cap (MB).
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.

View File

@@ -138,7 +138,7 @@ Legacy key migration:
Telegram:
- Uses Bot API `sendMessage` + `editMessageText`.
- Uses Bot API `sendMessageDraft` in DMs when available, and `sendMessage` + `editMessageText` for group/topic preview updates.
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
- `/reasoning stream` can write reasoning to preview.

View File

@@ -83,7 +83,7 @@ export function resolveTelegramPreviewStreamMode(
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
return "off";
return "partial";
}
export function resolveDiscordPreviewStreamMode(

View File

@@ -1370,7 +1370,7 @@ export const FIELD_HELP: Record<string, string> = {
"channels.telegram.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].',
"channels.telegram.streaming":
'Unified Telegram stream preview mode: "off" | "partial" | "block" | "progress". "progress" maps to "partial" on Telegram. Legacy boolean/streamMode keys are auto-mapped.',
'Unified Telegram stream preview mode: "off" | "partial" | "block" | "progress" (default: "partial"). "progress" maps to "partial" on Telegram. Legacy boolean/streamMode keys are auto-mapped.',
"channels.discord.streaming":
'Unified Discord stream preview mode: "off" | "partial" | "block" | "progress". "progress" maps to "partial" on Discord. Legacy boolean/streamMode keys are auto-mapped.',
"channels.discord.streamMode":

View File

@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
import { resolveTelegramStreamMode } from "./bot/helpers.js";
describe("resolveTelegramStreamMode", () => {
it("defaults to off when telegram streaming is unset", () => {
expect(resolveTelegramStreamMode(undefined)).toBe("off");
expect(resolveTelegramStreamMode({})).toBe("off");
it("defaults to partial when telegram streaming is unset", () => {
expect(resolveTelegramStreamMode(undefined)).toBe("partial");
expect(resolveTelegramStreamMode({})).toBe("partial");
});
it("prefers explicit streaming boolean", () => {

View File

@@ -160,6 +160,36 @@ describe("createTelegramDraftStream", () => {
);
});
it("falls back to message transport when sendMessageDraft is rejected at runtime", async () => {
const api = createMockDraftApi();
api.sendMessageDraft.mockRejectedValueOnce(
new Error(
"Call to 'sendMessageDraft' failed! (400: Bad Request: method sendMessageDraft can be used only in private chats)",
),
);
const warn = vi.fn();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
warn,
});
stream.update("Hello");
await stream.flush();
expect(api.sendMessageDraft).toHaveBeenCalledTimes(1);
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 });
expect(stream.previewMode?.()).toBe("message");
expect(warn).toHaveBeenCalledWith(
"telegram stream preview: sendMessageDraft rejected by API; falling back to sendMessage/editMessageText",
);
stream.update("Hello again");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "Hello again");
});
it("retries DM message preview send without thread when thread is not found", async () => {
const api = createMockDraftApi();
api.sendMessage

View File

@@ -6,6 +6,9 @@ const TELEGRAM_STREAM_MAX_CHARS = 4096;
const DEFAULT_THROTTLE_MS = 1000;
const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
const THREAD_NOT_FOUND_RE = /400:\s*Bad Request:\s*message thread not found/i;
const DRAFT_METHOD_UNAVAILABLE_RE =
/(unknown method|method .*not (found|available|supported)|unsupported)/i;
const DRAFT_CHAT_UNSUPPORTED_RE = /(can't be used|can be used only)/i;
type TelegramSendMessageDraft = (
chatId: number,
@@ -33,6 +36,23 @@ function resolveSendMessageDraftApi(api: Bot["api"]): TelegramSendMessageDraft |
return sendMessageDraft.bind(api as object);
}
function shouldFallbackFromDraftTransport(err: unknown): boolean {
const text =
typeof err === "string"
? err
: err instanceof Error
? err.message
: typeof err === "object" && err && "description" in err
? typeof err.description === "string"
? err.description
: ""
: "";
if (!/sendMessageDraft/i.test(text)) {
return false;
}
return DRAFT_METHOD_UNAVAILABLE_RE.test(text) || DRAFT_CHAT_UNSUPPORTED_RE.test(text);
}
export type TelegramDraftStream = {
update: (text: string) => void;
flush: () => Promise<void>;
@@ -105,101 +125,98 @@ export function createTelegramDraftStream(params: {
const streamState = { stopped: false, final: false };
let streamMessageId: number | undefined;
let streamDraftId = usesDraftTransport ? allocateTelegramDraftId() : undefined;
let previewTransport: "message" | "draft" = usesDraftTransport ? "draft" : "message";
let lastSentText = "";
let lastSentParseMode: "HTML" | undefined;
let previewRevision = 0;
let generation = 0;
const sendStreamPreview = usesDraftTransport
? async ({
renderedText,
renderedParseMode,
}: {
renderedText: string;
renderedParseMode: "HTML" | undefined;
sendGeneration: number;
}): Promise<boolean> => {
const draftId = streamDraftId ?? allocateTelegramDraftId();
streamDraftId = draftId;
const draftParams = {
...(threadParams?.message_thread_id != null
? { message_thread_id: threadParams.message_thread_id }
: {}),
...(renderedParseMode ? { parse_mode: renderedParseMode } : {}),
};
await resolvedDraftApi!(
chatId,
draftId,
renderedText,
Object.keys(draftParams).length > 0 ? draftParams : undefined,
);
return true;
type PreviewSendParams = {
renderedText: string;
renderedParseMode: "HTML" | undefined;
sendGeneration: number;
};
const sendMessageTransportPreview = async ({
renderedText,
renderedParseMode,
sendGeneration,
}: PreviewSendParams): Promise<boolean> => {
if (typeof streamMessageId === "number") {
if (renderedParseMode) {
await params.api.editMessageText(chatId, streamMessageId, renderedText, {
parse_mode: renderedParseMode,
});
} else {
await params.api.editMessageText(chatId, streamMessageId, renderedText);
}
: async ({
renderedText,
renderedParseMode,
sendGeneration,
}: {
renderedText: string;
renderedParseMode: "HTML" | undefined;
sendGeneration: number;
}): Promise<boolean> => {
if (typeof streamMessageId === "number") {
if (renderedParseMode) {
await params.api.editMessageText(chatId, streamMessageId, renderedText, {
parse_mode: renderedParseMode,
});
} else {
await params.api.editMessageText(chatId, streamMessageId, renderedText);
}
return true;
return true;
}
const sendParams = renderedParseMode
? {
...replyParams,
parse_mode: renderedParseMode,
}
const sendParams = renderedParseMode
? {
...replyParams,
parse_mode: renderedParseMode,
}
: replyParams;
let sent;
try {
sent = await params.api.sendMessage(chatId, renderedText, sendParams);
} catch (err) {
const hasThreadParam =
"message_thread_id" in (sendParams ?? {}) &&
typeof (sendParams as { message_thread_id?: unknown }).message_thread_id === "number";
if (!hasThreadParam || !THREAD_NOT_FOUND_RE.test(String(err))) {
throw err;
}
const threadlessParams = {
...(sendParams as Record<string, unknown>),
};
delete threadlessParams.message_thread_id;
params.warn?.(
"telegram stream preview send failed with message_thread_id, retrying without thread",
);
sent = await params.api.sendMessage(
chatId,
renderedText,
Object.keys(threadlessParams).length > 0 ? threadlessParams : undefined,
);
}
const sentMessageId = sent?.message_id;
if (typeof sentMessageId !== "number" || !Number.isFinite(sentMessageId)) {
streamState.stopped = true;
params.warn?.("telegram stream preview stopped (missing message id from sendMessage)");
return false;
}
const normalizedMessageId = Math.trunc(sentMessageId);
if (sendGeneration !== generation) {
params.onSupersededPreview?.({
messageId: normalizedMessageId,
textSnapshot: renderedText,
parseMode: renderedParseMode,
});
return true;
}
streamMessageId = normalizedMessageId;
return true;
: replyParams;
let sent;
try {
sent = await params.api.sendMessage(chatId, renderedText, sendParams);
} catch (err) {
const hasThreadParam =
"message_thread_id" in (sendParams ?? {}) &&
typeof (sendParams as { message_thread_id?: unknown }).message_thread_id === "number";
if (!hasThreadParam || !THREAD_NOT_FOUND_RE.test(String(err))) {
throw err;
}
const threadlessParams = {
...(sendParams as Record<string, unknown>),
};
delete threadlessParams.message_thread_id;
params.warn?.(
"telegram stream preview send failed with message_thread_id, retrying without thread",
);
sent = await params.api.sendMessage(
chatId,
renderedText,
Object.keys(threadlessParams).length > 0 ? threadlessParams : undefined,
);
}
const sentMessageId = sent?.message_id;
if (typeof sentMessageId !== "number" || !Number.isFinite(sentMessageId)) {
streamState.stopped = true;
params.warn?.("telegram stream preview stopped (missing message id from sendMessage)");
return false;
}
const normalizedMessageId = Math.trunc(sentMessageId);
if (sendGeneration !== generation) {
params.onSupersededPreview?.({
messageId: normalizedMessageId,
textSnapshot: renderedText,
parseMode: renderedParseMode,
});
return true;
}
streamMessageId = normalizedMessageId;
return true;
};
const sendDraftTransportPreview = async ({
renderedText,
renderedParseMode,
}: PreviewSendParams): Promise<boolean> => {
const draftId = streamDraftId ?? allocateTelegramDraftId();
streamDraftId = draftId;
const draftParams = {
...(threadParams?.message_thread_id != null
? { message_thread_id: threadParams.message_thread_id }
: {}),
...(renderedParseMode ? { parse_mode: renderedParseMode } : {}),
};
await resolvedDraftApi!(
chatId,
draftId,
renderedText,
Object.keys(draftParams).length > 0 ? draftParams : undefined,
);
return true;
};
const sendOrEditStreamMessage = async (text: string): Promise<boolean> => {
// Allow final flush even if stopped (e.g., after clear()).
@@ -240,11 +257,36 @@ export function createTelegramDraftStream(params: {
lastSentText = renderedText;
lastSentParseMode = renderedParseMode;
try {
const sent = await sendStreamPreview({
renderedText,
renderedParseMode,
sendGeneration,
});
let sent = false;
if (previewTransport === "draft") {
try {
sent = await sendDraftTransportPreview({
renderedText,
renderedParseMode,
sendGeneration,
});
} catch (err) {
if (!shouldFallbackFromDraftTransport(err)) {
throw err;
}
previewTransport = "message";
streamDraftId = undefined;
params.warn?.(
"telegram stream preview: sendMessageDraft rejected by API; falling back to sendMessage/editMessageText",
);
sent = await sendMessageTransportPreview({
renderedText,
renderedParseMode,
sendGeneration,
});
}
} else {
sent = await sendMessageTransportPreview({
renderedText,
renderedParseMode,
sendGeneration,
});
}
if (sent) {
previewRevision += 1;
}
@@ -281,7 +323,7 @@ export function createTelegramDraftStream(params: {
const forceNewMessage = () => {
generation += 1;
streamMessageId = undefined;
if (usesDraftTransport) {
if (previewTransport === "draft") {
streamDraftId = allocateTelegramDraftId();
}
lastSentText = "";
@@ -296,7 +338,7 @@ export function createTelegramDraftStream(params: {
update,
flush: loop.flush,
messageId: () => streamMessageId,
previewMode: () => (usesDraftTransport ? "draft" : "message"),
previewMode: () => previewTransport,
previewRevision: () => previewRevision,
clear,
stop,