feat(telegram): send author avatar as richheader link-preview card

Serve an OG page at /api/richheader and use a small link-preview card
(avatar + author name) above the message instead of sending a photo.
This commit is contained in:
RhenCloud 2026-08-03 07:13:07 +08:00
parent be50d23319
commit fc2e2c2efa
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
5 changed files with 61 additions and 1 deletions

View file

@ -19,6 +19,15 @@ export class TelegramDriver implements PlatformDriver {
const token = env.TELEGRAM_TOKEN ?? ""; const token = env.TELEGRAM_TOKEN ?? "";
const text = renderNeutralMessage(message); const text = renderNeutralMessage(message);
const avatar = message.author?.iconUrl; const avatar = message.author?.iconUrl;
const richHeaderHost = env.TELEGRAM_RICH_HEADER_HOST ?? env.BASE_URL;
if (avatar && richHeaderHost) {
const params = new URLSearchParams();
if (message.author?.name) params.set("title", message.author.name);
if (message.title) params.set("content", message.title);
params.set("avatar", avatar);
const rhUrl = `${richHeaderHost.replace(/\/+$/, "")}/api/richheader?${params.toString()}`;
return sendMessage(token, chatId, text, target.topicId, rhUrl);
}
if (avatar) { if (avatar) {
return sendPhoto(token, chatId, smallAvatar(avatar), text, target.topicId); return sendPhoto(token, chatId, smallAvatar(avatar), text, target.topicId);
} }

View file

@ -87,6 +87,7 @@ export async function sendMessage(
chatId: string, chatId: string,
text: string, text: string,
topicId?: string, topicId?: string,
linkPreviewUrl?: string,
): Promise<SendResult> { ): Promise<SendResult> {
if (!token) { if (!token) {
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" }; return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
@ -95,8 +96,15 @@ export async function sendMessage(
chat_id: chatId, chat_id: chatId,
text, text,
parse_mode: "HTML", parse_mode: "HTML",
disable_web_page_preview: true, disable_web_page_preview: !linkPreviewUrl,
}; };
if (linkPreviewUrl) {
body.link_preview_options = {
url: linkPreviewUrl,
prefer_small_media: true,
show_above_text: true,
};
}
if (topicId) { if (topicId) {
body.message_thread_id = Number(topicId); body.message_thread_id = Number(topicId);
} }

View file

@ -10,6 +10,7 @@ import { createActionRoutes } from "./web/action-routes";
import { createAdminRoutes } from "./web/admin-routes"; import { createAdminRoutes } from "./web/admin-routes";
import { createLegalRoutes } from "./web/legal-routes"; import { createLegalRoutes } from "./web/legal-routes";
import { createHomeRoutes } from "./web/home-routes"; import { createHomeRoutes } from "./web/home-routes";
import { createRichHeaderRoutes } from "./web/richheader-routes";
import { loadConfig } from "./config"; import { loadConfig } from "./config";
import { log } from "./lib/log"; import { log } from "./lib/log";
@ -25,6 +26,7 @@ export function createServer(): Hono<{ Bindings: Env }> {
app.route("/", createLegalRoutes()); app.route("/", createLegalRoutes());
app.route("/", createHomeRoutes()); app.route("/", createHomeRoutes());
app.route("/admin", createAdminRoutes()); app.route("/admin", createAdminRoutes());
app.route("/api", createRichHeaderRoutes());
app.post("/webhook", async (c) => { app.post("/webhook", async (c) => {
const contentLength = Number(c.req.header("content-length") ?? 0); const contentLength = Number(c.req.header("content-length") ?? 0);

View file

@ -15,6 +15,7 @@ export interface Env {
DISCORD_APPLICATION_ID?: string; DISCORD_APPLICATION_ID?: string;
TELEGRAM_TOKEN?: string; TELEGRAM_TOKEN?: string;
TELEGRAM_WEBHOOK_SECRET?: string; TELEGRAM_WEBHOOK_SECRET?: string;
TELEGRAM_RICH_HEADER_HOST?: string;
ASSETS?: Fetcher; ASSETS?: Fetcher;
KV: KVNamespace; KV: KVNamespace;
DB: D1Database; DB: D1Database;

View file

@ -0,0 +1,40 @@
import { Hono } from "hono";
function esc(s: string): string {
return String(s)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export function createRichHeaderRoutes(): Hono {
const app = new Hono();
app.get("/richheader", (c) => {
const title = c.req.query("title") ?? "";
const content = c.req.query("content") ?? "";
const avatar = c.req.query("avatar") ?? "";
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${esc(title)}">
${content ? `<meta property="og:title" content="${esc(content)}">` : ""}
${avatar ? `<meta property="og:image" content="${esc(avatar)}">` : ""}
</head>
<body style="font-family: system-ui, -apple-system, sans-serif; text-align: center; padding: 40px; max-width: 600px; margin: 0 auto;">
${avatar ? `<img src="${esc(avatar)}" alt="Avatar" style="width: 128px; height: 128px; border-radius: 50%; margin-bottom: 20px;">` : ""}
${title ? `<h1 style="margin: 0 0 10px 0; color: #333;">${esc(title)}</h1>` : ""}
${content ? `<p style="margin: 0; color: #666;">${esc(content)}</p>` : ""}
<p style="margin-top: 40px; font-size: 14px; color: #999;">This page is used for Open Graph meta tags (richheader) only.</p>
</body>
</html>`;
return c.html(html);
});
return app;
}