Skip to content

Commit ed512ff

Browse files
authored
feat(telegram): use entities-first markdown rendering in replies and streaming (#78)
1 parent 5222bae commit ed512ff

33 files changed

Lines changed: 4257 additions & 368 deletions

package-lock.json

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,12 @@
5757
"dotenv": "^17.2.3",
5858
"grammy": "^1.39.2",
5959
"https-proxy-agent": "^7.0.6",
60+
"mdast-util-to-string": "^4.0.0",
61+
"remark-gfm": "^4.0.1",
62+
"remark-parse": "^11.0.0",
6063
"socks-proxy-agent": "^8.0.5",
61-
"telegram-markdown-v2": "^0.0.4"
64+
"telegram-markdown-v2": "^0.0.4",
65+
"unified": "^11.0.5"
6266
},
6367
"devDependencies": {
6468
"@types/better-sqlite3": "^7.6.13",

src/bot/index.ts

Lines changed: 26 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,7 @@ import { clearAllInteractionState } from "../interaction/cleanup.js";
5050
import { keyboardManager } from "../keyboard/manager.js";
5151
import { subscribeToEvents } from "../opencode/events.js";
5252
import { summaryAggregator } from "../summary/aggregator.js";
53-
import {
54-
formatSummary,
55-
formatSummaryWithMode,
56-
formatToolInfo,
57-
getAssistantParseMode,
58-
} from "../summary/formatter.js";
53+
import { formatToolInfo } from "../summary/formatter.js";
5954
import { renderSubagentCards } from "../summary/subagent-formatter.js";
6055
import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
6156
import { getCurrentSession } from "../session/manager.js";
@@ -72,7 +67,12 @@ import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
7267
import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
7368
import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
7469
import { deliverThinkingMessage } from "./utils/thinking-message.js";
75-
import { sendBotText } from "./utils/telegram-text.js";
70+
import {
71+
editRenderedBotPart,
72+
getTelegramRenderedPartSignature,
73+
sendBotText,
74+
sendRenderedBotPart,
75+
} from "./utils/telegram-text.js";
7676
import { formatAssistantRunFooter } from "./utils/assistant-run-footer.js";
7777
import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
7878
import { getStoredModel } from "../model/manager.js";
@@ -84,9 +84,10 @@ import { ResponseStreamer } from "./streaming/response-streamer.js";
8484
import type { StreamingMessagePayload } from "./streaming/response-streamer.js";
8585
import { ToolCallStreamer, type ToolStreamKey } from "./streaming/tool-call-streamer.js";
8686
import {
87-
editMessageWithMarkdownFallback,
88-
sendMessageWithMarkdownFallback,
89-
} from "./utils/send-with-markdown-fallback.js";
87+
prepareAssistantFinalStreamingPayload,
88+
prepareAssistantStreamingPayload,
89+
renderAssistantFinalPartsSafe,
90+
} from "./utils/assistant-rendering.js";
9091

9192
let botInstance: Bot<Context> | null = null;
9293
let chatIdInstance: number | null = null;
@@ -124,32 +125,11 @@ function prepareDocumentCaption(caption: string): string {
124125
}
125126

126127
function prepareStreamingPayload(messageText: string): StreamingMessagePayload | null {
127-
const parts = formatSummaryWithMode(messageText, "raw", RESPONSE_STREAM_TEXT_LIMIT);
128-
if (parts.length === 0) {
129-
return null;
130-
}
131-
132-
return {
133-
parts,
134-
format: "raw",
135-
};
128+
return prepareAssistantStreamingPayload(messageText, RESPONSE_STREAM_TEXT_LIMIT);
136129
}
137130

138131
function prepareFinalStreamingPayload(messageText: string): StreamingMessagePayload | null {
139-
const format = getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw";
140-
const parts = formatSummaryWithMode(
141-
messageText,
142-
format === "markdown_v2" ? "markdown" : "raw",
143-
RESPONSE_STREAM_TEXT_LIMIT,
144-
);
145-
if (parts.length === 0) {
146-
return null;
147-
}
148-
149-
return {
150-
parts,
151-
format,
152-
};
132+
return prepareAssistantFinalStreamingPayload(messageText, RESPONSE_STREAM_TEXT_LIMIT);
153133
}
154134

155135
function enqueueSessionCompletionTask(sessionId: string, task: () => Promise<void>): Promise<void> {
@@ -220,43 +200,38 @@ const toolMessageBatcher = new ToolMessageBatcher({
220200

221201
const responseStreamer = new ResponseStreamer({
222202
throttleMs: RESPONSE_STREAM_THROTTLE_MS,
223-
sendText: async (text, format, options) => {
203+
sendPart: async (part, options) => {
224204
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
225205
throw new Error("Bot context missing for streamed send");
226206
}
227207

228-
const parseMode = format === "markdown_v2" ? "MarkdownV2" : undefined;
229-
const sentMessage = await sendMessageWithMarkdownFallback({
208+
return sendRenderedBotPart({
230209
api: botInstance.api,
231210
chatId: chatIdInstance,
232-
text,
211+
part,
233212
options,
234-
parseMode,
235213
});
236-
237-
return sentMessage.message_id;
238214
},
239-
editText: async (messageId, text, format, options) => {
215+
editPart: async (messageId, part, options) => {
240216
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
241217
throw new Error("Bot context missing for streamed edit");
242218
}
243219

244-
const parseMode = format === "markdown_v2" ? "MarkdownV2" : undefined;
245-
246220
try {
247-
await editMessageWithMarkdownFallback({
221+
return await editRenderedBotPart({
248222
api: botInstance.api,
249223
chatId: chatIdInstance,
250224
messageId,
251-
text,
225+
part,
252226
options,
253-
parseMode,
254227
});
255228
} catch (error) {
256229
const errorMessage =
257230
error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
258231
if (errorMessage.includes("message is not modified")) {
259-
return;
232+
return {
233+
deliveredSignature: getTelegramRenderedPartSignature(part),
234+
};
260235
}
261236

262237
throw error;
@@ -464,18 +439,14 @@ async function ensureEventSubscription(directory: string): Promise<void> {
464439
toolCallStreamer.breakSession(sessionId, "assistant_message_completed"),
465440
]).then(() => undefined),
466441
prepareStreamingPayload: prepareFinalStreamingPayload,
467-
formatSummary,
468-
formatRawSummary: (text) => formatSummaryWithMode(text, "raw"),
469-
resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
442+
renderFinalParts: (text) => renderAssistantFinalPartsSafe(text),
470443
getReplyKeyboard: getCurrentReplyKeyboard,
471-
sendText: async (text, rawFallbackText, options, format) => {
472-
await sendBotText({
444+
sendRenderedPart: async (part, options) => {
445+
await sendRenderedBotPart({
473446
api: botApi,
474447
chatId,
475-
text,
476-
rawFallbackText,
448+
part,
477449
options: options as Parameters<typeof sendBotText>[0]["options"],
478-
format,
479450
});
480451
},
481452
});

src/bot/streaming/response-streamer.ts

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import type { Api, RawApi } from "grammy";
22
import { logger } from "../../utils/logger.js";
3+
import type { TelegramRenderedPart } from "../../telegram/render/types.js";
34

45
type SendMessageApi = Pick<Api<RawApi>, "sendMessage">;
56
type EditMessageApi = Pick<Api<RawApi>, "editMessageText">;
67

78
type TelegramSendMessageOptions = Parameters<SendMessageApi["sendMessage"]>[2];
89
type TelegramEditMessageOptions = Parameters<EditMessageApi["editMessageText"]>[3];
910

10-
export type StreamingMessageFormat = "raw" | "markdown_v2";
11-
1211
export interface StreamingMessagePayload {
13-
parts: string[];
14-
format: StreamingMessageFormat;
12+
parts: TelegramRenderedPart[];
1513
sendOptions?: TelegramSendMessageOptions;
1614
editOptions?: TelegramEditMessageOptions;
1715
}
@@ -27,17 +25,15 @@ interface ResponseStreamerCompleteOptions {
2725

2826
interface ResponseStreamerOptions {
2927
throttleMs: number;
30-
sendText: (
31-
text: string,
32-
format: StreamingMessageFormat,
28+
sendPart: (
29+
part: TelegramRenderedPart,
3330
options?: TelegramSendMessageOptions,
34-
) => Promise<number>;
35-
editText: (
31+
) => Promise<{ messageId: number; deliveredSignature: string }>;
32+
editPart: (
3633
messageId: number,
37-
text: string,
38-
format: StreamingMessageFormat,
34+
part: TelegramRenderedPart,
3935
options?: TelegramEditMessageOptions,
40-
) => Promise<void>;
36+
) => Promise<{ deliveredSignature: string }>;
4137
deleteText: (messageId: number) => Promise<void>;
4238
}
4339

@@ -60,17 +56,24 @@ function buildStateKey(sessionId: string, messageId: string): string {
6056
return `${sessionId}:${messageId}`;
6157
}
6258

59+
function clonePart(part: TelegramRenderedPart): TelegramRenderedPart {
60+
return {
61+
text: part.text,
62+
entities: part.entities ? [...part.entities] : undefined,
63+
fallbackText: part.fallbackText,
64+
source: part.source,
65+
};
66+
}
67+
6368
function normalizePayload(payload: StreamingMessagePayload): StreamingMessagePayload | null {
64-
const normalizedParts = payload.parts
65-
.map((part) => part.trim())
66-
.filter((part) => part.length > 0);
69+
const normalizedParts = payload.parts.map(clonePart).filter((part) => part.text.length > 0);
6770
if (normalizedParts.length === 0) {
71+
logger.debug("[ResponseStreamer] Dropped empty streaming payload after normalization");
6872
return null;
6973
}
7074

7175
return {
7276
parts: normalizedParts,
73-
format: payload.format,
7477
sendOptions: payload.sendOptions,
7578
editOptions: payload.editOptions,
7679
};
@@ -103,8 +106,8 @@ function getRetryAfterMs(error: unknown): number | null {
103106
return seconds * 1000;
104107
}
105108

106-
function createSignature(text: string, format: StreamingMessageFormat): string {
107-
return `${format}\n${text}`;
109+
function createSignature(part: Pick<TelegramRenderedPart, "text" | "entities">): string {
110+
return `${part.text}\n${JSON.stringify(part.entities ?? null)}`;
108111
}
109112

110113
function delay(ms: number): Promise<void> {
@@ -115,15 +118,15 @@ function delay(ms: number): Promise<void> {
115118

116119
export class ResponseStreamer {
117120
private readonly throttleMs: number;
118-
private readonly sendText: ResponseStreamerOptions["sendText"];
119-
private readonly editText: ResponseStreamerOptions["editText"];
121+
private readonly sendPart: ResponseStreamerOptions["sendPart"];
122+
private readonly editPart: ResponseStreamerOptions["editPart"];
120123
private readonly deleteText: ResponseStreamerOptions["deleteText"];
121124
private readonly states: Map<string, StreamState> = new Map();
122125

123126
constructor(options: ResponseStreamerOptions) {
124127
this.throttleMs = Math.max(0, Math.floor(options.throttleMs));
125-
this.sendText = options.sendText;
126-
this.editText = options.editText;
128+
this.sendPart = options.sendPart;
129+
this.editPart = options.editPart;
127130
this.deleteText = options.deleteText;
128131
}
129132

@@ -152,6 +155,9 @@ export class ResponseStreamer {
152155

153156
const state = this.states.get(buildStateKey(sessionId, messageId));
154157
if (!state) {
158+
logger.debug(
159+
`[ResponseStreamer] Complete skipped, no active stream state: session=${sessionId}, message=${messageId}`,
160+
);
155161
return notStreamed;
156162
}
157163

@@ -174,6 +180,9 @@ export class ResponseStreamer {
174180
}
175181

176182
if (state.telegramMessageIds.length === 0) {
183+
logger.debug(
184+
`[ResponseStreamer] Complete returned not streamed: session=${sessionId}, message=${messageId}, reason=no_visible_partials`,
185+
);
177186
this.cancelState(state);
178187
this.states.delete(state.key);
179188
return notStreamed;
@@ -324,12 +333,15 @@ export class ResponseStreamer {
324333
return state.telegramMessageIds.length > 0;
325334
}
326335

327-
const targetSignatures = payload.parts.map((part) => createSignature(part, payload.format));
336+
const targetSignatures = payload.parts.map((part) => createSignature(part));
328337
const unchanged =
329338
targetSignatures.length === state.lastSentSignatures.length &&
330339
targetSignatures.every((signature, index) => signature === state.lastSentSignatures[index]);
331340

332341
if (unchanged) {
342+
logger.debug(
343+
`[ResponseStreamer] Skipped unchanged payload: session=${state.sessionId}, message=${state.messageId}, parts=${payload.parts.length}`,
344+
);
333345
return state.telegramMessageIds.length > 0;
334346
}
335347

@@ -407,7 +419,7 @@ export class ResponseStreamer {
407419
targetSignatures: string[],
408420
): Promise<void> {
409421
for (let index = 0; index < payload.parts.length; index++) {
410-
const text = payload.parts[index];
422+
const part = payload.parts[index];
411423
const nextSignature = targetSignatures[index];
412424
const currentMessageId = state.telegramMessageIds[index];
413425

@@ -416,14 +428,14 @@ export class ResponseStreamer {
416428
continue;
417429
}
418430

419-
await this.editText(currentMessageId, text, payload.format, payload.editOptions);
420-
state.lastSentSignatures[index] = nextSignature;
431+
const result = await this.editPart(currentMessageId, part, payload.editOptions);
432+
state.lastSentSignatures[index] = result.deliveredSignature;
421433
continue;
422434
}
423435

424-
const messageId = await this.sendText(text, payload.format, payload.sendOptions);
425-
state.telegramMessageIds[index] = messageId;
426-
state.lastSentSignatures[index] = nextSignature;
436+
const result = await this.sendPart(part, payload.sendOptions);
437+
state.telegramMessageIds[index] = result.messageId;
438+
state.lastSentSignatures[index] = result.deliveredSignature;
427439
}
428440

429441
for (let index = state.telegramMessageIds.length - 1; index >= payload.parts.length; index--) {

0 commit comments

Comments
 (0)