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 <noreply@anthropic.com>
This commit is contained in:
zerone0x
2026-01-25 13:18:05 +08:00
committed by Peter Steinberger
parent 43a6c5b77f
commit 92ab3f22dc
7 changed files with 84 additions and 9 deletions

View File

@@ -118,6 +118,8 @@ export type TelegramAccountConfig = {
reactionLevel?: "off" | "ack" | "minimal" | "extensive"; reactionLevel?: "off" | "ack" | "minimal" | "extensive";
/** Heartbeat visibility settings for this channel. */ /** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig; heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Controls whether link previews are shown in outbound messages. Default: true. */
linkPreview?: boolean;
}; };
export type TelegramTopicConfig = { export type TelegramTopicConfig = {

View File

@@ -125,6 +125,7 @@ export const TelegramAccountSchemaBase = z
reactionNotifications: z.enum(["off", "own", "all"]).optional(), reactionNotifications: z.enum(["off", "own", "all"]).optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema, heartbeat: ChannelHeartbeatVisibilitySchema,
linkPreview: z.boolean().optional(),
}) })
.strict(); .strict();

View File

@@ -151,6 +151,7 @@ export const dispatchTelegramMessage = async ({
tableMode, tableMode,
chunkMode, chunkMode,
onVoiceRecording: sendRecordVoice, onVoiceRecording: sendRecordVoice,
linkPreview: telegramCfg.linkPreview,
}); });
}, },
onError: (err, info) => { onError: (err, info) => {

View File

@@ -348,6 +348,7 @@ export const registerTelegramNativeCommands = ({
messageThreadId: resolvedThreadId, messageThreadId: resolvedThreadId,
tableMode, tableMode,
chunkMode, chunkMode,
linkPreview: telegramCfg.linkPreview,
}); });
}, },
onError: (err, info) => { onError: (err, info) => {

View File

@@ -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(),
}),
);
});
}); });

View File

@@ -36,8 +36,11 @@ export async function deliverReplies(params: {
chunkMode?: ChunkMode; chunkMode?: ChunkMode;
/** Callback invoked before sending a voice message to switch typing indicator. */ /** Callback invoked before sending a voice message to switch typing indicator. */
onVoiceRecording?: () => Promise<void> | void; onVoiceRecording?: () => Promise<void> | 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 chunkMode = params.chunkMode ?? "length";
const threadParams = buildTelegramThreadParams(messageThreadId); const threadParams = buildTelegramThreadParams(messageThreadId);
let hasReplied = false; let hasReplied = false;
@@ -85,6 +88,7 @@ export async function deliverReplies(params: {
messageThreadId, messageThreadId,
textMode: "html", textMode: "html",
plainText: chunk.text, plainText: chunk.text,
linkPreview,
}); });
if (replyToId && !hasReplied) { if (replyToId && !hasReplied) {
hasReplied = true; hasReplied = true;
@@ -180,6 +184,7 @@ export async function deliverReplies(params: {
messageThreadId, messageThreadId,
textMode: "html", textMode: "html",
plainText: chunk.text, plainText: chunk.text,
linkPreview,
}); });
if (replyToId && !hasReplied) { if (replyToId && !hasReplied) {
hasReplied = true; hasReplied = true;
@@ -248,17 +253,22 @@ async function sendTelegramText(
messageThreadId?: number; messageThreadId?: number;
textMode?: "markdown" | "html"; textMode?: "markdown" | "html";
plainText?: string; plainText?: string;
linkPreview?: boolean;
}, },
): Promise<number | undefined> { ): Promise<number | undefined> {
const baseParams = buildTelegramSendParams({ const baseParams = buildTelegramSendParams({
replyToMessageId: opts?.replyToMessageId, replyToMessageId: opts?.replyToMessageId,
messageThreadId: opts?.messageThreadId, 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 textMode = opts?.textMode ?? "markdown";
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
try { try {
const res = await bot.api.sendMessage(chatId, htmlText, { const res = await bot.api.sendMessage(chatId, htmlText, {
parse_mode: "HTML", parse_mode: "HTML",
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...baseParams, ...baseParams,
}); });
return res.message_id; return res.message_id;
@@ -268,6 +278,7 @@ async function sendTelegramText(
runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`); runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`);
const fallbackText = opts?.plainText ?? text; const fallbackText = opts?.plainText ?? text;
const res = await bot.api.sendMessage(chatId, fallbackText, { const res = await bot.api.sendMessage(chatId, fallbackText, {
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...baseParams, ...baseParams,
}); });
return res.message_id; return res.message_id;

View File

@@ -42,6 +42,8 @@ type TelegramSendOpts = {
messageThreadId?: number; messageThreadId?: number;
/** Inline keyboard buttons (reply markup). */ /** Inline keyboard buttons (reply markup). */
buttons?: Array<Array<{ text: string; callback_data: string }>>; buttons?: Array<Array<{ text: string; callback_data: string }>>;
/** Controls whether link previews are shown. Default: true (previews enabled). */
linkPreview?: boolean;
}; };
type TelegramSendResult = { type TelegramSendResult = {
@@ -198,20 +200,21 @@ export async function sendMessageTelegram(
}); });
const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode }); 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 ( const sendTelegramText = async (
rawText: string, rawText: string,
params?: Record<string, unknown>, params?: Record<string, unknown>,
fallbackText?: string, fallbackText?: string,
) => { ) => {
const htmlText = renderHtmlText(rawText); const htmlText = renderHtmlText(rawText);
const sendParams = params const sendParams = {
? { parse_mode: "HTML" as const,
parse_mode: "HTML" as const, ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...params, ...params,
} };
: {
parse_mode: "HTML" as const,
};
const res = await request(() => api.sendMessage(chatId, htmlText, sendParams), "message").catch( const res = await request(() => api.sendMessage(chatId, htmlText, sendParams), "message").catch(
async (err) => { async (err) => {
// Telegram rejects malformed HTML (e.g., unsupported tags or entities). // Telegram rejects malformed HTML (e.g., unsupported tags or entities).