From 50ae8c76078c0c46b0715a38c9bc41840ad7387d Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Mon, 3 Aug 2026 06:42:29 +0800 Subject: [PATCH] feat(telegram): render markdown and send author avatar photo --- src/__tests__/telegram.test.ts | 89 +++++++++++++++++++++++++++++++++- src/drivers/telegram/index.ts | 14 +++++- src/drivers/telegram/render.ts | 37 +++++++++----- src/drivers/telegram/rest.ts | 67 ++++++++++++++++++------- 4 files changed, 175 insertions(+), 32 deletions(-) diff --git a/src/__tests__/telegram.test.ts b/src/__tests__/telegram.test.ts index bd9841c..39ff470 100644 --- a/src/__tests__/telegram.test.ts +++ b/src/__tests__/telegram.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach } from "bun:test"; -import { sendMessage } from "../drivers/telegram/rest"; +import { sendMessage, sendPhoto } from "../drivers/telegram/rest"; import { renderNeutralMessage } from "../drivers/telegram/render"; +import { TelegramDriver } from "../drivers/telegram"; import type { NeutralMessage } from "../types"; function mockFetch(handler: (url: string, init?: RequestInit) => Response): void { @@ -38,6 +39,25 @@ describe("telegram renderNeutralMessage", () => { expect(out).not.toContain("acme"); expect(out).toContain("<script>alert(1)</script>"); }); + + it("converts Discord markdown to Telegram HTML", () => { + const out = renderNeutralMessage({ + title: "acme/widget#7: Add feature", + description: "**1** commit pushed to `main`\n[View comparison](https://github.com/acme/widget/compare/a...b)", + fields: [{ name: "Status", value: "**ok** and `done`" }], + }); + expect(out).toContain("1 commit pushed to main"); + expect(out).toContain('View comparison'); + expect(out).toContain("Status: ok and done"); + }); + + it("formats ISO timestamps into a readable UTC string", () => { + const out = renderNeutralMessage({ + title: "acme/widget: t", + timestamp: "2026-08-02T21:26:04.042Z", + }); + expect(out).toContain("2026-08-02 21:26 UTC"); + }); }); describe("telegram-rest sendMessage", () => { @@ -93,3 +113,70 @@ describe("telegram-rest sendMessage", () => { expect(result.errorCode).toBe("NO_TOKEN"); }); }); + +describe("telegram-rest sendPhoto", () => { + it("posts to the bot API with photo, caption and thread id", async () => { + let capturedUrl = ""; + let capturedInit: RequestInit | undefined; + mockFetch((url, init) => { + capturedUrl = url; + capturedInit = init; + return new Response(JSON.stringify({ ok: true, result: { message_id: 7 } }), { + status: 200, + }); + }); + + const result = await sendPhoto("t", "-100123", "https://avatars/1.png", "caption here", "999"); + + expect(result.ok).toBe(true); + expect(result.messageId).toBe("7"); + expect(capturedUrl).toBe("https://api.telegram.org/bott/sendPhoto"); + const body = JSON.parse(String(capturedInit!.body)) as Record; + expect(body.chat_id).toBe("-100123"); + expect(body.photo).toBe("https://avatars/1.png"); + expect(body.caption).toBe("caption here"); + expect(body.message_thread_id).toBe(999); + }); +}); + +describe("TelegramDriver", () => { + it("sends a small avatar photo (s=64) when the author has an icon", async () => { + let capturedInit: RequestInit | undefined; + mockFetch((url, init) => { + capturedInit = init; + return new Response(JSON.stringify({ ok: true, result: { message_id: 9 } }), { status: 200 }); + }); + + const driver = new TelegramDriver(); + const result = await driver.send( + { + title: "acme/widget: Add feature", + author: { name: "alice", iconUrl: "https://avatars.githubusercontent.com/u/1?v=4" }, + }, + { platform: "telegram", chatId: "-100123" }, + { TELEGRAM_TOKEN: "t", KV: {} as never, DB: {} as never, GITHUB_WEBHOOK_SECRET: "s" }, + ); + + expect(result.ok).toBe(true); + const body = JSON.parse(String(capturedInit!.body)) as Record; + expect(body.photo).toBe("https://avatars.githubusercontent.com/u/1?v=4&s=64"); + }); + + it("sends a plain message when the author has no icon", async () => { + let capturedUrl = ""; + mockFetch((url) => { + capturedUrl = url; + return new Response(JSON.stringify({ ok: true, result: { message_id: 10 } }), { status: 200 }); + }); + + const driver = new TelegramDriver(); + const result = await driver.send( + { title: "acme/widget: Add feature" }, + { platform: "telegram", chatId: "-100123" }, + { TELEGRAM_TOKEN: "t", KV: {} as never, DB: {} as never, GITHUB_WEBHOOK_SECRET: "s" }, + ); + + expect(result.ok).toBe(true); + expect(capturedUrl).toContain("/sendMessage"); + }); +}); diff --git a/src/drivers/telegram/index.ts b/src/drivers/telegram/index.ts index cbd47b4..0c10441 100644 --- a/src/drivers/telegram/index.ts +++ b/src/drivers/telegram/index.ts @@ -1,8 +1,13 @@ import type { RouteTarget, Env, NeutralMessage } from "../../types"; import type { PlatformDriver, SendResult } from "../types"; -import { sendMessage } from "./rest"; +import { sendMessage, sendPhoto } from "./rest"; import { renderNeutralMessage } from "./render"; +function smallAvatar(url: string): string { + const sep = url.includes("?") ? "&" : "?"; + return `${url}${sep}s=64`; +} + export class TelegramDriver implements PlatformDriver { readonly id = "telegram"; @@ -12,6 +17,11 @@ export class TelegramDriver implements PlatformDriver { return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" }; } const token = env.TELEGRAM_TOKEN ?? ""; - return sendMessage(token, chatId, renderNeutralMessage(message), target.topicId); + const text = renderNeutralMessage(message); + const avatar = message.author?.iconUrl; + if (avatar) { + return sendPhoto(token, chatId, smallAvatar(avatar), text, target.topicId); + } + return sendMessage(token, chatId, text, target.topicId); } } diff --git a/src/drivers/telegram/render.ts b/src/drivers/telegram/render.ts index 3845583..d8d787b 100644 --- a/src/drivers/telegram/render.ts +++ b/src/drivers/telegram/render.ts @@ -8,35 +8,50 @@ function esc(s: string): string { .replace(/"/g, """); } -function inlineUrl(url?: string, text?: string): string { - const label = esc(text ?? url ?? ""); - if (!url) return label; - return `${label}`; +function mdToHtml(s: string): string { + let out = esc(s); + out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => `${label}`); + out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); + out = out.replace(/`([^`]+)`/g, "$1"); + out = out.replace(/(^|[^*])\*([^*]+)\*/g, "$1$2"); + out = out.replace(/~~([^~]+)~~/g, "$1"); + return out; +} + +function formatTimestamp(ts?: string): string { + if (!ts) return ""; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ts; + const pad = (n: number): string => String(n).padStart(2, "0"); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`; } export function renderNeutralMessage(message: NeutralMessage): string { const parts: string[] = []; - const title = inlineUrl(message.url, message.title); + + const title = message.url + ? `${mdToHtml(message.title)}` + : mdToHtml(message.title); parts.push(`${title}`); if (message.author) { - const author = message.author.url - ? `${esc(message.author.name)}` - : esc(message.author.name); + const name = mdToHtml(message.author.name); + const author = message.author.url ? `${name}` : name; parts.push(`👤 ${author}`); } if (message.description) { - parts.push(esc(message.description)); + parts.push(mdToHtml(message.description)); } for (const field of message.fields ?? []) { - parts.push(`${esc(field.name)}: ${esc(field.value)}`); + parts.push(`${mdToHtml(field.name)}: ${mdToHtml(field.value)}`); } const meta: string[] = []; if (message.footer) meta.push(esc(message.footer)); - if (message.timestamp) meta.push(esc(message.timestamp)); + const ts = formatTimestamp(message.timestamp); + if (ts) meta.push(ts); if (meta.length > 0) { parts.push(`${meta.join(" · ")}`); } diff --git a/src/drivers/telegram/rest.ts b/src/drivers/telegram/rest.ts index 4c67f70..a02c91d 100644 --- a/src/drivers/telegram/rest.ts +++ b/src/drivers/telegram/rest.ts @@ -9,27 +9,13 @@ interface TelegramResponse { result?: { message_id?: number }; } -export async function sendMessage( +async function post( token: string, + method: string, + body: Record, chatId: string, - text: string, - topicId?: string, ): Promise { - if (!token) { - return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" }; - } - - const body: Record = { - chat_id: chatId, - text, - parse_mode: "HTML", - disable_web_page_preview: true, - }; - if (topicId) { - body.message_thread_id = Number(topicId); - } - - const url = `${TELEGRAM_API}/bot${token}/sendMessage`; + const url = `${TELEGRAM_API}/bot${token}/${method}`; let lastStatus = 0; let lastError = ""; @@ -95,3 +81,48 @@ export async function sendMessage( attempts: 3, }; } + +export async function sendMessage( + token: string, + chatId: string, + text: string, + topicId?: string, +): Promise { + if (!token) { + return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" }; + } + const body: Record = { + chat_id: chatId, + text, + parse_mode: "HTML", + disable_web_page_preview: true, + }; + if (topicId) { + body.message_thread_id = Number(topicId); + } + return post(token, "sendMessage", body, chatId); +} + +export async function sendPhoto( + token: string, + chatId: string, + photoUrl: string, + caption?: string, + topicId?: string, +): Promise { + if (!token) { + return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" }; + } + const body: Record = { + chat_id: chatId, + photo: photoUrl, + parse_mode: "HTML", + }; + if (caption) { + body.caption = caption; + } + if (topicId) { + body.message_thread_id = Number(topicId); + } + return post(token, "sendPhoto", body, chatId); +}