feat: add Telegram push support with multi-target routes

Add a platform-aware route target system so a single route can forward
to several destinations at once (e.g. a Discord channel and a Telegram
group). Route.target becomes Route.targets[] with per-entry platform,
channelId/threadId for Discord and chatId/topicId for Telegram; the
legacy single-target format is normalized on load and accepted by the
admin API.

Implement the Telegram driver with HTML rendering and Bot API
sendMessage (chat_id + message_thread_id for topics, retry on 429/5xx),
plus /gh commands served over POST /telegram/webhook: login, logout,
comment, merge and close. The comment/merge/close commands resolve the
issue or PR from the replied-to notification message. OAuth binding now
stores a D1 telegram_links mapping and replies with a confirmation.

Sync the Telegram webhook from the scheduled trigger via setWebhook.
This commit is contained in:
RhenCloud 2026-08-03 05:17:59 +08:00
parent dcbe93be91
commit bd7a8f2632
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
39 changed files with 1165 additions and 162 deletions

View file

@ -37,59 +37,70 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
return matchRoute(route, event);
})
.map(async (route) => {
const target = route.target.threadId
? `${route.target.channelId}/${route.target.threadId}`
: route.target.channelId;
const targets = route.targets && route.targets.length > 0 ? route.targets : [];
if (targets.length === 0) return;
const base: {
ts: number;
routeId: string;
event: string;
repo: string | undefined;
target: string;
deliveryId: string | undefined;
actor: string | undefined;
action: string | undefined;
} = {
ts: Date.now(),
routeId: route.id,
event: event.event,
repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name,
target,
deliveryId: event.deliveryId,
actor: (event.payload.sender as { login?: string } | undefined)?.login,
action: (event.payload.action as string | undefined),
};
const tr = trMap.get(route.lang ?? "en")!;
const group = route.groupId ? groupById.get(route.groupId) : undefined;
const showEmoji = group?.emoji !== false;
const message = formatEvent(route, event, tr, showEmoji);
const started = Date.now();
try {
const tr = trMap.get(route.lang ?? "en")!;
const group = route.groupId ? groupById.get(route.groupId) : undefined;
const showEmoji = group?.emoji !== false;
const message = formatEvent(route, event, tr, showEmoji);
const driver = getDriver(route.target);
const result = await driver.send(message, route.target, env);
const durationMs = Date.now() - started;
if (!result.ok) throw new Error(result.error ?? "Send failed");
await recordSend(env.DB, {
...base,
ok: true,
status: result.status,
messageId: result.messageId,
platform: driver.id,
attempts: result.attempts,
durationMs,
errorCode: result.errorCode,
});
} catch (err) {
const durationMs = Date.now() - started;
await recordSend(env.DB, {
...base,
ok: false,
error: err instanceof Error ? err.message : String(err),
durationMs,
});
log.error({ routeId: route.id, err }, "Route failed");
for (const target of targets) {
const targetStr =
target.platform === "telegram"
? target.topicId
? `${target.chatId}/${target.topicId}`
: (target.chatId ?? "")
: target.threadId
? `${target.channelId}/${target.threadId}`
: (target.channelId ?? "");
const base: {
ts: number;
routeId: string;
event: string;
repo: string | undefined;
target: string;
deliveryId: string | undefined;
actor: string | undefined;
action: string | undefined;
} = {
ts: Date.now(),
routeId: route.id,
event: event.event,
repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name,
target: targetStr,
deliveryId: event.deliveryId,
actor: (event.payload.sender as { login?: string } | undefined)?.login,
action: (event.payload.action as string | undefined),
};
const started = Date.now();
try {
const driver = getDriver(target);
const 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, {
...base,
ok: true,
status: result.status,
messageId: result.messageId,
platform: driver.id,
attempts: result.attempts,
durationMs,
errorCode: result.errorCode,
});
} catch (err) {
const durationMs = Date.now() - started;
await recordSend(env.DB, {
...base,
ok: false,
error: err instanceof Error ? err.message : String(err),
durationMs,
});
log.error({ routeId: route.id, target: targetStr, err }, "Route failed");
}
}
});