Skip to content

Commit e4b8460

Browse files
committed
Add configurable Telegram parse mode
1 parent 8f311bf commit e4b8460

20 files changed

Lines changed: 325 additions & 18 deletions

‎apps/admin-web/src/api/client.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export function createAdminClient(getToken: () => string) {
216216
name: string;
217217
botToken?: string;
218218
webhookSecret?: string;
219+
parseMode?: TelegramIntegration["parseMode"];
219220
}) => {
220221
return request<ApiResult<{ integration: TelegramIntegration }>>(
221222
"/admin/platforms/telegram",
@@ -225,6 +226,18 @@ export function createAdminClient(getToken: () => string) {
225226
}
226227
);
227228
},
229+
updateTelegramIntegration: (
230+
integrationId: string,
231+
body: { parseMode?: TelegramIntegration["parseMode"] }
232+
) => {
233+
return request<ApiResult<{ integration: TelegramIntegration }>>(
234+
`/admin/platforms/telegram/${integrationId}`,
235+
{
236+
method: "PUT",
237+
body: JSON.stringify(body)
238+
}
239+
);
240+
},
228241
testTelegramIntegration: (integrationId: string) => {
229242
return request<ApiResult<{ integration: TelegramIntegration; bot: unknown; webhook: unknown }>>(
230243
`/admin/platforms/telegram/${integrationId}/test`,

‎apps/admin-web/src/api/types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ export type TelegramIntegration = {
309309
name: string;
310310
status: string;
311311
webhookSecretConfigured: boolean;
312+
parseMode: "none" | "HTML" | "MarkdownV2";
312313
hasCredential: boolean;
313314
lastCheckedAt?: string;
314315
lastError?: string;

‎apps/admin-web/src/styles/panels.css‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
minmax(140px, 0.8fr)
4545
minmax(220px, 1.2fr)
4646
minmax(220px, 1.2fr)
47+
minmax(120px, 0.6fr)
4748
auto;
4849
gap: 10px;
4950
align-items: end;
@@ -516,7 +517,7 @@
516517

517518
.telegram-row {
518519
display: grid;
519-
grid-template-columns: minmax(0, 1fr) auto auto auto auto auto;
520+
grid-template-columns: minmax(0, 1fr) minmax(120px, 160px) auto auto auto auto auto;
520521
gap: 10px;
521522
align-items: center;
522523
border: 1px solid var(--border);
@@ -536,6 +537,14 @@
536537
font-size: 12px;
537538
}
538539

540+
.inline-select-label {
541+
display: grid;
542+
gap: 4px;
543+
min-width: 0;
544+
color: var(--text-muted);
545+
font-size: 12px;
546+
}
547+
539548
.search-provider-row {
540549
display: grid;
541550
grid-template-columns: minmax(0, 1fr) auto auto auto auto auto;

‎apps/admin-web/src/ui/i18n/translations.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,11 @@ export const translations: Record<Locale, Record<string, string>> = {
172172
"platforms.name": "名称",
173173
"platforms.botToken": "Bot Token",
174174
"platforms.webhookSecret": "Webhook Secret",
175+
"platforms.parseMode": "格式模式",
176+
"platforms.parseModeNone": "纯文本",
175177
"platforms.telegramSaved": "Telegram 集成已保存",
176178
"platforms.telegramSavedWebhook": "Telegram 集成已保存并设置 webhook",
179+
"platforms.integrationUpdated": "Telegram 集成已更新",
177180
"platforms.botTested": "Telegram bot 测试完成",
178181
"platforms.webhookSet": "Telegram webhook 已设置",
179182
"platforms.webhookDeleted": "Telegram webhook 已删除",
@@ -420,8 +423,11 @@ export const translations: Record<Locale, Record<string, string>> = {
420423
"platforms.name": "Name",
421424
"platforms.botToken": "Bot token",
422425
"platforms.webhookSecret": "Webhook secret",
426+
"platforms.parseMode": "Parse mode",
427+
"platforms.parseModeNone": "Plain text",
423428
"platforms.telegramSaved": "Telegram integration saved",
424429
"platforms.telegramSavedWebhook": "Telegram integration saved and webhook set",
430+
"platforms.integrationUpdated": "Telegram integration updated",
425431
"platforms.botTested": "Telegram bot tested",
426432
"platforms.webhookSet": "Telegram webhook set",
427433
"platforms.webhookDeleted": "Telegram webhook deleted",

‎apps/admin-web/src/ui/panels/PlatformsPanel.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export function PlatformsPanel({ client, notify }: PanelProps) {
2020
agentId: "default",
2121
name: "Telegram",
2222
botToken: "",
23-
webhookSecret: ""
23+
webhookSecret: "",
24+
parseMode: "HTML"
2425
});
2526

2627
async function load() {
@@ -39,7 +40,8 @@ export function PlatformsPanel({ client, notify }: PanelProps) {
3940
agentId: draft.agentId || undefined,
4041
name: draft.name,
4142
botToken: draft.botToken || undefined,
42-
webhookSecret: draft.webhookSecret || undefined
43+
webhookSecret: draft.webhookSecret || undefined,
44+
parseMode: draft.parseMode
4345
});
4446
if (draft.botToken) {
4547
const webhook = await client.setTelegramWebhook(
@@ -75,6 +77,16 @@ export function PlatformsPanel({ client, notify }: PanelProps) {
7577
await runAction(() => client.deleteTelegramWebhook(id), t("platforms.webhookDeleted"));
7678
}
7779

80+
async function updateParseMode(
81+
id: string,
82+
parseMode: TelegramIntegration["parseMode"]
83+
) {
84+
await runAction(
85+
() => client.updateTelegramIntegration(id, { parseMode }),
86+
t("platforms.integrationUpdated")
87+
);
88+
}
89+
7890
async function deleteIntegration(id: string) {
7991
await runAction(() => client.deleteTelegramIntegration(id), t("platforms.integrationDeleted"));
8092
}
@@ -119,6 +131,7 @@ export function PlatformsPanel({ client, notify }: PanelProps) {
119131

120132
<TelegramIntegrationList
121133
integrations={integrations}
134+
onUpdateParseMode={(id, parseMode) => void updateParseMode(id, parseMode)}
122135
onTest={(id) => void testIntegration(id)}
123136
onSetWebhook={(id) => void setWebhook(id)}
124137
onDeleteWebhook={(id) => void deleteWebhook(id)}

‎apps/admin-web/src/ui/panels/platforms/TelegramIntegrationForm.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { Plus } from "lucide-react";
2+
import type { TelegramIntegration } from "../../../api/types";
23
import { useI18n } from "../../i18n/I18nProvider";
34

45
export type TelegramIntegrationDraft = {
56
agentId: string;
67
name: string;
78
botToken: string;
89
webhookSecret: string;
10+
parseMode: TelegramIntegration["parseMode"];
911
};
1012

1113
type TelegramIntegrationFormProps = {
@@ -54,6 +56,22 @@ export function TelegramIntegrationForm({
5456
onChange={(event) => onDraftChange({ ...draft, webhookSecret: event.target.value })}
5557
/>
5658
</label>
59+
<label>
60+
{t("platforms.parseMode")}
61+
<select
62+
value={draft.parseMode}
63+
onChange={(event) =>
64+
onDraftChange({
65+
...draft,
66+
parseMode: event.target.value as TelegramIntegration["parseMode"]
67+
})
68+
}
69+
>
70+
<option value="HTML">HTML</option>
71+
<option value="MarkdownV2">MarkdownV2</option>
72+
<option value="none">{t("platforms.parseModeNone")}</option>
73+
</select>
74+
</label>
5775
<button className="primary-button" type="button" onClick={onSubmit}>
5876
<Plus size={16} />
5977
{t("common.add")}

‎apps/admin-web/src/ui/panels/platforms/TelegramIntegrationList.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { ToolbarButton } from "../../ToolbarButton";
66

77
type TelegramIntegrationListProps = {
88
integrations: TelegramIntegration[];
9+
onUpdateParseMode: (
10+
integrationId: string,
11+
parseMode: TelegramIntegration["parseMode"]
12+
) => void;
913
onTest: (integrationId: string) => void;
1014
onSetWebhook: (integrationId: string) => void;
1115
onDeleteWebhook: (integrationId: string) => void;
@@ -14,6 +18,7 @@ type TelegramIntegrationListProps = {
1418

1519
export function TelegramIntegrationList({
1620
integrations,
21+
onUpdateParseMode,
1722
onTest,
1823
onSetWebhook,
1924
onDeleteWebhook,
@@ -36,6 +41,22 @@ export function TelegramIntegrationList({
3641
</span>
3742
{integration.lastError && <span>{integration.lastError}</span>}
3843
</div>
44+
<label className="inline-select-label">
45+
{t("platforms.parseMode")}
46+
<select
47+
value={integration.parseMode}
48+
onChange={(event) =>
49+
onUpdateParseMode(
50+
integration.id,
51+
event.target.value as TelegramIntegration["parseMode"]
52+
)
53+
}
54+
>
55+
<option value="HTML">HTML</option>
56+
<option value="MarkdownV2">MarkdownV2</option>
57+
<option value="none">{t("platforms.parseModeNone")}</option>
58+
</select>
59+
</label>
3960
<StatusBadge value={integration.status} />
4061
<ToolbarButton
4162
label={t("platforms.testBot")}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export type TelegramParseMode = "none" | "HTML" | "MarkdownV2";
2+
3+
export const DEFAULT_TELEGRAM_PARSE_MODE: TelegramParseMode = "HTML";
4+
5+
export function normalizeTelegramParseMode(value: unknown): TelegramParseMode {
6+
return value === "none" || value === "HTML" || value === "MarkdownV2"
7+
? value
8+
: DEFAULT_TELEGRAM_PARSE_MODE;
9+
}
10+
11+
export function telegramParseModePayload(
12+
parseMode: TelegramParseMode
13+
): "HTML" | "MarkdownV2" | undefined {
14+
return parseMode === "none" ? undefined : parseMode;
15+
}
16+
17+
export function stripTelegramMarkup(text: string): string {
18+
return text
19+
.replace(/<br\s*\/?>/gi, "\n")
20+
.replace(/<\/(p|div|section|article|li|h[1-6])>/gi, "\n")
21+
.replace(/<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, "$2 ($1)")
22+
.replace(/<\/?(b|strong|i|em|u|s|strike|del|code|pre|blockquote|tg-spoiler)[^>]*>/gi, "")
23+
.replace(/<[^>]+>/g, "")
24+
.replace(/&lt;/g, "<")
25+
.replace(/&gt;/g, ">")
26+
.replace(/&amp;/g, "&")
27+
.replace(/&quot;/g, "\"")
28+
.replace(/&#39;/g, "'")
29+
.trim();
30+
}

‎src/adapters/telegram/outbound.ts‎

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { Env } from "../../shared/types/env";
22
import { callTelegramApi } from "./api";
33
import { resolveTelegramBotForAgent } from "./config";
4+
import {
5+
normalizeTelegramParseMode,
6+
stripTelegramMarkup,
7+
telegramParseModePayload
8+
} from "./formatting";
49

510
export type PlatformSendResult = {
611
ok: boolean;
@@ -20,22 +25,64 @@ export async function sendTelegramText(
2025
}
2126

2227
const chatId = conversationId.replace(/^telegram:/, "");
28+
const parseMode = normalizeTelegramParseMode(bot.integration?.config.parseMode);
29+
const requestBody: Record<string, unknown> = {
30+
chat_id: chatId,
31+
text,
32+
disable_web_page_preview: true
33+
};
34+
const parseModePayload = telegramParseModePayload(parseMode);
35+
if (parseModePayload) {
36+
requestBody.parse_mode = parseModePayload;
37+
}
38+
2339
try {
2440
const payload = await callTelegramApi<{ message_id?: number }>(
2541
bot.token,
2642
"sendMessage",
27-
{
28-
chat_id: chatId,
29-
text,
30-
disable_web_page_preview: true
31-
}
43+
requestBody
3244
);
3345

3446
return {
3547
ok: true,
3648
providerMessageId: payload.message_id ? String(payload.message_id) : undefined
3749
};
3850
} catch (error) {
51+
if (parseMode !== "none") {
52+
const fallback = await sendPlainTextFallback(bot.token, chatId, text).catch(
53+
(fallbackError) => ({
54+
ok: false as const,
55+
error: fallbackError instanceof Error
56+
? fallbackError.message
57+
: "Telegram fallback send failed"
58+
})
59+
);
60+
if (fallback.ok) {
61+
return fallback;
62+
}
63+
}
64+
3965
return { ok: false, error: error instanceof Error ? error.message : "Telegram send failed" };
4066
}
4167
}
68+
69+
async function sendPlainTextFallback(
70+
token: string,
71+
chatId: string,
72+
text: string
73+
): Promise<PlatformSendResult> {
74+
const payload = await callTelegramApi<{ message_id?: number }>(
75+
token,
76+
"sendMessage",
77+
{
78+
chat_id: chatId,
79+
text: stripTelegramMarkup(text),
80+
disable_web_page_preview: true
81+
}
82+
);
83+
84+
return {
85+
ok: true,
86+
providerMessageId: payload.message_id ? String(payload.message_id) : undefined
87+
};
88+
}

‎src/core/agent-context.ts‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { RegisteredTool } from "../tools/types";
22
import type { InternalMessage } from "../shared/types/internal-message";
33
import type { SelectedSkill } from "../skills/skill-selector";
44
import type { ModelMessage, ModelTool } from "./model/types";
5+
import type { TelegramParseMode } from "../adapters/telegram/formatting";
56

67
export type ConversationContextMessage = {
78
id: string;
@@ -11,6 +12,7 @@ export type ConversationContextMessage = {
1112

1213
export type AgentContextOptions = {
1314
timeZone?: string;
15+
telegramParseMode?: TelegramParseMode;
1416
};
1517

1618
export function createInitialModelMessages(
@@ -47,7 +49,7 @@ function createBaseInstructions(
4749
"Use tools when a task requires reading or writing the virtual filesystem, sending messages, or performing external actions.",
4850
"Use search.web to find candidate pages, then use web.fetch_page to read and verify pages when the user asks for details, latest information, or claims that need support.",
4951
"Do not force a search result count unless the user explicitly requests one; the system search settings control the normal result count.",
50-
platformFormatInstruction(message.platform),
52+
platformFormatInstruction(message.platform, options),
5153
"When the task is complete, answer concisely in the user's language.",
5254
"Do not claim a tool action succeeded unless a tool result confirms it."
5355
].join("\n");
@@ -88,12 +90,32 @@ function normalizeTimeZone(timeZone?: string): string {
8890
return timeZone?.trim() || "UTC";
8991
}
9092

91-
function platformFormatInstruction(platform: InternalMessage["platform"]): string {
93+
function platformFormatInstruction(
94+
platform: InternalMessage["platform"],
95+
options: AgentContextOptions
96+
): string {
9297
if (platform === "telegram") {
98+
if (options.telegramParseMode === "none") {
99+
return [
100+
"Telegram formatting: messages are sent as plain text.",
101+
"Avoid Markdown tables, HTML tags, and formatting that requires Telegram parse mode.",
102+
"Use short sections, numbered lines, plain URLs, and compact text."
103+
].join("\n");
104+
}
105+
106+
if (options.telegramParseMode === "MarkdownV2") {
107+
return [
108+
"Telegram formatting: messages are sent with Telegram parse_mode MarkdownV2.",
109+
"MarkdownV2 requires escaping reserved characters: _ * [ ] ( ) ~ ` > # + - = | { } . !",
110+
"Prefer simple bold/italic/code only when you can escape correctly; avoid tables and complex formatting."
111+
].join("\n");
112+
}
113+
93114
return [
94-
"Telegram formatting: messages are currently sent as plain text without parse_mode.",
95-
"Avoid Markdown tables, HTML tags, and formatting that requires Telegram Markdown parsing.",
96-
"Use short sections, numbered lines, plain URLs, and compact text."
115+
"Telegram formatting: messages are sent with Telegram parse_mode HTML by default.",
116+
"Use only Telegram-supported HTML tags when useful: <b>, <i>, <u>, <s>, <code>, <pre>, <a href=\"https://...\">text</a>.",
117+
"Escape literal <, >, and & when they are not part of supported tags.",
118+
"Avoid Markdown tables and MarkdownV2-specific syntax; use short sections, numbered lines, plain URLs, and compact text."
97119
].join("\n");
98120
}
99121

0 commit comments

Comments
 (0)