From 92ab3f22dc0c5e8ae6285addec83ede757a69d60 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Sun, 25 Jan 2026 13:18:05 +0800 Subject: [PATCH] feat(telegram): add linkPreview config option Add channels.telegram.linkPreview config to control whether link previews are shown in outbound messages. When set to false, uses Telegram's link_preview_options.is_disabled to suppress URL previews. - Add linkPreview to TelegramAccountConfig type - Add Zod schema validation for linkPreview - Pass link_preview_options to sendMessage in send.ts and bot/delivery.ts - Propagate linkPreview config through deliverReplies callers - Add tests for link preview behavior Fixes #1675 Co-Authored-By: Claude --- src/config/types.telegram.ts | 2 + src/config/zod-schema.providers-core.ts | 1 + src/telegram/bot-message-dispatch.ts | 1 + src/telegram/bot-native-commands.ts | 1 + src/telegram/bot/delivery.test.ts | 56 +++++++++++++++++++++++++ src/telegram/bot/delivery.ts | 13 +++++- src/telegram/send.ts | 19 +++++---- 7 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 9fa51be9c..5d0b80e25 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -118,6 +118,8 @@ export type TelegramAccountConfig = { reactionLevel?: "off" | "ack" | "minimal" | "extensive"; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Controls whether link previews are shown in outbound messages. Default: true. */ + linkPreview?: boolean; }; export type TelegramTopicConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 2bf1876d7..4b1b9338a 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -125,6 +125,7 @@ export const TelegramAccountSchemaBase = z reactionNotifications: z.enum(["off", "own", "all"]).optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + linkPreview: z.boolean().optional(), }) .strict(); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 474b6136c..334c4c212 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -151,6 +151,7 @@ export const dispatchTelegramMessage = async ({ tableMode, chunkMode, onVoiceRecording: sendRecordVoice, + linkPreview: telegramCfg.linkPreview, }); }, onError: (err, info) => { diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index fc28e58e3..0f1cc1cb7 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -348,6 +348,7 @@ export const registerTelegramNativeCommands = ({ messageThreadId: resolvedThreadId, tableMode, chunkMode, + linkPreview: telegramCfg.linkPreview, }); }, onError: (err, info) => { diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index d9302062e..ca1b3f4cd 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -108,4 +108,60 @@ describe("deliverReplies", () => { }), ); }); + + it("includes link_preview_options when linkPreview is false", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 3, + chat: { id: "123" }, + }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await deliverReplies({ + replies: [{ text: "Check https://example.com" }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + linkPreview: false, + }); + + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.any(String), + expect.objectContaining({ + link_preview_options: { is_disabled: true }, + }), + ); + }); + + it("does not include link_preview_options when linkPreview is true", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 4, + chat: { id: "123" }, + }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await deliverReplies({ + replies: [{ text: "Check https://example.com" }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + linkPreview: true, + }); + + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.any(String), + expect.not.objectContaining({ + link_preview_options: expect.anything(), + }), + ); + }); }); diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index b0b296fa6..2d117d748 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -36,8 +36,11 @@ export async function deliverReplies(params: { chunkMode?: ChunkMode; /** Callback invoked before sending a voice message to switch typing indicator. */ onVoiceRecording?: () => Promise | void; + /** Controls whether link previews are shown. Default: true (previews enabled). */ + linkPreview?: boolean; }) { - const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId } = params; + const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId, linkPreview } = + params; const chunkMode = params.chunkMode ?? "length"; const threadParams = buildTelegramThreadParams(messageThreadId); let hasReplied = false; @@ -85,6 +88,7 @@ export async function deliverReplies(params: { messageThreadId, textMode: "html", plainText: chunk.text, + linkPreview, }); if (replyToId && !hasReplied) { hasReplied = true; @@ -180,6 +184,7 @@ export async function deliverReplies(params: { messageThreadId, textMode: "html", plainText: chunk.text, + linkPreview, }); if (replyToId && !hasReplied) { hasReplied = true; @@ -248,17 +253,22 @@ async function sendTelegramText( messageThreadId?: number; textMode?: "markdown" | "html"; plainText?: string; + linkPreview?: boolean; }, ): Promise { const baseParams = buildTelegramSendParams({ replyToMessageId: opts?.replyToMessageId, messageThreadId: opts?.messageThreadId, }); + // Add link_preview_options when link preview is disabled. + const linkPreviewEnabled = opts?.linkPreview ?? true; + const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; const textMode = opts?.textMode ?? "markdown"; const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); try { const res = await bot.api.sendMessage(chatId, htmlText, { parse_mode: "HTML", + ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), ...baseParams, }); return res.message_id; @@ -268,6 +278,7 @@ async function sendTelegramText( runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`); const fallbackText = opts?.plainText ?? text; const res = await bot.api.sendMessage(chatId, fallbackText, { + ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), ...baseParams, }); return res.message_id; diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 0274f0b72..b440bbe8a 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -42,6 +42,8 @@ type TelegramSendOpts = { messageThreadId?: number; /** Inline keyboard buttons (reply markup). */ buttons?: Array>; + /** Controls whether link previews are shown. Default: true (previews enabled). */ + linkPreview?: boolean; }; type TelegramSendResult = { @@ -198,20 +200,21 @@ export async function sendMessageTelegram( }); const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode }); + // Resolve link preview setting: explicit opt > config > default (enabled). + const linkPreviewEnabled = opts.linkPreview ?? account.config.linkPreview ?? true; + const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; + const sendTelegramText = async ( rawText: string, params?: Record, fallbackText?: string, ) => { const htmlText = renderHtmlText(rawText); - const sendParams = params - ? { - parse_mode: "HTML" as const, - ...params, - } - : { - parse_mode: "HTML" as const, - }; + const sendParams = { + parse_mode: "HTML" as const, + ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), + ...params, + }; const res = await request(() => api.sendMessage(chatId, htmlText, sendParams), "message").catch( async (err) => { // Telegram rejects malformed HTML (e.g., unsupported tags or entities).