mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat: edit existing messages for workflow run updates
This commit is contained in:
parent
4b3f5189a8
commit
78f0a4e28a
8 changed files with 187 additions and 16 deletions
|
|
@ -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<void> {
|
||||
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, {
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SendResult> {
|
||||
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<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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,9 @@ export interface SendResult {
|
|||
export interface PlatformDriver {
|
||||
readonly id: string;
|
||||
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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue