Skip to content

feat(telegram): use sendMessageDraft for private chat streaming#31824

Merged
obviyus merged 6 commits intomainfrom
feat/telegram-send-message-draft-stream
Mar 2, 2026
Merged

feat(telegram): use sendMessageDraft for private chat streaming#31824
obviyus merged 6 commits intomainfrom
feat/telegram-send-message-draft-stream

Conversation

@obviyus
Copy link
Contributor

@obviyus obviyus commented Mar 2, 2026

Summary

  • use Telegram sendMessageDraft for private-chat draft streaming
  • keep existing preview message edit/delete flow unchanged for groups/forums/other scopes
  • add tests to assert DM draft flow never sends/edits/deletes preview messages

Validation

  • pnpm test src/telegram/draft-stream.test.ts
  • pnpm tsgo

@openclaw-barnacle openclaw-barnacle bot added channel: telegram Channel integration: telegram size: S maintainer Maintainer-authored PR labels Mar 2, 2026
@obviyus obviyus self-assigned this Mar 2, 2026
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 2, 2026

Greptile Summary

This PR introduces Telegram draft streaming for private chats (DM threads) using the sendMessageDraft API, replacing the previous sendMessage/editMessageText/deleteMessage flow for DMs while preserving the existing behavior for groups and forums.

Key changes:

  • Added draft ID allocation system with module-level counter (nextDraftId) that wraps at 2.1 billion
  • Split streaming logic into two branches: DM drafts use sendMessageDraft, while groups/forums continue using message APIs
  • Draft streams for DMs never create, edit, or delete actual messages—only update draft state
  • Added validation to ensure sendMessageDraft API is available for DM streams (throws error if missing)

Issues found:

  • The sendGeneration parameter in the DM branch is declared but never used, unlike the non-DM branch which uses it to detect race conditions. This could cause stale content when forceNewMessage() is called while a send is in-flight.
  • Test coverage is missing for forceNewMessage() with DM drafts, so the draft ID allocation behavior in that scenario is untested.

Confidence Score: 3/5

  • This PR is safe to merge with moderate risk due to an unused parameter that could cause race conditions in edge cases
  • The core implementation correctly separates DM draft streaming from group/forum message streaming. However, the sendGeneration parameter is unused in the DM branch, which could allow late-completing sends to overwrite newer draft content when forceNewMessage() is called. This race condition is unlikely in typical usage but represents a genuine logic gap compared to the non-DM implementation. Missing test coverage for forceNewMessage() with DM drafts leaves this code path unverified.
  • Pay close attention to src/telegram/draft-stream.ts lines 96-120 (DM streaming branch) where the race condition exists

Last reviewed commit: a9bbcac

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 2, 2026

Additional Comments (1)

src/telegram/draft-stream.test.ts
Consider adding test coverage for forceNewMessage() with DM drafts (scope: "dm"). Current tests only cover non-threaded streams or forum threads, leaving the DM draft path untested where a new draft ID is allocated.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/telegram/draft-stream.test.ts
Line: 144-165

Comment:
Consider adding test coverage for `forceNewMessage()` with DM drafts (scope: "dm"). Current tests only cover non-threaded streams or forum threads, leaving the DM draft path untested where a new draft ID is allocated.

How can I resolve this? If you propose a fix, please make it concise.

@obviyus
Copy link
Contributor Author

obviyus commented Mar 2, 2026

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e09ae54c08

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@aisle-research-bot
Copy link

aisle-research-bot bot commented Mar 2, 2026

🔒 Aisle Security Analysis

We found 3 potential security issue(s) in this PR:

# Severity Title
1 🟠 High DM draft preview fallback may send reasoning preview as a normal message (leaking sensitive content / causing duplicates)
2 🟡 Medium Telegram topic/thread fallback retries preview send without message_thread_id, potentially posting drafts to main chat
3 🟡 Medium Telegram DM reasoning lane forced to "message" preview transport can expose model chain-of-thought

1. 🟠 DM draft preview fallback may send reasoning preview as a normal message (leaking sensitive content / causing duplicates)

Property Value
Severity High
CWE CWE-200
Location src/telegram/lane-delivery.ts:340-354

Description

In createLaneTextDeliverer, draft-preview lanes (DMs using sendMessageDraft) attempt to update the preview by flushing the draft stream and then checking whether previewRevision advanced.

However, previewRevision only increments when the underlying stream actually performs an API send; it does not increment when:

  • The preview already contains the same text (a no-op flush returns true without sending), or
  • The stream refuses to emit a preview (e.g., minInitialChars debounce returns false), or
  • Any other internal condition prevents an emit without throwing.

When this happens, lane-delivery.ts falls back to sendPayload(...), which sends the reasoning lane text as a regular Telegram message.

Impact:

  • Information disclosure / privacy regression: reasoning/draft-only preview text (often chain-of-thought style content) can be delivered into the chat history and notifications even though the lane is configured for draft-preview transport.
  • Duplicate sends / chat spam: if the draft preview is already up to date (no-op flush), the code can still send the same text again as a normal message.

Vulnerable code:

const previewRevisionBeforeFlush = lane.stream?.previewRevision?.() ?? 0;
lane.stream?.update(text);
await params.flushDraftLane(lane);
const previewUpdated = (lane.stream?.previewRevision?.() ?? 0) > previewRevisionBeforeFlush;
if (!previewUpdated) {
  const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
  return delivered ? "sent" : "skipped";
}

Recommendation

Avoid using previewRevision as the sole indicator that an update was (or should be) emitted.

Recommended safeguards:

  1. Treat no-op flushes as success (text already delivered in preview): if the stream already has the same pending/sent text, do not fall back to a normal send.

  2. Do not fall back to normal send for reasoning drafts by default (or gate it behind an explicit allowlist).

  3. If fallback is required, sanitize/strip sensitive reasoning content before sending, or only allow fallback for the answer lane.

Example fix (conservative: never fall back for draft reasoning updates):

if (isDraftPreviewLane(lane)) {
  const before = lane.stream?.previewRevision?.() ?? 0;
  lane.stream?.update(text);
  await params.flushDraftLane(lane);
  const after = lane.stream?.previewRevision?.() ?? 0;// If nothing was emitted, treat as skipped rather than sending to chat history.
  if (after <= before) return "skipped";

  lane.lastPartialText = text;
  params.markDelivered();
  return "preview-updated";
}

If you must keep fallback, add an explicit check such as if (laneName !== "reasoning") before calling sendPayload and/or ensure the content is safe to publish.


2. 🟡 Telegram topic/thread fallback retries preview send without message_thread_id, potentially posting drafts to main chat

Property Value
Severity Medium
CWE CWE-200
Location src/telegram/draft-stream.ts:165-184

Description

In createTelegramDraftStream message-preview transport, the code catches Telegram's message thread not found error and retries sendMessage after deleting message_thread_id.

This is unsafe when the stream is running in a forum supergroup topic (where message_thread_id is the topic selector):

  • Input: threadParams comes from buildTelegramThreadParams(params.thread) and is included in replyParams/sendParams.
  • Sink: params.api.sendMessage(chatId, renderedText, sendParams).
  • On error matching message thread not found, the code removes message_thread_id and retries.
  • Effect: for forum topics, removing message_thread_id posts the preview into the main chat / General topic, which can be visible/notify a wider set of recipients than the intended topic context.

Concrete leakage scenarios:

  • A bot is streaming a sensitive draft/reasoning preview in a forum topic, and the topic is deleted/invalidated while streaming. The next preview send errors with message thread not found, and the retry posts the draft into the main supergroup feed.
  • Any stale/mis-resolved topic ID (e.g., from config or cached state) will trigger the same behavior, shifting messages from a scoped topic to the broader chat.

Tests added in this diff only assert the retry behavior for scope: "dm" (DM thread) and do not verify that forum-topic sends fail closed.

Vulnerable code:

} 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, /* threadless */ ...);
}

Recommendation

Fail closed for forum topics; only allow a threadless retry when the thread scope is truly DM (matching the design already used in sendTelegramWithThreadFallback).

For example:

} catch (err) {
  const hasThreadParam = typeof (sendParams as any)?.message_thread_id === "number";
  const allowThreadlessRetry = params.thread?.scope === "dm";
  if (!allowThreadlessRetry || !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 thread not found in DM; retrying without thread");
  sent = await params.api.sendMessage(
    chatId,
    renderedText,
    Object.keys(threadlessParams).length ? threadlessParams : undefined,
  );
}

Additionally:

  • Add a regression test asserting that scope: "forum" does not retry without message_thread_id.
  • Consider logging chatId/threadId in the warning to aid incident triage (without logging message text).

3. 🟡 Telegram DM reasoning lane forced to "message" preview transport can expose model chain-of-thought

Property Value
Severity Medium
CWE CWE-200
Location src/telegram/bot-message-dispatch.ts:192-203

Description

The Telegram dispatcher now forces the reasoning lane to use message-based preview transport in DM scope.

  • Input (sensitive): onReasoningStream is fed by the agent runtime’s reasoning/thinking stream (chain-of-thought style content formatted as Reasoning:\n...).
  • Change: For DM threads, the reasoning lane previously defaulted to previewTransport: "auto", which in createTelegramDraftStream() prefers the draft-only sendMessageDraft transport (no visible chat message).
  • New behavior: When laneName === "reasoning" && threadSpec?.scope === "dm" && canStreamAnswerDraft, the code forces previewTransport: "message", which makes createTelegramDraftStream() use sendMessage/editMessageText and thus creates/edits an actual Telegram message.

Impact:

  • In DMs with reasoning streaming enabled, live reasoning/chain-of-thought updates become user-visible chat messages, potentially exposing internal reasoning that was previously kept out of the chat history.
  • This increases the risk of information disclosure if the model’s reasoning contains sensitive material (e.g., hidden instructions, internal state, tool-derived details) that should not be user-visible.

Vulnerable change:

const useMessagePreviewTransportForDmReasoning =
  laneName === "reasoning" && threadSpec?.scope === "dm" && canStreamAnswerDraft;

createTelegramDraftStream({
  ...,
  previewTransport: useMessagePreviewTransportForDmReasoning ? "message" : "auto",
});

Recommendation

Do not expose raw reasoning by default in Telegram DMs.

Recommended hardening options (pick one based on intended product behavior):

  1. Keep DM reasoning previews draft-only (preferred if reasoning is considered internal):
const useMessagePreviewTransportForDmReasoning = false;// or explicitly
previewTransport: laneName === "reasoning" && threadSpec?.scope === "dm" ? "draft" : "auto";
  1. Gate message-based reasoning previews behind an explicit, high-friction opt-in (e.g., admin/debug flag), separate from general streaming:
const allowVisibleReasoningInDm =
  resolvedReasoningLevel === "stream" && telegramCfg.debugReasoning === true;

previewTransport:
  laneName === "reasoning" && threadSpec?.scope === "dm" && allowVisibleReasoningInDm
    ? "message"
    : "auto";
  1. If reasoning must be shown, send a sanitized summary instead of raw chain-of-thought (e.g., “Working…” style), and never forward tool outputs/system text into the reasoning lane.

Any approach should be paired with clear user/admin documentation that enabling reasoning may reveal sensitive internal data.


Analyzed PR: #31824 at commit 932ef30

Last updated on: 2026-03-02T17:41:10Z

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ed43adee3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@obviyus
Copy link
Contributor Author

obviyus commented Mar 2, 2026

draft-streaming.MP4

Demo of sraft streaming in DMs.

@openclaw-barnacle openclaw-barnacle bot added docs Improvements or additions to documentation channel: bluebubbles Channel integration: bluebubbles channel: googlechat Channel integration: googlechat channel: matrix Channel integration: matrix channel: nextcloud-talk Channel integration: nextcloud-talk channel: slack Channel integration: slack channel: zalo Channel integration: zalo channel: zalouser Channel integration: zalouser app: android App: android app: macos App: macos app: web-ui App: web-ui gateway Gateway runtime cli CLI command changes scripts Repository scripts commands Command implementations agents Agent runtime and tooling labels Mar 2, 2026
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff3c6e8a83

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1bb322c3d9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +348 to +352
if (!previewUpdated) {
params.log(
`telegram: ${laneName} draft preview update not emitted; falling back to standard send`,
);
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat unchanged draft preview text as delivered

When the reasoning lane uses draft transport, this fallback path treats any non-increment of previewRevision as a failed preview update and sends the payload as a normal message. However, createTelegramDraftStream intentionally skips sending when the rendered text is unchanged (renderedText === lastSentText), so a block payload that matches the already-streamed draft text will hit this branch and post a duplicate reasoning message in DM flows (notably reasoning-stream mode where only the reasoning lane is active). This branch should distinguish “no-op because already up to date” from “failed to emit preview” before falling back to sendPayload.

Useful? React with 👍 / 👎.

@obviyus obviyus force-pushed the feat/telegram-send-message-draft-stream branch from 1bb322c to 932ef30 Compare March 2, 2026 16:26
@obviyus obviyus merged commit 6edb512 into main Mar 2, 2026
@obviyus obviyus deleted the feat/telegram-send-message-draft-stream branch March 2, 2026 16:27
@obviyus
Copy link
Contributor Author

obviyus commented Mar 2, 2026

Landed via temp rebase onto main.

  • Gate: pnpm check (fails on unrelated baseline extension typing), pnpm build, pnpm test src/telegram/draft-stream.test.ts src/telegram/bot-message-dispatch.test.ts
  • Land commit: 932ef30
  • Merge commit: 6edb512

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 932ef30796

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +248 to +249
if (sent) {
previewRevision += 1;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore superseded draft sends when bumping preview revision

sendOrEditStreamMessage increments previewRevision whenever sendStreamPreview returns true, but the draft-transport branch never checks whether the send belonged to the current generation after forceNewMessage(). In a race where an older in-flight draft send resolves during a later flush, this stale completion can bump previewRevision and make createLaneTextDeliverer treat the current non-final reasoning block as delivered, skipping sendPayload fallback even when the new text was not emitted (for example, because it is below minInitialChars).

Useful? React with 👍 / 👎.

execute008 pushed a commit to execute008/openclaw that referenced this pull request Mar 2, 2026
…claw#31824)

* feat(telegram): use sendMessageDraft for private stream previews

* test(telegram): cover DM draft id rotation race

* fix(telegram): keep DM reasoning updates in draft preview

* fix(telegram): split DM reasoning preview transport

* fix(telegram): harden DM draft preview fallback paths

* style(telegram): normalize draft preview formatting
execute008 pushed a commit to execute008/openclaw that referenced this pull request Mar 2, 2026
dawi369 pushed a commit to dawi369/davis that referenced this pull request Mar 3, 2026
…claw#31824)

* feat(telegram): use sendMessageDraft for private stream previews

* test(telegram): cover DM draft id rotation race

* fix(telegram): keep DM reasoning updates in draft preview

* fix(telegram): split DM reasoning preview transport

* fix(telegram): harden DM draft preview fallback paths

* style(telegram): normalize draft preview formatting
dawi369 pushed a commit to dawi369/davis that referenced this pull request Mar 3, 2026
mekenthompson added a commit to mekenthompson/openclaw that referenced this pull request Mar 4, 2026
Align Telegram streaming behavior with Discord's existing pattern:

- In partial mode (streaming: 'partial'), do not rotate to a new message
  on assistant message boundaries. The stream keeps editing the same
  message across tool-call turns, matching Discord's shouldSplitPreviewMessages
  gating.

- Force message transport (sendMessage+editMessageText) for all DM lanes,
  not just reasoning. Draft transport (sendMessageDraft) has no real messageId,
  so editMessageText finalization cannot work — every turn falls through to
  sendPayload creating a new message. This restores pre-openclaw#31824 DM behavior.

- Reset streamState.stopped/final in forceNewMessage() so the stream can
  be reused when rotation IS needed (block mode). Previously the stream was
  permanently dead after the first stop().

- Add keepStreamAlive option to lane-delivery so intermediate finals edit
  the preview in place without stopping the stream.

- Preserve visible preview messages in the finally cleanup block when in
  partial mode, even if finalizedPreviewByLane was reset between turns.

Existing config controls the behavior:
  streaming: 'partial' → one coalesced message (default for Telegram DMs)
  streaming: 'block'   → split on message boundaries (existing behavior)

Co-authored-by: Claude <noreply@anthropic.com>
mekenthompson added a commit to mekenthompson/openclaw that referenced this pull request Mar 4, 2026
Align Telegram streaming behavior with Discord's existing pattern:

- In partial mode (streaming: 'partial'), do not rotate to a new message
  on assistant message boundaries. The stream keeps editing the same
  message across tool-call turns, matching Discord's shouldSplitPreviewMessages
  gating.

- Force message transport (sendMessage+editMessageText) for all DM lanes,
  not just reasoning. Draft transport (sendMessageDraft) has no real messageId,
  so editMessageText finalization cannot work — every turn falls through to
  sendPayload creating a new message. This restores pre-openclaw#31824 DM behavior.

- Reset streamState.stopped/final in forceNewMessage() so the stream can
  be reused when rotation IS needed (block mode). Previously the stream was
  permanently dead after the first stop().

- Add keepStreamAlive option to lane-delivery so intermediate finals edit
  the preview in place without stopping the stream.

- Preserve visible preview messages in the finally cleanup block when in
  partial mode, even if finalizedPreviewByLane was reset between turns.

Existing config controls the behavior:
  streaming: 'partial' → one coalesced message (default for Telegram DMs)
  streaming: 'block'   → split on message boundaries (existing behavior)

Closes openclaw#32535

Co-authored-by: Claude <noreply@anthropic.com>
OWALabuy pushed a commit to kcinzgg/openclaw that referenced this pull request Mar 4, 2026
…claw#31824)

* feat(telegram): use sendMessageDraft for private stream previews

* test(telegram): cover DM draft id rotation race

* fix(telegram): keep DM reasoning updates in draft preview

* fix(telegram): split DM reasoning preview transport

* fix(telegram): harden DM draft preview fallback paths

* style(telegram): normalize draft preview formatting
OWALabuy pushed a commit to kcinzgg/openclaw that referenced this pull request Mar 4, 2026
zooqueen pushed a commit to hanzoai/bot that referenced this pull request Mar 6, 2026
…claw#31824)

* feat(telegram): use sendMessageDraft for private stream previews

* test(telegram): cover DM draft id rotation race

* fix(telegram): keep DM reasoning updates in draft preview

* fix(telegram): split DM reasoning preview transport

* fix(telegram): harden DM draft preview fallback paths

* style(telegram): normalize draft preview formatting
zooqueen pushed a commit to hanzoai/bot that referenced this pull request Mar 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: telegram Channel integration: telegram maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant