From 78f0a4e28aedfc19705491c15eaaae6f72d309d4 Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Mon, 3 Aug 2026 09:43:36 +0800 Subject: [PATCH] feat: edit existing messages for workflow run updates --- src/core/dispatch.ts | 43 +++++++++++++++++++++++++++- src/drivers/discord/index.ts | 16 ++++++++++- src/drivers/discord/rest.ts | 37 ++++++++++++++++++------ src/drivers/telegram/index.ts | 40 ++++++++++++++++++++++---- src/drivers/telegram/rest.ts | 53 +++++++++++++++++++++++++++++++++++ src/drivers/types.ts | 5 ++++ src/formatters/workflow.ts | 3 ++ src/types.ts | 6 ++++ 8 files changed, 187 insertions(+), 16 deletions(-) diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 11720cb..d4b7c1f 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -6,6 +6,7 @@ import { loadTranslations, type Translations } from "../lib/i18n"; import { recordSend } from "../lib/send-log"; import { loadGroups, groupAcceptsOwners } from "../web/groups"; import { getDriver } from "../drivers"; +import type { SendResult } from "../drivers/types"; export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise { const langs = [...new Set(config.routes.map((r) => r.lang ?? "en"))]; @@ -78,7 +79,47 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En const started = Date.now(); try { const driver = getDriver(target); - const result = await driver.send(message, target, env); + let result: SendResult; + if (message.updateKey) { + const kvKey = `msg:${route.id}:${message.updateKey}:${targetStr}`; + const existingId = await env.KV.get(kvKey); + if (existingId) { + result = await driver.edit(message, target, env, existingId); + if (result.ok) { + await recordSend(env.DB, { + ...base, + ok: true, + status: result.status, + messageId: existingId, + platform: driver.id, + attempts: result.attempts, + durationMs: Date.now() - started, + errorCode: result.errorCode, + }); + continue; + } + if (/not modified/i.test(result.error ?? "")) { + await recordSend(env.DB, { + ...base, + ok: true, + status: result.status, + messageId: existingId, + platform: driver.id, + attempts: result.attempts, + durationMs: Date.now() - started, + errorCode: result.errorCode, + }); + continue; + } + await env.KV.delete(kvKey); + } + result = await driver.send(message, target, env); + if (result.ok && result.messageId) { + await env.KV.put(kvKey, result.messageId, { expirationTtl: 604800 }); + } + } else { + result = await driver.send(message, target, env); + } const durationMs = Date.now() - started; if (!result.ok) throw new Error(result.error ?? "Send failed"); await recordSend(env.DB, { diff --git a/src/drivers/discord/index.ts b/src/drivers/discord/index.ts index 2d68bfe..ee04cdc 100644 --- a/src/drivers/discord/index.ts +++ b/src/drivers/discord/index.ts @@ -1,6 +1,6 @@ import type { RouteTarget, Env, NeutralMessage } from "../../types"; import type { PlatformDriver, SendResult } from "../types"; -import { sendMessage } from "./rest"; +import { sendMessage, editMessage } from "./rest"; import { renderNeutralMessage } from "./render"; export class DiscordDriver implements PlatformDriver { @@ -14,4 +14,18 @@ export class DiscordDriver implements PlatformDriver { const token = env.DISCORD_TOKEN ?? ""; return sendMessage(token, channelId, renderNeutralMessage(message), target.threadId); } + + async edit( + message: NeutralMessage, + target: RouteTarget, + env: Env, + messageId: string, + ): Promise { + const channelId = target.channelId ?? ""; + if (!channelId) { + return { ok: false, error: "target.channelId is required", errorCode: "NO_TARGET" }; + } + const token = env.DISCORD_TOKEN ?? ""; + return editMessage(token, channelId, messageId, renderNeutralMessage(message), target.threadId); + } } diff --git a/src/drivers/discord/rest.ts b/src/drivers/discord/rest.ts index 8409255..882b252 100644 --- a/src/drivers/discord/rest.ts +++ b/src/drivers/discord/rest.ts @@ -7,20 +7,17 @@ interface DiscordMessage { id?: string; } -export async function sendMessage( +async function request( + url: string, + method: string, token: string, - channelId: string, message: unknown, - threadId?: string, + channelId: string, ): Promise { - const url = threadId - ? `${DISCORD_API}/channels/${threadId}/messages` - : `${DISCORD_API}/channels/${channelId}/messages`; - for (let attempt = 0; attempt < 3; attempt++) { try { const res = await fetch(url, { - method: "POST", + method, headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json", @@ -80,3 +77,27 @@ export async function sendMessage( return { ok: false, error: "Max retries exceeded", errorCode: "RETRIES", attempts: 3 }; } + +export async function sendMessage( + token: string, + channelId: string, + message: unknown, + threadId?: string, +): Promise { + const url = threadId + ? `${DISCORD_API}/channels/${threadId}/messages` + : `${DISCORD_API}/channels/${channelId}/messages`; + return request(url, "POST", token, message, channelId); +} + +export async function editMessage( + token: string, + channelId: string, + messageId: string, + message: unknown, + threadId?: string, +): Promise { + const base = threadId ?? channelId; + const url = `${DISCORD_API}/channels/${base}/messages/${messageId}`; + return request(url, "PATCH", token, message, channelId); +} diff --git a/src/drivers/telegram/index.ts b/src/drivers/telegram/index.ts index 29464aa..722e8ef 100644 --- a/src/drivers/telegram/index.ts +++ b/src/drivers/telegram/index.ts @@ -1,6 +1,6 @@ import type { RouteTarget, Env, NeutralMessage } from "../../types"; import type { PlatformDriver, SendResult } from "../types"; -import { sendMessage, sendPhoto } from "./rest"; +import { sendMessage, sendPhoto, editMessageText, editMessageCaption } from "./rest"; import { renderNeutralMessage } from "./render"; function smallAvatar(url: string): string { @@ -8,6 +8,14 @@ function smallAvatar(url: string): string { return `${url}${sep}s=64`; } +function richHeaderUrl(message: NeutralMessage, avatar: string, host: string): string { + 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); + return `${host.replace(/\/+$/, "")}/api/richheader?${params.toString()}`; +} + export class TelegramDriver implements PlatformDriver { readonly id = "telegram"; @@ -21,11 +29,7 @@ export class TelegramDriver implements PlatformDriver { 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()}`; + const rhUrl = richHeaderUrl(message, avatar, richHeaderHost); return sendMessage(token, chatId, text, target.topicId, rhUrl); } if (avatar) { @@ -33,4 +37,28 @@ export class TelegramDriver implements PlatformDriver { } return sendMessage(token, chatId, text, target.topicId); } + + async edit( + message: NeutralMessage, + target: RouteTarget, + env: Env, + messageId: string, + ): Promise { + const chatId = target.chatId ?? ""; + if (!chatId) { + return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" }; + } + const token = env.TELEGRAM_TOKEN ?? ""; + const text = renderNeutralMessage(message); + const avatar = message.author?.iconUrl; + const richHeaderHost = env.TELEGRAM_RICH_HEADER_HOST ?? env.BASE_URL; + if (avatar && richHeaderHost) { + const rhUrl = richHeaderUrl(message, avatar, richHeaderHost); + return editMessageText(token, chatId, messageId, text, target.topicId, rhUrl); + } + if (avatar) { + return editMessageCaption(token, chatId, messageId, text, target.topicId); + } + return editMessageText(token, chatId, messageId, text, target.topicId); + } } diff --git a/src/drivers/telegram/rest.ts b/src/drivers/telegram/rest.ts index 90d3c50..faf9245 100644 --- a/src/drivers/telegram/rest.ts +++ b/src/drivers/telegram/rest.ts @@ -134,3 +134,56 @@ export async function sendPhoto( } return post(token, "sendPhoto", body, chatId); } + +export async function editMessageText( + token: string, + chatId: string, + messageId: string, + text: string, + topicId?: string, + linkPreviewUrl?: string, +): Promise { + if (!token) { + return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" }; + } + const body: Record = { + chat_id: chatId, + message_id: messageId, + text, + parse_mode: "HTML", + disable_web_page_preview: !linkPreviewUrl, + }; + if (linkPreviewUrl) { + body.link_preview_options = { + url: linkPreviewUrl, + prefer_small_media: true, + show_above_text: true, + }; + } + if (topicId) { + body.message_thread_id = Number(topicId); + } + return post(token, "editMessageText", body, chatId); +} + +export async function editMessageCaption( + token: string, + chatId: string, + messageId: 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, + message_id: messageId, + caption, + parse_mode: "HTML", + }; + if (topicId) { + body.message_thread_id = Number(topicId); + } + return post(token, "editMessageCaption", body, chatId); +} diff --git a/src/drivers/types.ts b/src/drivers/types.ts index ddcf0d1..a74c596 100644 --- a/src/drivers/types.ts +++ b/src/drivers/types.ts @@ -12,4 +12,9 @@ export interface SendResult { export interface PlatformDriver { readonly id: string; send(message: NeutralMessage, target: RouteTarget, env: Env): Promise; + /** + * Edit an already-sent message in place (e.g. workflow run progress updates). + * Must be implemented by drivers that support message updates. + */ + edit(message: NeutralMessage, target: RouteTarget, env: Env, messageId: string): Promise; } diff --git a/src/formatters/workflow.ts b/src/formatters/workflow.ts index d6aba3d..4cf569b 100644 --- a/src/formatters/workflow.ts +++ b/src/formatters/workflow.ts @@ -100,6 +100,7 @@ export function formatWorkflowRun( showEmoji: boolean, ): NeutralMessage { const workflow = payload.workflow_run as { + id?: number; name?: string; conclusion?: string; html_url?: string; @@ -183,6 +184,8 @@ export function formatWorkflowRun( url: workflow.html_url, color: GITHUB_COLORS[colorKey], fields, + updateKey: + repo && workflow.id != null ? `workflow_run:${repo}:${workflow.id}` : undefined, }, t, repo, diff --git a/src/types.ts b/src/types.ts index ad379c7..1e57ca6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -124,6 +124,12 @@ export interface NeutralMessage { footer?: string; timestamp?: string; actions?: NeutralAction[]; + /** + * Stable key identifying a message chain that should be updated in place + * (e.g. workflow run progress). When set, subsequent events edit the + * previously sent message instead of sending a new one. + */ + updateKey?: string; } export interface FormattedMessage {