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 { 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, {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue