feat: edit existing messages for workflow run updates

This commit is contained in:
RhenCloud 2026-08-03 09:43:36 +08:00
parent 4b3f5189a8
commit 78f0a4e28a
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
8 changed files with 187 additions and 16 deletions

View file

@ -6,6 +6,7 @@ import { loadTranslations, type Translations } from "../lib/i18n";
import { recordSend } from "../lib/send-log"; import { recordSend } from "../lib/send-log";
import { loadGroups, groupAcceptsOwners } from "../web/groups"; import { loadGroups, groupAcceptsOwners } from "../web/groups";
import { getDriver } from "../drivers"; import { getDriver } from "../drivers";
import type { SendResult } from "../drivers/types";
export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise<void> { export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise<void> {
const langs = [...new Set(config.routes.map((r) => r.lang ?? "en"))]; 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(); const started = Date.now();
try { try {
const driver = getDriver(target); 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; const durationMs = Date.now() - started;
if (!result.ok) throw new Error(result.error ?? "Send failed"); if (!result.ok) throw new Error(result.error ?? "Send failed");
await recordSend(env.DB, { await recordSend(env.DB, {

View file

@ -1,6 +1,6 @@
import type { RouteTarget, Env, NeutralMessage } from "../../types"; import type { RouteTarget, Env, NeutralMessage } from "../../types";
import type { PlatformDriver, SendResult } from "../types"; import type { PlatformDriver, SendResult } from "../types";
import { sendMessage } from "./rest"; import { sendMessage, editMessage } from "./rest";
import { renderNeutralMessage } from "./render"; import { renderNeutralMessage } from "./render";
export class DiscordDriver implements PlatformDriver { export class DiscordDriver implements PlatformDriver {
@ -14,4 +14,18 @@ export class DiscordDriver implements PlatformDriver {
const token = env.DISCORD_TOKEN ?? ""; const token = env.DISCORD_TOKEN ?? "";
return sendMessage(token, channelId, renderNeutralMessage(message), target.threadId); return sendMessage(token, channelId, renderNeutralMessage(message), target.threadId);
} }
async edit(
message: NeutralMessage,
target: RouteTarget,
env: Env,
messageId: string,
): Promise<SendResult> {
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);
}
} }

View file

@ -7,20 +7,17 @@ interface DiscordMessage {
id?: string; id?: string;
} }
export async function sendMessage( async function request(
url: string,
method: string,
token: string, token: string,
channelId: string,
message: unknown, message: unknown,
threadId?: string, channelId: string,
): Promise<SendResult> { ): Promise<SendResult> {
const url = threadId
? `${DISCORD_API}/channels/${threadId}/messages`
: `${DISCORD_API}/channels/${channelId}/messages`;
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
try { try {
const res = await fetch(url, { const res = await fetch(url, {
method: "POST", method,
headers: { headers: {
Authorization: `Bot ${token}`, Authorization: `Bot ${token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
@ -80,3 +77,27 @@ export async function sendMessage(
return { ok: false, error: "Max retries exceeded", errorCode: "RETRIES", attempts: 3 }; return { ok: false, error: "Max retries exceeded", errorCode: "RETRIES", attempts: 3 };
} }
export async function sendMessage(
token: string,
channelId: string,
message: unknown,
threadId?: string,
): Promise<SendResult> {
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<SendResult> {
const base = threadId ?? channelId;
const url = `${DISCORD_API}/channels/${base}/messages/${messageId}`;
return request(url, "PATCH", token, message, channelId);
}

View file

@ -1,6 +1,6 @@
import type { RouteTarget, Env, NeutralMessage } from "../../types"; import type { RouteTarget, Env, NeutralMessage } from "../../types";
import type { PlatformDriver, SendResult } from "../types"; import type { PlatformDriver, SendResult } from "../types";
import { sendMessage, sendPhoto } from "./rest"; import { sendMessage, sendPhoto, editMessageText, editMessageCaption } from "./rest";
import { renderNeutralMessage } from "./render"; import { renderNeutralMessage } from "./render";
function smallAvatar(url: string): string { function smallAvatar(url: string): string {
@ -8,6 +8,14 @@ function smallAvatar(url: string): string {
return `${url}${sep}s=64`; 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 { export class TelegramDriver implements PlatformDriver {
readonly id = "telegram"; readonly id = "telegram";
@ -21,11 +29,7 @@ export class TelegramDriver implements PlatformDriver {
const avatar = message.author?.iconUrl; const avatar = message.author?.iconUrl;
const richHeaderHost = env.TELEGRAM_RICH_HEADER_HOST ?? env.BASE_URL; const richHeaderHost = env.TELEGRAM_RICH_HEADER_HOST ?? env.BASE_URL;
if (avatar && richHeaderHost) { if (avatar && richHeaderHost) {
const params = new URLSearchParams(); const rhUrl = richHeaderUrl(message, avatar, richHeaderHost);
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); return sendMessage(token, chatId, text, target.topicId, rhUrl);
} }
if (avatar) { if (avatar) {
@ -33,4 +37,28 @@ export class TelegramDriver implements PlatformDriver {
} }
return sendMessage(token, chatId, text, target.topicId); return sendMessage(token, chatId, text, target.topicId);
} }
async edit(
message: NeutralMessage,
target: RouteTarget,
env: Env,
messageId: string,
): Promise<SendResult> {
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);
}
} }

View file

@ -134,3 +134,56 @@ export async function sendPhoto(
} }
return post(token, "sendPhoto", body, chatId); return post(token, "sendPhoto", body, chatId);
} }
export async function editMessageText(
token: string,
chatId: string,
messageId: string,
text: string,
topicId?: string,
linkPreviewUrl?: string,
): Promise<SendResult> {
if (!token) {
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
}
const body: Record<string, unknown> = {
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<SendResult> {
if (!token) {
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
}
const body: Record<string, unknown> = {
chat_id: chatId,
message_id: messageId,
caption,
parse_mode: "HTML",
};
if (topicId) {
body.message_thread_id = Number(topicId);
}
return post(token, "editMessageCaption", body, chatId);
}

View file

@ -12,4 +12,9 @@ export interface SendResult {
export interface PlatformDriver { export interface PlatformDriver {
readonly id: string; readonly id: string;
send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult>; send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult>;
/**
* 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<SendResult>;
} }

View file

@ -100,6 +100,7 @@ export function formatWorkflowRun(
showEmoji: boolean, showEmoji: boolean,
): NeutralMessage { ): NeutralMessage {
const workflow = payload.workflow_run as { const workflow = payload.workflow_run as {
id?: number;
name?: string; name?: string;
conclusion?: string; conclusion?: string;
html_url?: string; html_url?: string;
@ -183,6 +184,8 @@ export function formatWorkflowRun(
url: workflow.html_url, url: workflow.html_url,
color: GITHUB_COLORS[colorKey], color: GITHUB_COLORS[colorKey],
fields, fields,
updateKey:
repo && workflow.id != null ? `workflow_run:${repo}:${workflow.id}` : undefined,
}, },
t, t,
repo, repo,

View file

@ -124,6 +124,12 @@ export interface NeutralMessage {
footer?: string; footer?: string;
timestamp?: string; timestamp?: string;
actions?: NeutralAction[]; 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 { export interface FormattedMessage {