Skip to content

Commit e1d52d6

Browse files
authored
feat: add sendMessageDraft streaming mode (#141)
1 parent 88e5725 commit e1d52d6

6 files changed

Lines changed: 390 additions & 54 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ OPENCODE_MODEL_ID=big-pickle
8080
# If exceeded, the bot stops waiting for the result and marks the run as failed.
8181
# SCHEDULED_TASK_EXECUTION_TIMEOUT_MINUTES=120
8282

83+
# Response streaming mode: "edit" (default) or "draft" (default: edit)
84+
# edit = uses editMessageText to update messages incrementally (may flicker)
85+
# draft = uses sendMessageDraft (Bot API 9.5+) for smooth animated draft previews,
86+
# then finalizes with sendMessage. Only works in private chats.
87+
# RESPONSE_STREAMING_MODE=edit
88+
8389
# Stream update throttle in milliseconds for assistant/tool message edits (default: 1000)
8490
# Higher value = fewer Telegram edit requests, lower value = more real-time updates
8591
# RESPONSE_STREAM_THROTTLE_MS=1000

src/bot/index.ts

Lines changed: 107 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,11 @@ import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
9393
import { deliverThinkingMessage } from "./utils/thinking-message.js";
9494
import { shouldSuppressUserAbortSessionError } from "./utils/abort-error-suppression.js";
9595
import {
96+
completeDraftPart,
9697
editRenderedBotPart,
9798
getTelegramRenderedPartSignature,
9899
sendBotText,
100+
sendDraftBotPart,
99101
sendRenderedBotPart,
100102
} from "./utils/telegram-text.js";
101103
import { formatAssistantRunFooter } from "./utils/assistant-run-footer.js";
@@ -134,6 +136,7 @@ let unsubscribeReadyRestore: (() => void) | null = null;
134136

135137
const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
136138
const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
139+
const RESPONSE_STREAMING_MODE = config.bot.responseStreamingMode;
137140
const RESPONSE_STREAM_TEXT_LIMIT = 3800;
138141
const SESSION_RETRY_PREFIX = "🔁";
139142
const SUBAGENT_STREAM_PREFIX = "🧩";
@@ -237,64 +240,116 @@ const toolMessageBatcher = new ToolMessageBatcher({
237240
},
238241
});
239242

240-
const responseStreamer = new ResponseStreamer({
241-
throttleMs: RESPONSE_STREAM_THROTTLE_MS,
242-
sendPart: async (part, options) => {
243-
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
244-
throw new Error("Bot context missing for streamed send");
245-
}
243+
let nextDraftId = 1;
244+
function getNextDraftId(): number {
245+
const id = nextDraftId;
246+
nextDraftId += 1;
247+
return id;
248+
}
246249

247-
return sendRenderedBotPart({
248-
api: botInstance.api,
249-
chatId: chatIdInstance,
250-
part,
251-
options,
252-
});
253-
},
254-
editPart: async (messageId, part, options) => {
255-
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
256-
throw new Error("Bot context missing for streamed edit");
257-
}
250+
const responseStreamer = RESPONSE_STREAMING_MODE === "draft"
251+
? new ResponseStreamer({
252+
throttleMs: RESPONSE_STREAM_THROTTLE_MS,
253+
sendPart: async (part) => {
254+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
255+
throw new Error("Bot context missing for draft send");
256+
}
258257

259-
try {
260-
return await editRenderedBotPart({
261-
api: botInstance.api,
262-
chatId: chatIdInstance,
263-
messageId,
264-
part,
265-
options,
266-
});
267-
} catch (error) {
268-
const errorMessage =
269-
error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
270-
if (errorMessage.includes("message is not modified")) {
271-
return {
272-
deliveredSignature: getTelegramRenderedPartSignature(part),
273-
};
274-
}
258+
const draftId = getNextDraftId();
259+
const result = await sendDraftBotPart({
260+
api: botInstance.api,
261+
chatId: chatIdInstance,
262+
draftId,
263+
part,
264+
});
265+
return { messageId: draftId, deliveredSignature: result.deliveredSignature };
266+
},
267+
editPart: async (messageId, part) => {
268+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
269+
throw new Error("Bot context missing for draft edit");
270+
}
275271

276-
throw error;
277-
}
278-
},
279-
deleteText: async (messageId) => {
280-
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
281-
throw new Error("Bot context missing for streamed delete");
282-
}
272+
return sendDraftBotPart({
273+
api: botInstance.api,
274+
chatId: chatIdInstance,
275+
draftId: messageId,
276+
part,
277+
});
278+
},
279+
deleteText: async () => {
280+
// Drafts are ephemeral — no need to delete
281+
},
282+
completePart: async (part, options) => {
283+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
284+
throw new Error("Bot context missing for draft complete");
285+
}
283286

284-
await botInstance.api.deleteMessage(chatIdInstance, messageId).catch((error) => {
285-
const errorMessage =
286-
error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
287-
if (
288-
errorMessage.includes("message to delete not found") ||
289-
errorMessage.includes("message identifier is not specified")
290-
) {
291-
return;
292-
}
287+
return completeDraftPart({
288+
api: botInstance.api,
289+
chatId: chatIdInstance,
290+
part,
291+
options,
292+
});
293+
},
294+
})
295+
: new ResponseStreamer({
296+
throttleMs: RESPONSE_STREAM_THROTTLE_MS,
297+
sendPart: async (part, options) => {
298+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
299+
throw new Error("Bot context missing for streamed send");
300+
}
293301

294-
throw error;
302+
return sendRenderedBotPart({
303+
api: botInstance.api,
304+
chatId: chatIdInstance,
305+
part,
306+
options,
307+
});
308+
},
309+
editPart: async (messageId, part, options) => {
310+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
311+
throw new Error("Bot context missing for streamed edit");
312+
}
313+
314+
try {
315+
return await editRenderedBotPart({
316+
api: botInstance.api,
317+
chatId: chatIdInstance,
318+
messageId,
319+
part,
320+
options,
321+
});
322+
} catch (error) {
323+
const errorMessage =
324+
error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
325+
if (errorMessage.includes("message is not modified")) {
326+
return {
327+
deliveredSignature: getTelegramRenderedPartSignature(part),
328+
};
329+
}
330+
331+
throw error;
332+
}
333+
},
334+
deleteText: async (messageId) => {
335+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
336+
throw new Error("Bot context missing for streamed delete");
337+
}
338+
339+
await botInstance.api.deleteMessage(chatIdInstance, messageId).catch((error) => {
340+
const errorMessage =
341+
error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
342+
if (
343+
errorMessage.includes("message to delete not found") ||
344+
errorMessage.includes("message identifier is not specified")
345+
) {
346+
return;
347+
}
348+
349+
throw error;
350+
});
351+
},
295352
});
296-
},
297-
});
298353

299354
setResponseStreamerForReconciliation(responseStreamer);
300355

src/bot/streaming/response-streamer.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ interface ResponseStreamerOptions {
3535
options?: TelegramEditMessageOptions,
3636
) => Promise<{ deliveredSignature: string }>;
3737
deleteText: (messageId: number) => Promise<void>;
38+
completePart?: (
39+
part: TelegramRenderedPart,
40+
options?: TelegramSendMessageOptions,
41+
) => Promise<{ messageId: number; deliveredSignature: string }>;
3842
}
3943

4044
interface StreamState {
@@ -121,13 +125,15 @@ export class ResponseStreamer {
121125
private readonly sendPart: ResponseStreamerOptions["sendPart"];
122126
private readonly editPart: ResponseStreamerOptions["editPart"];
123127
private readonly deleteText: ResponseStreamerOptions["deleteText"];
128+
private readonly completePart: ResponseStreamerOptions["completePart"];
124129
private readonly states: Map<string, StreamState> = new Map();
125130

126131
constructor(options: ResponseStreamerOptions) {
127132
this.throttleMs = Math.max(0, Math.floor(options.throttleMs));
128133
this.sendPart = options.sendPart;
129134
this.editPart = options.editPart;
130135
this.deleteText = options.deleteText;
136+
this.completePart = options.completePart;
131137
}
132138

133139
enqueue(sessionId: string, messageId: string, payload: StreamingMessagePayload): void {
@@ -191,8 +197,25 @@ export class ResponseStreamer {
191197
let synced = true;
192198
if (options?.flushFinal !== false) {
193199
synced = await this.enqueueTask(state, () => this.flushState(state, "complete"));
194-
if (!synced && state.isBroken) {
195-
await this.cleanupBrokenStream(state, "final_sync_failed_cleanup");
200+
}
201+
202+
if (synced && this.completePart && state.latestPayload) {
203+
try {
204+
const realMessageIds: number[] = [];
205+
for (const part of state.latestPayload.parts) {
206+
if (!part.text) {
207+
continue;
208+
}
209+
const result = await this.completePart(part, state.latestPayload.sendOptions);
210+
realMessageIds.push(result.messageId);
211+
}
212+
state.telegramMessageIds = realMessageIds;
213+
} catch (error) {
214+
logger.error(
215+
`[ResponseStreamer] Failed to persist draft message: session=${sessionId}, message=${messageId}`,
216+
error,
217+
);
218+
synced = false;
196219
}
197220
}
198221

src/bot/utils/telegram-text.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { TelegramRenderedPart } from "../../telegram/render/types.js";
88

99
type SendMessageApi = Pick<Api<RawApi>, "sendMessage">;
1010
type EditMessageApi = Pick<Api<RawApi>, "editMessageText">;
11+
type SendDraftApi = Pick<Api<RawApi>, "sendMessageDraft">;
1112

1213
type TelegramSendMessageOptions = Parameters<SendMessageApi["sendMessage"]>[2];
1314
type TelegramEditMessageOptions = Parameters<EditMessageApi["editMessageText"]>[3];
@@ -203,6 +204,95 @@ export async function editRenderedBotPart({
203204
}
204205
}
205206

207+
interface SendDraftBotPartParams {
208+
api: SendDraftApi;
209+
chatId: Parameters<SendDraftApi["sendMessageDraft"]>[0];
210+
draftId: number;
211+
part: TelegramRenderedPart;
212+
}
213+
214+
interface CompleteDraftPartParams {
215+
api: SendMessageApi;
216+
chatId: Parameters<SendMessageApi["sendMessage"]>[0];
217+
part: TelegramRenderedPart;
218+
options?: TelegramSendMessageOptions;
219+
}
220+
221+
export async function sendDraftBotPart({
222+
api,
223+
chatId,
224+
draftId,
225+
part,
226+
}: SendDraftBotPartParams): Promise<RenderedPartDeliveryResult> {
227+
logger.debug("[Bot] Sending draft part", {
228+
draftId,
229+
textLength: part.text.length,
230+
entityCount: part.entities?.length ?? 0,
231+
});
232+
233+
if (!part.entities?.length) {
234+
await api.sendMessageDraft(chatId, draftId, part.text);
235+
return {
236+
deliveredSignature: getTelegramRenderedPartSignature({ text: part.text }),
237+
};
238+
}
239+
240+
try {
241+
await api.sendMessageDraft(chatId, draftId, part.text, {
242+
entities: part.entities,
243+
});
244+
return {
245+
deliveredSignature: getTelegramRenderedPartSignature(part),
246+
};
247+
} catch (error) {
248+
logger.warn("[Bot] Entity draft failed, retrying with raw fallback", error);
249+
await api.sendMessageDraft(chatId, draftId, part.fallbackText);
250+
return {
251+
deliveredSignature: getTelegramRenderedPartSignature({ text: part.fallbackText }),
252+
};
253+
}
254+
}
255+
256+
export async function completeDraftPart({
257+
api,
258+
chatId,
259+
part,
260+
options,
261+
}: CompleteDraftPartParams): Promise<RenderedPartSendResult> {
262+
const rawOptions = stripRichFormattingOptions(options);
263+
264+
logger.debug("[Bot] Completing draft with real message", {
265+
textLength: part.text.length,
266+
entityCount: part.entities?.length ?? 0,
267+
});
268+
269+
if (!part.entities?.length) {
270+
const sentMessage = await api.sendMessage(chatId, part.text, rawOptions);
271+
return {
272+
messageId: sentMessage.message_id,
273+
deliveredSignature: getTelegramRenderedPartSignature({ text: part.text }),
274+
};
275+
}
276+
277+
try {
278+
const sentMessage = await api.sendMessage(chatId, part.text, {
279+
...(rawOptions || {}),
280+
entities: part.entities,
281+
});
282+
return {
283+
messageId: sentMessage.message_id,
284+
deliveredSignature: getTelegramRenderedPartSignature(part),
285+
};
286+
} catch (error) {
287+
logger.warn("[Bot] Entity complete failed, retrying with raw fallback", error);
288+
const sentMessage = await api.sendMessage(chatId, part.fallbackText, rawOptions);
289+
return {
290+
messageId: sentMessage.message_id,
291+
deliveredSignature: getTelegramRenderedPartSignature({ text: part.fallbackText }),
292+
};
293+
}
294+
}
295+
206296
export async function editBotText({
207297
api,
208298
chatId,

0 commit comments

Comments
 (0)