From 7a836c9ff0451a79931313d8f2ddae400c40130a Mon Sep 17 00:00:00 2001 From: Ruby Date: Sat, 10 Jan 2026 10:40:25 -0600 Subject: [PATCH 1/2] Discord: include forwarded message snapshots --- src/discord/monitor.tool-result.test.ts | 90 +++++++++++++++++++ src/discord/monitor.ts | 115 ++++++++++++++++++++++-- 2 files changed, 196 insertions(+), 9 deletions(-) diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 50b5600cb5b..5963a4d2d77 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -193,6 +193,96 @@ describe("discord tool result dispatch", () => { expect(fetchChannel).toHaveBeenCalledTimes(1); }); + it("includes forwarded message snapshots in body", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + let capturedBody = ""; + dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { + capturedBody = ctx.Body ?? ""; + dispatcher.sendFinalReply({ text: "ok" }); + return { queuedFinal: true, counts: { final: 1 } }; + }); + + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, + session: { store: "/tmp/clawdbot-sessions.json" }, + discord: { dm: { enabled: true, policy: "open" } }, + } as ReturnType; + + const handler = createDiscordMessageHandler({ + cfg, + discordConfig: cfg.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + }); + + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.DM, + name: "dm", + }), + } as unknown as Client; + + await handler( + { + message: { + id: "m-forward-1", + content: "", + channelId: "c-forward-1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada" }, + rawData: { + message_snapshots: [ + { + message: { + content: "forwarded hello", + embeds: [], + attachments: [], + author: { + id: "u2", + username: "Bob", + discriminator: "0", + }, + }, + }, + ], + }, + }, + author: { id: "u1", bot: false, username: "Ada" }, + guild_id: null, + }, + client, + ); + + expect(capturedBody).toContain("[Forwarded message from @Bob]"); + expect(capturedBody).toContain("forwarded hello"); + }); + it("uses channel id allowlists for non-thread channels with categories", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); let capturedCtx: { SessionKey?: string } | undefined; diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 9a99dd54f38..84b8e2bebcc 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -99,6 +99,25 @@ type DiscordMediaInfo = { placeholder: string; }; +type DiscordSnapshotAuthor = { + id?: string | null; + username?: string | null; + discriminator?: string | null; + global_name?: string | null; + name?: string | null; +}; + +type DiscordSnapshotMessage = { + content?: string | null; + embeds?: Array<{ description?: string | null; title?: string | null }> | null; + attachments?: APIAttachment[] | null; + author?: DiscordSnapshotAuthor | null; +}; + +type DiscordMessageSnapshot = { + message?: DiscordSnapshotMessage | null; +}; + type DiscordHistoryEntry = { sender: string; body: string; @@ -706,7 +725,12 @@ export function createDiscordMessageHandler(params: { } } const botId = botUserId; - const baseText = resolveDiscordMessageText(message); + const baseText = resolveDiscordMessageText(message, { + includeForwarded: false, + }); + const messageText = resolveDiscordMessageText(message, { + includeForwarded: true, + }); recordProviderActivity({ provider: "discord", accountId, @@ -732,7 +756,7 @@ export function createDiscordMessageHandler(params: { matchesMentionPatterns(baseText, mentionRegexes)); if (shouldLogVerbose()) { logVerbose( - `discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${baseText ? "yes" : "no"}`, + `discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`, ); } @@ -860,7 +884,9 @@ export function createDiscordMessageHandler(params: { return; } - const textForHistory = resolveDiscordMessageText(message); + const textForHistory = resolveDiscordMessageText(message, { + includeForwarded: true, + }); if (isGuildMessage && historyLimit > 0 && textForHistory) { const history = guildHistories.get(message.channelId) ?? []; history.push({ @@ -957,7 +983,7 @@ export function createDiscordMessageHandler(params: { } const mediaList = await resolveMediaList(message, mediaMaxBytes); - const text = baseText; + const text = messageText; if (!text) { logVerbose(`discord: drop message ${message.id} (empty content)`); return; @@ -1938,17 +1964,86 @@ function buildDiscordAttachmentPlaceholder( function resolveDiscordMessageText( message: Message, - fallbackText?: string, + options?: { fallbackText?: string; includeForwarded?: boolean }, ): string { - return ( + const baseText = message.content?.trim() || buildDiscordAttachmentPlaceholder(message.attachments) || message.embeds?.[0]?.description || - fallbackText?.trim() || - "" + options?.fallbackText?.trim() || + ""; + if (!options?.includeForwarded) return baseText; + const forwardedText = resolveDiscordForwardedMessagesText(message); + if (!forwardedText) return baseText; + if (!baseText) return forwardedText; + return `${baseText}\n${forwardedText}`; +} + +function resolveDiscordForwardedMessagesText(message: Message): string { + const snapshots = resolveDiscordMessageSnapshots(message); + if (snapshots.length === 0) return ""; + const forwardedBlocks = snapshots + .map((snapshot) => { + const snapshotMessage = snapshot.message; + if (!snapshotMessage) return null; + const text = resolveDiscordSnapshotMessageText(snapshotMessage); + if (!text) return null; + const authorLabel = formatDiscordSnapshotAuthor(snapshotMessage.author); + const heading = authorLabel + ? `[Forwarded message from ${authorLabel}]` + : "[Forwarded message]"; + return `${heading}\n${text}`; + }) + .filter((entry): entry is string => Boolean(entry)); + if (forwardedBlocks.length === 0) return ""; + return forwardedBlocks.join("\n\n"); +} + +function resolveDiscordMessageSnapshots( + message: Message, +): DiscordMessageSnapshot[] { + const rawData = (message as { rawData?: { message_snapshots?: unknown } }) + .rawData; + const snapshots = + rawData?.message_snapshots ?? + (message as { message_snapshots?: unknown }).message_snapshots ?? + (message as { messageSnapshots?: unknown }).messageSnapshots; + if (!Array.isArray(snapshots)) return []; + return snapshots.filter( + (entry): entry is DiscordMessageSnapshot => + Boolean(entry) && typeof entry === "object", ); } +function resolveDiscordSnapshotMessageText( + snapshot: DiscordSnapshotMessage, +): string { + const content = snapshot.content?.trim() ?? ""; + const attachmentText = buildDiscordAttachmentPlaceholder( + snapshot.attachments ?? undefined, + ); + const embed = snapshot.embeds?.[0]; + const embedText = embed?.description?.trim() || embed?.title?.trim() || ""; + return content || attachmentText || embedText || ""; +} + +function formatDiscordSnapshotAuthor( + author: DiscordSnapshotAuthor | null | undefined, +): string | undefined { + if (!author) return undefined; + const globalName = author.global_name ?? undefined; + const username = author.username ?? undefined; + const name = author.name ?? undefined; + const discriminator = author.discriminator ?? undefined; + const base = globalName || username || name; + if (username && discriminator && discriminator !== "0") { + return `@${username}#${discriminator}`; + } + if (base) return `@${base}`; + if (author.id) return `@${author.id}`; + return undefined; +} + export function buildDiscordMediaPayload( mediaList: Array<{ path: string; contentType?: string }>, ): { @@ -1977,7 +2072,9 @@ export function buildDiscordMediaPayload( function resolveReplyContext(message: Message): string | null { const referenced = message.referencedMessage; if (!referenced?.author) return null; - const referencedText = resolveDiscordMessageText(referenced); + const referencedText = resolveDiscordMessageText(referenced, { + includeForwarded: true, + }); if (!referencedText) return null; const fromLabel = referenced.author ? buildDirectLabel(referenced.author) From 6480ef369ff073bf854d2ed5c56818fe70be9ab2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 18:30:06 +0100 Subject: [PATCH 2/2] fix: telegram draft chunking defaults (#667) (thanks @rubyrunsstuff) --- CHANGELOG.md | 2 + docs/concepts/streaming.md | 1 + docs/gateway/configuration.md | 5 ++ docs/providers/telegram.md | 3 ++ src/auto-reply/reply/block-streaming.test.ts | 54 ++++++++++++++++++++ src/auto-reply/reply/block-streaming.ts | 35 +++++++++++++ src/config/schema.ts | 10 ++++ src/config/types.ts | 14 +++-- src/config/zod-schema.ts | 27 +++++----- src/telegram/bot.ts | 4 +- 10 files changed, 135 insertions(+), 20 deletions(-) create mode 100644 src/auto-reply/reply/block-streaming.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e19554671e9..11b7c06bf4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ - Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan. - Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight. - Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm. +- Discord: include forwarded message snapshots in agent session context. (#667) — thanks @rubyrunsstuff. +- Telegram: add `telegram.draftChunk` to tune draft streaming chunking for `streamMode: "block"`. (#667) — thanks @rubyrunsstuff. - Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults. - iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski. - iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks). diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index 755071561d8..6928ac9170b 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -100,6 +100,7 @@ Telegram is the only provider with draft streaming: - `partial`: draft updates with the latest stream text. - `block`: draft updates in chunked blocks (same chunker rules). - `off`: no draft streaming. +- Draft chunk config (only for `streamMode: "block"`): `telegram.draftChunk` (defaults: `minChars: 200`, `maxChars: 800`). - Draft streaming is separate from block streaming; block replies are off by default and only enabled by `*.blockStreaming: true` on non-Telegram providers. - Final reply is still a normal message. - `/reasoning stream` writes reasoning into the draft bubble (Telegram only). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 80efd2c709e..975c52c498c 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -676,6 +676,11 @@ Multi-account support lives under `telegram.accounts` (see the multi-account sec }, replyToMode: "first", // off | first | all streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming) + draftChunk: { // optional; only for streamMode=block + minChars: 200, + maxChars: 800, + breakPreference: "paragraph" // paragraph | newline | sentence + }, actions: { reactions: true, sendMessage: true }, // tool action gates (false disables) mediaMaxMb: 5, retry: { // outbound retry policy diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index a669e4e56c2..0d1e0660ece 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -209,6 +209,9 @@ Config: - `partial`: update the draft bubble with the latest streaming text. - `block`: update the draft bubble in larger blocks (chunked). - `off`: disable draft streaming. +- Optional (only for `streamMode: "block"`): + - `telegram.draftChunk: { minChars?, maxChars?, breakPreference? }` + - defaults: `minChars: 200`, `maxChars: 800`, `breakPreference: "paragraph"` (clamped to `telegram.textChunkLimit`). Note: draft streaming is separate from **block streaming** (provider messages). Block streaming is off by default and requires `telegram.blockStreaming: true` diff --git a/src/auto-reply/reply/block-streaming.test.ts b/src/auto-reply/reply/block-streaming.test.ts new file mode 100644 index 00000000000..17b6c505ed4 --- /dev/null +++ b/src/auto-reply/reply/block-streaming.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveTelegramDraftStreamingChunking } from "./block-streaming.js"; + +describe("resolveTelegramDraftStreamingChunking", () => { + it("uses smaller defaults than block streaming", () => { + const chunking = resolveTelegramDraftStreamingChunking( + undefined, + "default", + ); + expect(chunking).toEqual({ + minChars: 200, + maxChars: 800, + breakPreference: "paragraph", + }); + }); + + it("clamps to telegram.textChunkLimit", () => { + const cfg: ClawdbotConfig = { + telegram: { allowFrom: ["*"], textChunkLimit: 150 }, + }; + const chunking = resolveTelegramDraftStreamingChunking(cfg, "default"); + expect(chunking).toEqual({ + minChars: 150, + maxChars: 150, + breakPreference: "paragraph", + }); + }); + + it("supports per-account overrides", () => { + const cfg: ClawdbotConfig = { + telegram: { + allowFrom: ["*"], + accounts: { + default: { + allowFrom: ["*"], + draftChunk: { + minChars: 10, + maxChars: 20, + breakPreference: "sentence", + }, + }, + }, + }, + }; + const chunking = resolveTelegramDraftStreamingChunking(cfg, "default"); + expect(chunking).toEqual({ + minChars: 10, + maxChars: 20, + breakPreference: "sentence", + }); + }); +}); diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index da60e1ce976..3a0e1801e18 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -5,6 +5,8 @@ import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MAX = 1200; const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000; +const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200; +const DEFAULT_TELEGRAM_DRAFT_STREAM_MAX = 800; const PROVIDER_COALESCE_DEFAULTS: Partial< Record > = { @@ -72,6 +74,39 @@ export function resolveBlockStreamingChunking( return { minChars, maxChars, breakPreference }; } +export function resolveTelegramDraftStreamingChunking( + cfg: ClawdbotConfig | undefined, + accountId?: string | null, +): { + minChars: number; + maxChars: number; + breakPreference: "paragraph" | "newline" | "sentence"; +} { + const providerKey: TextChunkProvider = "telegram"; + const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId); + const normalizedAccountId = normalizeAccountId(accountId); + const draftCfg = + cfg?.telegram?.accounts?.[normalizedAccountId]?.draftChunk ?? + cfg?.telegram?.draftChunk; + + const maxRequested = Math.max( + 1, + Math.floor(draftCfg?.maxChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MAX), + ); + const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); + const minRequested = Math.max( + 1, + Math.floor(draftCfg?.minChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MIN), + ); + const minChars = Math.min(minRequested, maxChars); + const breakPreference = + draftCfg?.breakPreference === "newline" || + draftCfg?.breakPreference === "sentence" + ? draftCfg.breakPreference + : "paragraph"; + return { minChars, maxChars, breakPreference }; +} + export function resolveBlockStreamingCoalescing( cfg: ClawdbotConfig | undefined, provider?: string, diff --git a/src/config/schema.ts b/src/config/schema.ts index 5778968e8cd..8cba062ddc9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -120,6 +120,10 @@ const FIELD_LABELS: Record = { "telegram.botToken": "Telegram Bot Token", "telegram.dmPolicy": "Telegram DM Policy", "telegram.streamMode": "Telegram Draft Stream Mode", + "telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars", + "telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars", + "telegram.draftChunk.breakPreference": + "Telegram Draft Chunk Break Preference", "telegram.retry.attempts": "Telegram Retry Attempts", "telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", "telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", @@ -203,6 +207,12 @@ const FIELD_HELP: Record = { 'Direct message access control ("pairing" recommended). "open" requires telegram.allowFrom=["*"].', "telegram.streamMode": "Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.", + "telegram.draftChunk.minChars": + 'Minimum chars before emitting a Telegram draft update when telegram.streamMode="block" (default: 200).', + "telegram.draftChunk.maxChars": + 'Target max size for a Telegram draft update chunk when telegram.streamMode="block" (default: 800; clamped to telegram.textChunkLimit).', + "telegram.draftChunk.breakPreference": + "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.", "telegram.retry.attempts": "Max retry attempts for outbound Telegram API calls (default: 3).", "telegram.retry.minDelayMs": diff --git a/src/config/types.ts b/src/config/types.ts index 842dade7083..36c2b3f4d91 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -22,6 +22,12 @@ export type BlockStreamingCoalesceConfig = { idleMs?: number; }; +export type BlockStreamingChunkConfig = { + minChars?: number; + maxChars?: number; + breakPreference?: "paragraph" | "newline" | "sentence"; +}; + export type HumanDelayConfig = { /** Delay style for block replies (off|natural|custom). */ mode?: "off" | "natural" | "custom"; @@ -345,6 +351,8 @@ export type TelegramAccountConfig = { textChunkLimit?: number; /** Disable block streaming for this account. */ blockStreaming?: boolean; + /** Chunking config for draft streaming in `streamMode: "block"`. */ + draftChunk?: BlockStreamingChunkConfig; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; /** Draft streaming mode for Telegram (off|partial|block). Default: partial. */ @@ -1318,11 +1326,7 @@ export type AgentDefaultsConfig = { */ blockStreamingBreak?: "text_end" | "message_end"; /** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */ - blockStreamingChunk?: { - minChars?: number; - maxChars?: number; - breakPreference?: "paragraph" | "newline" | "sentence"; - }; + blockStreamingChunk?: BlockStreamingChunkConfig; /** * Block reply coalescing (merge streamed chunks before send). * idleMs: wait time before flushing when idle. diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index c4314efc1ad..c63db55934d 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -103,6 +103,18 @@ const BlockStreamingCoalesceSchema = z.object({ idleMs: z.number().int().nonnegative().optional(), }); +const BlockStreamingChunkSchema = z.object({ + minChars: z.number().int().positive().optional(), + maxChars: z.number().int().positive().optional(), + breakPreference: z + .union([ + z.literal("paragraph"), + z.literal("newline"), + z.literal("sentence"), + ]) + .optional(), +}); + const HumanDelaySchema = z.object({ mode: z .union([z.literal("off"), z.literal("natural"), z.literal("custom")]) @@ -207,6 +219,7 @@ const TelegramAccountSchemaBase = z.object({ groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), blockStreaming: z.boolean().optional(), + draftChunk: BlockStreamingChunkSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"), mediaMaxMb: z.number().positive().optional(), @@ -1038,19 +1051,7 @@ const AgentDefaultsSchema = z blockStreamingBreak: z .union([z.literal("text_end"), z.literal("message_end")]) .optional(), - blockStreamingChunk: z - .object({ - minChars: z.number().int().positive().optional(), - maxChars: z.number().int().positive().optional(), - breakPreference: z - .union([ - z.literal("paragraph"), - z.literal("newline"), - z.literal("sentence"), - ]) - .optional(), - }) - .optional(), + blockStreamingChunk: BlockStreamingChunkSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), humanDelay: HumanDelaySchema.optional(), timeoutSeconds: z.number().int().positive().optional(), diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 0d8ddda8b9f..306a3e87d7f 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -19,7 +19,7 @@ import { listNativeCommandSpecs, } from "../auto-reply/commands-registry.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; -import { resolveBlockStreamingChunking } from "../auto-reply/reply/block-streaming.js"; +import { resolveTelegramDraftStreamingChunking } from "../auto-reply/reply/block-streaming.js"; import { buildMentionRegexes, matchesMentionPatterns, @@ -749,7 +749,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { : undefined; const draftChunking = draftStream && streamMode === "block" - ? resolveBlockStreamingChunking(cfg, "telegram", route.accountId) + ? resolveTelegramDraftStreamingChunking(cfg, route.accountId) : undefined; const draftChunker = draftChunking ? new EmbeddedBlockChunker(draftChunking)