mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
refactor: replace Discord Gateway DO with Interaction Endpoint
This commit is contained in:
parent
9bb9cb1444
commit
d540d465d1
27 changed files with 834 additions and 964 deletions
|
|
@ -42,7 +42,6 @@ function createEnv(overrides: Partial<Env> = {}): Env {
|
|||
return {
|
||||
GITHUB_WEBHOOK_SECRET: "secret",
|
||||
KV: createMockKV(),
|
||||
DISCORD_GATEWAY: {} as DurableObjectNamespace,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { sendMessage } from "../discord-rest";
|
||||
import { dispatchEvent, isGatewayEnabled } from "../discord";
|
||||
import { dispatchEvent } from "../discord";
|
||||
import type { Env, Route } from "../types";
|
||||
|
||||
function mockFetch(handler: (url: string, init?: RequestInit) => Response): void {
|
||||
|
|
@ -12,7 +12,6 @@ function createEnv(overrides: Partial<Env> = {}): Env {
|
|||
return {
|
||||
GITHUB_WEBHOOK_SECRET: "secret",
|
||||
KV: {} as KVNamespace,
|
||||
DISCORD_GATEWAY: {} as DurableObjectNamespace,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -68,14 +67,6 @@ describe("discord-rest sendMessage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("isGatewayEnabled", () => {
|
||||
it("is enabled only when set to true", () => {
|
||||
expect(isGatewayEnabled(createEnv({ DISCORD_GATEWAY_ENABLED: "true" }))).toBe(true);
|
||||
expect(isGatewayEnabled(createEnv({ DISCORD_GATEWAY_ENABLED: "false" }))).toBe(false);
|
||||
expect(isGatewayEnabled(createEnv())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchEvent fallback routing", () => {
|
||||
function createMockKV(): KVNamespace {
|
||||
const store = new Map<string, string>();
|
||||
|
|
|
|||
|
|
@ -1,806 +0,0 @@
|
|||
import { log } from "./log";
|
||||
import { sendMessage } from "./discord-rest";
|
||||
import {
|
||||
getOAuthURL,
|
||||
commentAsUser,
|
||||
getCommentAsUser,
|
||||
editCommentAsUser,
|
||||
deleteCommentAsUser,
|
||||
mergePullRequestAsUser,
|
||||
closePullRequestAsUser,
|
||||
} from "./github-oauth";
|
||||
import { getDiscordLink, removeDiscordLink } from "./token-store";
|
||||
import type { Env } from "./types";
|
||||
|
||||
interface SendMessageBody {
|
||||
channelId: string;
|
||||
message: unknown;
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
const DISCORD_API = "https://discord.com/api/v10";
|
||||
const GATEWAY_URL = "https://gateway.discord.gg/?v=10&encoding=json";
|
||||
const BASE_RECONNECT_DELAY = 1000;
|
||||
const MAX_RECONNECT_DELAY = 60_000;
|
||||
const ALARM_INTERVAL = 30;
|
||||
|
||||
// Discord interaction protocol constants
|
||||
const INTERACTION_TYPE = { COMMAND: 2, BUTTON: 3, MODAL_SUBMIT: 5 } as const;
|
||||
const CALLBACK_TYPE = { MESSAGE: 4, DEFERRED_MESSAGE: 5, MODAL: 9 } as const;
|
||||
const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const;
|
||||
const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const;
|
||||
const EPHEMERAL = 64;
|
||||
|
||||
// Right-click (message context-menu) command names → operation.
|
||||
const MSG_CMD_ADD = "GitHub: 添加评论";
|
||||
const MSG_CMD_EDIT = "GitHub: 编辑评论";
|
||||
const MSG_CMD_DEL = "GitHub: 删除评论";
|
||||
|
||||
// Modal custom_id encodings (delimiter '|' never appears in owner/repo).
|
||||
const MODAL_ADD = "ghc|add|"; // ghc|add|owner|repo|issueNumber
|
||||
const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId
|
||||
|
||||
// PR notification button custom_id encodings.
|
||||
const BTN_MERGE = "ghpr|merge|"; // ghpr|merge|owner|repo|pullNumber
|
||||
const BTN_CLOSE = "ghpr|close|"; // ghpr|close|owner|repo|pullNumber
|
||||
|
||||
const APP_COMMANDS = [
|
||||
{
|
||||
name: "gh",
|
||||
type: COMMAND_TYPE.CHAT_INPUT,
|
||||
description: "GitHub 集成",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "login",
|
||||
description: "绑定你的 GitHub 账号以用本人身份评论",
|
||||
},
|
||||
{ type: OPTION_TYPE.SUB_COMMAND, name: "logout", description: "解绑你的 GitHub 账号" },
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND_GROUP,
|
||||
name: "comment",
|
||||
description: "对 issue/PR 评论进行增删改",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "add",
|
||||
description: "在 issue/PR 下新增评论",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.STRING,
|
||||
name: "link",
|
||||
description: "issue/PR 链接",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "edit",
|
||||
description: "编辑一条评论",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.STRING,
|
||||
name: "link",
|
||||
description: "评论链接(含 #issuecomment-)",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "del",
|
||||
description: "删除一条评论",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.STRING,
|
||||
name: "link",
|
||||
description: "评论链接(含 #issuecomment-)",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: MSG_CMD_ADD, type: COMMAND_TYPE.MESSAGE },
|
||||
{ name: MSG_CMD_EDIT, type: COMMAND_TYPE.MESSAGE },
|
||||
{ name: MSG_CMD_DEL, type: COMMAND_TYPE.MESSAGE },
|
||||
];
|
||||
|
||||
// Comment link (has the comment id); check this BEFORE the plain issue regex.
|
||||
const GITHUB_COMMENT_RE =
|
||||
/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/\d+#issuecomment-(\d+)/;
|
||||
const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/;
|
||||
|
||||
export class DiscordGateway {
|
||||
private state: DurableObjectState;
|
||||
private env: Env;
|
||||
private socket: WebSocket | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private heartbeatInterval: number | null = null;
|
||||
private lastSequence: number | null = null;
|
||||
private sessionId: string | null = null;
|
||||
private token: string | null = null;
|
||||
private connecting = false;
|
||||
private reconnectAttempt = 0;
|
||||
private applicationId: string | null = null;
|
||||
private registeredGuilds = new Set<string>();
|
||||
|
||||
constructor(state: DurableObjectState, env: Env) {
|
||||
this.state = state;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const body = (await request.json()) as {
|
||||
action: string;
|
||||
token?: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
switch (body.action) {
|
||||
case "start": {
|
||||
this.token = body.token as string;
|
||||
await this.state.storage.put("token", this.token);
|
||||
if (this.connecting || this.socket) {
|
||||
return new Response(JSON.stringify({ ok: true, status: "already_connected" }));
|
||||
}
|
||||
await this.connect();
|
||||
await this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL * 1000);
|
||||
return new Response(JSON.stringify({ ok: true }));
|
||||
}
|
||||
case "send": {
|
||||
const { channelId, message, threadId } = body as unknown as SendMessageBody;
|
||||
const result = await this.postMessage(channelId, message, threadId);
|
||||
return new Response(JSON.stringify(result));
|
||||
}
|
||||
case "status": {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
connected: this.socket?.readyState === WebSocket.OPEN,
|
||||
sessionId: this.sessionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return new Response(JSON.stringify({ error: "Unknown action" }), { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
if (!this.token) return;
|
||||
if (this.connecting || this.socket) return;
|
||||
this.connecting = true;
|
||||
log.info("Connecting to Discord Gateway");
|
||||
|
||||
try {
|
||||
const resp = await fetch(GATEWAY_URL, {
|
||||
headers: { Upgrade: "websocket" },
|
||||
});
|
||||
const ws = resp.webSocket;
|
||||
if (!ws) {
|
||||
this.connecting = false;
|
||||
log.error({ status: resp.status }, "Gateway did not return a WebSocket");
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.accept();
|
||||
this.socket = ws;
|
||||
this.connecting = false;
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
this.handleMessage(event.data as string);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
this.socket = null;
|
||||
this.clearHeartbeat();
|
||||
log.warn(
|
||||
{ code: (event as CloseEvent).code, reason: (event as CloseEvent).reason },
|
||||
"Gateway disconnected, scheduling reconnect via alarm",
|
||||
);
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
log.error(
|
||||
{ err: String((event as ErrorEvent).message ?? event) },
|
||||
"Gateway WebSocket error",
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
this.connecting = false;
|
||||
log.error({ err: String(err) }, "Failed to connect to Gateway");
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
const delay = Math.min(BASE_RECONNECT_DELAY * 2 ** this.reconnectAttempt, MAX_RECONNECT_DELAY);
|
||||
this.reconnectAttempt++;
|
||||
this.state.storage.setAlarm(Date.now() + delay);
|
||||
}
|
||||
|
||||
private handleMessage(data: string): void {
|
||||
let msg: { op: number; d: unknown; s: number | null; t: string | null };
|
||||
try {
|
||||
msg = JSON.parse(data) as {
|
||||
op: number;
|
||||
d: unknown;
|
||||
s: number | null;
|
||||
t: string | null;
|
||||
};
|
||||
} catch {
|
||||
log.warn("Gateway received malformed frame");
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.s !== null) this.lastSequence = msg.s;
|
||||
|
||||
switch (msg.op) {
|
||||
case 0:
|
||||
this.reconnectAttempt = 0;
|
||||
this.handleDispatch(msg.t!, msg.d);
|
||||
break;
|
||||
case 1:
|
||||
// Heartbeat request from Discord — respond immediately
|
||||
this.sendHeartbeat();
|
||||
break;
|
||||
case 10:
|
||||
this.handleHello(msg.d as { heartbeat_interval: number });
|
||||
break;
|
||||
case 11:
|
||||
break;
|
||||
case 7:
|
||||
log.warn("Gateway requested reconnect (op 7)");
|
||||
this.reconnect();
|
||||
break;
|
||||
case 9:
|
||||
log.warn({ resumable: msg.d }, "Gateway Invalid Session (op 9)");
|
||||
this.lastSequence = null;
|
||||
this.sessionId = null;
|
||||
// Discord asks to wait 1-5s before a fresh identify
|
||||
setTimeout(() => this.identify(), 2000 + Math.floor(Math.random() * 3000));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleHello(d: { heartbeat_interval: number }): void {
|
||||
log.info({ heartbeatInterval: d.heartbeat_interval }, "Gateway HELLO received");
|
||||
this.heartbeatInterval = d.heartbeat_interval;
|
||||
this.heartbeat();
|
||||
this.identify();
|
||||
}
|
||||
|
||||
private heartbeat(): void {
|
||||
this.clearHeartbeat();
|
||||
this.sendHeartbeat();
|
||||
if (this.heartbeatInterval) {
|
||||
this.heartbeatTimer = setTimeout(() => this.heartbeat(), this.heartbeatInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private sendHeartbeat(): void {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
||||
this.socket.send(JSON.stringify({ op: 1, d: this.lastSequence }));
|
||||
}
|
||||
|
||||
private clearHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearTimeout(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private identify(): void {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN || !this.token) {
|
||||
log.warn(
|
||||
{ hasSocket: !!this.socket, readyState: this.socket?.readyState ?? null },
|
||||
"Cannot identify",
|
||||
);
|
||||
return;
|
||||
}
|
||||
log.info("Sending IDENTIFY");
|
||||
this.socket.send(
|
||||
JSON.stringify({
|
||||
op: 2,
|
||||
d: {
|
||||
token: this.token,
|
||||
// GUILDS only — interactions are delivered regardless of intents,
|
||||
// and GUILDS lets us receive GUILD_CREATE to register slash commands.
|
||||
intents: 1 << 0,
|
||||
properties: {
|
||||
os: "linux",
|
||||
browser: "webhooker",
|
||||
device: "webhooker",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleDispatch(event: string, data: unknown): void {
|
||||
const d = data as Record<string, unknown>;
|
||||
switch (event) {
|
||||
case "READY":
|
||||
this.sessionId = d.session_id as string;
|
||||
this.applicationId = (d.application as { id?: string })?.id ?? this.applicationId;
|
||||
log.info(
|
||||
{ user: (d.user as { username?: string })?.username, appId: this.applicationId },
|
||||
"Gateway READY",
|
||||
);
|
||||
break;
|
||||
case "GUILD_CREATE": {
|
||||
const guildId = d.id as string | undefined;
|
||||
if (guildId) {
|
||||
this.registerGuildCommands(guildId).catch((err) =>
|
||||
log.error({ err: String(err), guildId }, "Failed to register guild commands"),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "INTERACTION_CREATE":
|
||||
this.handleInteraction(d).catch((err) =>
|
||||
log.error({ err: String(err) }, "Interaction handler failed"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private botToken(): string {
|
||||
return this.token ?? this.env.DISCORD_TOKEN ?? "";
|
||||
}
|
||||
|
||||
/** Register the slash + message commands for a guild (instant availability). */
|
||||
private async registerGuildCommands(guildId: string): Promise<void> {
|
||||
if (!this.applicationId || this.registeredGuilds.has(guildId)) return;
|
||||
const res = await fetch(
|
||||
`${DISCORD_API}/applications/${this.applicationId}/guilds/${guildId}/commands`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bot ${this.botToken()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(APP_COMMANDS),
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
this.registeredGuilds.add(guildId);
|
||||
log.info({ guildId }, "Registered guild application commands");
|
||||
} else {
|
||||
const err = await res.text();
|
||||
log.warn({ guildId, status: res.status, err }, "Command registration failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleInteraction(d: Record<string, unknown>): Promise<void> {
|
||||
const interaction = d as {
|
||||
id: string;
|
||||
token: string;
|
||||
type: number;
|
||||
guild_id?: string;
|
||||
channel_id?: string;
|
||||
member?: { user?: { id?: string } };
|
||||
user?: { id?: string };
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
const userId = interaction.member?.user?.id ?? interaction.user?.id ?? null;
|
||||
const id = interaction.id;
|
||||
const token = interaction.token;
|
||||
|
||||
if (interaction.type === INTERACTION_TYPE.BUTTON) {
|
||||
const data = interaction.data as {
|
||||
custom_id?: string;
|
||||
message?: { id?: string };
|
||||
};
|
||||
return this.handleButton(
|
||||
id,
|
||||
token,
|
||||
userId,
|
||||
interaction.channel_id,
|
||||
data.message?.id,
|
||||
data.custom_id,
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction.type === INTERACTION_TYPE.COMMAND) {
|
||||
const data = interaction.data as {
|
||||
name?: string;
|
||||
type?: number;
|
||||
target_id?: string;
|
||||
options?: Array<{
|
||||
name: string;
|
||||
options?: Array<{
|
||||
name: string;
|
||||
value?: string;
|
||||
options?: Array<{ name: string; value?: string }>;
|
||||
}>;
|
||||
}>;
|
||||
resolved?: {
|
||||
messages?: Record<string, { embeds?: Array<{ url?: string }>; content?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
// Right-click (message context-menu) commands.
|
||||
if (data.type === COMMAND_TYPE.MESSAGE) {
|
||||
const op =
|
||||
data.name === MSG_CMD_ADD
|
||||
? "add"
|
||||
: data.name === MSG_CMD_EDIT
|
||||
? "edit"
|
||||
: data.name === MSG_CMD_DEL
|
||||
? "del"
|
||||
: null;
|
||||
if (!op) return;
|
||||
const target = data.target_id ? data.resolved?.messages?.[data.target_id] : undefined;
|
||||
const source = target?.embeds?.[0]?.url ?? target?.content ?? "";
|
||||
return this.commentOp(id, token, userId, op, source);
|
||||
}
|
||||
|
||||
// Slash command /gh ...
|
||||
if (data.name === "gh" && data.type === COMMAND_TYPE.CHAT_INPUT) {
|
||||
const top = data.options?.[0];
|
||||
if (top?.name === "login") return this.cmdLogin(id, token, userId);
|
||||
if (top?.name === "logout") return this.cmdLogout(id, token, userId);
|
||||
if (top?.name === "comment") {
|
||||
const sub = top.options?.[0];
|
||||
const op =
|
||||
sub?.name === "add"
|
||||
? "add"
|
||||
: sub?.name === "edit"
|
||||
? "edit"
|
||||
: sub?.name === "del"
|
||||
? "del"
|
||||
: null;
|
||||
if (!op) return;
|
||||
const link = sub?.options?.find((o) => o.name === "link")?.value ?? "";
|
||||
return this.commentOp(id, token, userId, op, link);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.type === INTERACTION_TYPE.MODAL_SUBMIT) {
|
||||
return this.modalSubmit(id, token, userId, interaction.data);
|
||||
}
|
||||
}
|
||||
|
||||
/** Respond to an interaction with an ephemeral text message. */
|
||||
private async respond(id: string, token: string, content: string): Promise<void> {
|
||||
await this.interactionCallback(id, token, {
|
||||
type: CALLBACK_TYPE.MESSAGE,
|
||||
data: { content, flags: EPHEMERAL },
|
||||
});
|
||||
}
|
||||
|
||||
private async interactionCallback(id: string, token: string, body: unknown): Promise<void> {
|
||||
const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/callback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log.warn({ status: res.status, err }, "Interaction callback failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the deferred (ephemeral) response body with the final result. */
|
||||
private async updateOriginal(id: string, token: string, content: string): Promise<void> {
|
||||
const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/messages/@original`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log.warn({ status: res.status, err }, "Failed to update interaction response");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PR notification buttons: merge or close the PR as the clicker's linked
|
||||
* GitHub account. The clicker must have run `/gh login` first.
|
||||
*/
|
||||
private async handleButton(
|
||||
id: string,
|
||||
token: string,
|
||||
userId: string | null,
|
||||
channelId: string | undefined,
|
||||
messageId: string | undefined,
|
||||
customId: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。");
|
||||
const githubUserId = await getDiscordLink(this.env.KV, userId);
|
||||
if (!githubUserId) {
|
||||
return this.respond(id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||
}
|
||||
|
||||
let op: "merge" | "close";
|
||||
let rest: string;
|
||||
if (customId?.startsWith(BTN_MERGE)) {
|
||||
op = "merge";
|
||||
rest = customId.slice(BTN_MERGE.length);
|
||||
} else if (customId?.startsWith(BTN_CLOSE)) {
|
||||
op = "close";
|
||||
rest = customId.slice(BTN_CLOSE.length);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const [owner, repo, number] = rest.split("|");
|
||||
if (!owner || !repo || !number) return;
|
||||
|
||||
// Acknowledge first (deferred, ephemeral) so the clicker sees a spinner
|
||||
// while the GitHub API call runs.
|
||||
await this.interactionCallback(id, token, {
|
||||
type: CALLBACK_TYPE.DEFERRED_MESSAGE,
|
||||
data: { flags: EPHEMERAL },
|
||||
});
|
||||
|
||||
try {
|
||||
if (op === "merge") {
|
||||
await mergePullRequestAsUser(this.env.KV, githubUserId, owner, repo, Number(number));
|
||||
} else {
|
||||
await closePullRequestAsUser(this.env.KV, githubUserId, owner, repo, Number(number));
|
||||
}
|
||||
// Remove the buttons from the notification so nobody double-clicks.
|
||||
if (channelId && messageId) {
|
||||
await fetch(`${DISCORD_API}/channels/${channelId}/messages/${messageId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bot ${this.botToken()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ components: [] }),
|
||||
}).catch((err) => log.warn({ err: String(err) }, "Failed to strip PR buttons"));
|
||||
}
|
||||
const label = op === "merge" ? "合并" : "关闭";
|
||||
await this.updateOriginal(id, token, `✅ 已${label} PR ${owner}/${repo}#${number}`);
|
||||
} catch (err) {
|
||||
await this.updateOriginal(id, token, this.errText(err));
|
||||
}
|
||||
}
|
||||
|
||||
private async cmdLogin(id: string, token: string, userId: string | null): Promise<void> {
|
||||
if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。");
|
||||
const clientId = this.env.GITHUB_CLIENT_ID;
|
||||
if (!clientId)
|
||||
return this.respond(id, token, "服务器未配置 GitHub OAuth(GITHUB_CLIENT_ID)。");
|
||||
|
||||
const state = crypto.randomUUID().replace(/-/g, "");
|
||||
await this.env.KV.put(
|
||||
`state:${state}`,
|
||||
JSON.stringify({ redirectTo: "/", discordUserId: userId, expiresAt: Date.now() + 600_000 }),
|
||||
{ expirationTtl: 600 },
|
||||
);
|
||||
const url = getOAuthURL(clientId, state);
|
||||
await this.respond(
|
||||
id,
|
||||
token,
|
||||
`点击链接授权 GitHub,即可用**本人身份**评论(仅你可见,10 分钟内有效):\n${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async cmdLogout(id: string, token: string, userId: string | null): Promise<void> {
|
||||
if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。");
|
||||
await removeDiscordLink(this.env.KV, userId);
|
||||
await this.respond(id, token, "已解绑你的 GitHub 账号。");
|
||||
}
|
||||
|
||||
/** Map a GitHub op error code to a user-facing (Chinese) message. */
|
||||
private errText(err: unknown): string {
|
||||
const t = err instanceof Error ? err.message : String(err);
|
||||
if (t === "GITHUB_TOKEN_EXPIRED")
|
||||
return "GitHub 授权已过期或无效,请重新使用 `/gh login` 绑定。";
|
||||
if (t === "GITHUB_FORBIDDEN") return "GitHub 拒绝了此操作:你的账号没有权限修改/删除这条评论。";
|
||||
if (t === "GITHUB_NOT_FOUND") return "找不到目标(可能评论已被删除或仓库不可访问)。";
|
||||
return `操作失败:${t}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entry for add/edit/del, from either a slash command (source = link
|
||||
* option) or a right-click message command (source = notification embed url).
|
||||
*/
|
||||
private async commentOp(
|
||||
id: string,
|
||||
token: string,
|
||||
userId: string | null,
|
||||
op: "add" | "edit" | "del",
|
||||
source: string,
|
||||
): Promise<void> {
|
||||
if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。");
|
||||
const githubUserId = await getDiscordLink(this.env.KV, userId);
|
||||
if (!githubUserId) {
|
||||
return this.respond(id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||
}
|
||||
|
||||
if (op === "add") {
|
||||
const m = source.match(GITHUB_ISSUE_RE);
|
||||
if (!m)
|
||||
return this.respond(
|
||||
id,
|
||||
token,
|
||||
"找不到 issue / PR 链接(右键 issue/PR 通知,或用 link 传入链接)。",
|
||||
);
|
||||
return this.openCommentModal(
|
||||
id,
|
||||
token,
|
||||
`${MODAL_ADD}${m[1]}|${m[2]}|${m[3]}`,
|
||||
`评论 ${m[1]}/${m[2]}#${m[3]}`,
|
||||
);
|
||||
}
|
||||
|
||||
// edit / del both need a specific comment id.
|
||||
const m = source.match(GITHUB_COMMENT_RE);
|
||||
if (!m) {
|
||||
return this.respond(
|
||||
id,
|
||||
token,
|
||||
"找不到评论链接(需含 `#issuecomment-...`,请右键某条评论通知,或粘贴评论链接)。",
|
||||
);
|
||||
}
|
||||
const [, owner, repo, commentId] = m;
|
||||
|
||||
if (op === "del") {
|
||||
try {
|
||||
await deleteCommentAsUser(this.env.KV, githubUserId, owner!, repo!, Number(commentId));
|
||||
return this.respond(id, token, `已删除评论 ${owner}/${repo}#issuecomment-${commentId}。`);
|
||||
} catch (err) {
|
||||
return this.respond(id, token, this.errText(err));
|
||||
}
|
||||
}
|
||||
|
||||
// edit: fetch current body to prefill the modal.
|
||||
let prefill = "";
|
||||
try {
|
||||
const { body } = await getCommentAsUser(
|
||||
this.env.KV,
|
||||
githubUserId,
|
||||
owner!,
|
||||
repo!,
|
||||
Number(commentId),
|
||||
);
|
||||
prefill = body;
|
||||
} catch (err) {
|
||||
return this.respond(id, token, this.errText(err));
|
||||
}
|
||||
return this.openCommentModal(
|
||||
id,
|
||||
token,
|
||||
`${MODAL_EDIT}${owner}|${repo}|${commentId}`,
|
||||
`编辑评论 #${commentId}`,
|
||||
prefill,
|
||||
);
|
||||
}
|
||||
|
||||
/** Open a modal to collect/edit comment body. */
|
||||
private async openCommentModal(
|
||||
id: string,
|
||||
token: string,
|
||||
customId: string,
|
||||
title: string,
|
||||
prefill = "",
|
||||
): Promise<void> {
|
||||
await this.interactionCallback(id, token, {
|
||||
type: CALLBACK_TYPE.MODAL,
|
||||
data: {
|
||||
custom_id: customId,
|
||||
title: title.slice(0, 45),
|
||||
components: [
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
{
|
||||
type: 4,
|
||||
custom_id: "body",
|
||||
label: "评论内容",
|
||||
style: 2,
|
||||
required: true,
|
||||
max_length: 2000,
|
||||
value: prefill.slice(0, 2000) || undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async modalSubmit(
|
||||
id: string,
|
||||
token: string,
|
||||
userId: string | null,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
const d = data as {
|
||||
custom_id?: string;
|
||||
components?: Array<{ components?: Array<{ custom_id?: string; value?: string }> }>;
|
||||
};
|
||||
const customId = d.custom_id;
|
||||
if (!userId || !customId) return;
|
||||
|
||||
const body = d.components?.[0]?.components?.find((c) => c.custom_id === "body")?.value?.trim();
|
||||
if (!body) return this.respond(id, token, "评论内容不能为空。");
|
||||
|
||||
const githubUserId = await getDiscordLink(this.env.KV, userId);
|
||||
if (!githubUserId) {
|
||||
return this.respond(id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||
}
|
||||
|
||||
// ghc|add|owner|repo|issueNumber
|
||||
if (customId.startsWith(MODAL_ADD)) {
|
||||
const [owner, repo, number] = customId.slice(MODAL_ADD.length).split("|");
|
||||
if (!owner || !repo || !number)
|
||||
return this.respond(id, token, "内部错误:无法解析目标 issue。");
|
||||
try {
|
||||
const { htmlUrl, login } = await commentAsUser(
|
||||
this.env.KV,
|
||||
githubUserId,
|
||||
owner,
|
||||
repo,
|
||||
Number(number),
|
||||
body,
|
||||
);
|
||||
return this.respond(id, token, `已以 **@${login}** 身份评论:${htmlUrl}`);
|
||||
} catch (err) {
|
||||
return this.respond(id, token, this.errText(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ghc|edit|owner|repo|commentId
|
||||
if (customId.startsWith(MODAL_EDIT)) {
|
||||
const [owner, repo, commentId] = customId.slice(MODAL_EDIT.length).split("|");
|
||||
if (!owner || !repo || !commentId)
|
||||
return this.respond(id, token, "内部错误:无法解析目标评论。");
|
||||
try {
|
||||
const { htmlUrl } = await editCommentAsUser(
|
||||
this.env.KV,
|
||||
githubUserId,
|
||||
owner,
|
||||
repo,
|
||||
Number(commentId),
|
||||
body,
|
||||
);
|
||||
return this.respond(id, token, `已更新评论:${htmlUrl}`);
|
||||
} catch (err) {
|
||||
return this.respond(id, token, this.errText(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private reconnect(): void {
|
||||
this.clearHeartbeat();
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
this.connecting = false;
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private async postMessage(
|
||||
channelId: string,
|
||||
message: unknown,
|
||||
threadId?: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const token = this.token ?? this.env.DISCORD_TOKEN;
|
||||
if (!token) return { ok: false, error: "Discord token is not configured" };
|
||||
return sendMessage(token, channelId, message, threadId);
|
||||
}
|
||||
|
||||
async alarm(): Promise<void> {
|
||||
if (!this.token) {
|
||||
this.token = (await this.state.storage.get<string>("token")) ?? this.env.DISCORD_TOKEN ?? "";
|
||||
}
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
log.info("Alarm: restarting Gateway connection");
|
||||
await this.connect();
|
||||
}
|
||||
await this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL * 1000);
|
||||
}
|
||||
}
|
||||
707
src/discord-interactions.ts
Normal file
707
src/discord-interactions.ts
Normal file
|
|
@ -0,0 +1,707 @@
|
|||
import { log } from "./log";
|
||||
import {
|
||||
getOAuthURL,
|
||||
commentAsUser,
|
||||
getCommentAsUser,
|
||||
editCommentAsUser,
|
||||
deleteCommentAsUser,
|
||||
mergePullRequestAsUser,
|
||||
closePullRequestAsUser,
|
||||
} from "./github-oauth";
|
||||
import { getDiscordLink, removeDiscordLink } from "./token-store";
|
||||
import type { Env } from "./types";
|
||||
|
||||
const DISCORD_API = "https://discord.com/api/v10";
|
||||
|
||||
// Discord interaction protocol constants
|
||||
const INTERACTION_TYPE = { PING: 1, COMMAND: 2, BUTTON: 3, MODAL_SUBMIT: 5 } as const;
|
||||
const CALLBACK_TYPE = { PONG: 1, MESSAGE: 4, DEFERRED_MESSAGE: 5, MODAL: 9 } as const;
|
||||
const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const;
|
||||
const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const;
|
||||
const EPHEMERAL = 64;
|
||||
|
||||
// Right-click (message context-menu) command names → operation.
|
||||
const MSG_CMD_ADD = "GitHub: 添加评论";
|
||||
const MSG_CMD_EDIT = "GitHub: 编辑评论";
|
||||
const MSG_CMD_DEL = "GitHub: 删除评论";
|
||||
|
||||
// Modal custom_id encodings (delimiter '|' never appears in owner/repo).
|
||||
const MODAL_ADD = "ghc|add|"; // ghc|add|owner|repo|issueNumber
|
||||
const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId
|
||||
|
||||
// PR notification button custom_id encodings.
|
||||
const BTN_MERGE = "ghpr|merge|"; // ghpr|merge|owner|repo|pullNumber
|
||||
const BTN_CLOSE = "ghpr|close|"; // ghpr|close|owner|repo|pullNumber
|
||||
|
||||
const APP_COMMANDS = [
|
||||
{
|
||||
name: "gh",
|
||||
type: COMMAND_TYPE.CHAT_INPUT,
|
||||
description: "GitHub 集成",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "login",
|
||||
description: "绑定你的 GitHub 账号以用本人身份评论",
|
||||
},
|
||||
{ type: OPTION_TYPE.SUB_COMMAND, name: "logout", description: "解绑你的 GitHub 账号" },
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND_GROUP,
|
||||
name: "comment",
|
||||
description: "对 issue/PR 评论进行增删改",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "add",
|
||||
description: "在 issue/PR 下新增评论",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.STRING,
|
||||
name: "link",
|
||||
description: "issue/PR 链接",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "edit",
|
||||
description: "编辑一条评论",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.STRING,
|
||||
name: "link",
|
||||
description: "评论链接(含 #issuecomment-)",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "del",
|
||||
description: "删除一条评论",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.STRING,
|
||||
name: "link",
|
||||
description: "评论链接(含 #issuecomment-)",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: MSG_CMD_ADD, type: COMMAND_TYPE.MESSAGE },
|
||||
{ name: MSG_CMD_EDIT, type: COMMAND_TYPE.MESSAGE },
|
||||
{ name: MSG_CMD_DEL, type: COMMAND_TYPE.MESSAGE },
|
||||
];
|
||||
|
||||
// Comment link (has the comment id); check this BEFORE the plain issue regex.
|
||||
const GITHUB_COMMENT_RE =
|
||||
/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/\d+#issuecomment-(\d+)/;
|
||||
const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/;
|
||||
|
||||
interface Interaction {
|
||||
id: string;
|
||||
token: string;
|
||||
type: number;
|
||||
channel_id?: string;
|
||||
member?: { user?: { id?: string } };
|
||||
user?: { id?: string };
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const MAX_BODY_SIZE = 1024 * 1024;
|
||||
const TIMESTAMP_TOLERANCE_SECONDS = 180;
|
||||
|
||||
function hexToBytes(hex: string): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(hex.length / 2);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an interaction's Ed25519 signature (X-Signature-Ed25519 over
|
||||
* timestamp + raw body, signed by the Discord application public key).
|
||||
*/
|
||||
export async function verifyDiscordSignature(
|
||||
publicKey: string,
|
||||
timestamp: string,
|
||||
signatureHex: string,
|
||||
rawBody: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
hexToBytes(publicKey),
|
||||
{ name: "Ed25519" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
return await crypto.subtle.verify(
|
||||
{ name: "Ed25519" },
|
||||
key,
|
||||
hexToBytes(signatureHex),
|
||||
new TextEncoder().encode(timestamp + rawBody),
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn({ err: String(err) }, "Failed to verify Discord signature");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle a POST to the Discord Interactions Endpoint. */
|
||||
export async function handleInteractionRequest(request: Request, env: Env): Promise<Response> {
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > MAX_BODY_SIZE) {
|
||||
return new Response("Request too large", { status: 413 });
|
||||
}
|
||||
|
||||
const signature = request.headers.get("X-Signature-Ed25519");
|
||||
const timestamp = request.headers.get("X-Signature-Timestamp");
|
||||
if (!signature || !timestamp || !env.DISCORD_PUBLIC_KEY) {
|
||||
log.warn({ hasSig: !!signature, hasTs: !!timestamp, hasKey: !!env.DISCORD_PUBLIC_KEY }, "Discord interaction missing signature");
|
||||
return new Response("Invalid signature", { status: 401 });
|
||||
}
|
||||
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TIMESTAMP_TOLERANCE_SECONDS) {
|
||||
return new Response("Invalid signature", { status: 401 });
|
||||
}
|
||||
|
||||
const rawBody = await request.text();
|
||||
if (rawBody.length > MAX_BODY_SIZE) {
|
||||
return new Response("Request too large", { status: 413 });
|
||||
}
|
||||
|
||||
const valid = await verifyDiscordSignature(env.DISCORD_PUBLIC_KEY, timestamp, signature, rawBody);
|
||||
if (!valid) {
|
||||
return new Response("Invalid signature", { status: 401 });
|
||||
}
|
||||
|
||||
let interaction: Interaction;
|
||||
try {
|
||||
interaction = JSON.parse(rawBody) as Interaction;
|
||||
} catch {
|
||||
return new Response("Invalid JSON", { status: 400 });
|
||||
}
|
||||
|
||||
// Discord's connection check.
|
||||
if (interaction.type === INTERACTION_TYPE.PING) {
|
||||
return new Response(JSON.stringify({ type: CALLBACK_TYPE.PONG }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Handle the interaction via the callback webhook; respond 202 with no body
|
||||
// as required for interactions received over the HTTP endpoint.
|
||||
await handleInteraction(env, interaction).catch((err) =>
|
||||
log.error({ err: String(err) }, "Interaction handler failed"),
|
||||
);
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
async function handleInteraction(env: Env, interaction: Interaction): Promise<void> {
|
||||
const userId = interaction.member?.user?.id ?? interaction.user?.id ?? null;
|
||||
const id = interaction.id;
|
||||
const token = interaction.token;
|
||||
|
||||
if (interaction.type === INTERACTION_TYPE.BUTTON) {
|
||||
const data = interaction.data as { custom_id?: string; message?: { id?: string } };
|
||||
return handleButton(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
userId,
|
||||
interaction.channel_id,
|
||||
data.message?.id,
|
||||
data.custom_id,
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction.type === INTERACTION_TYPE.COMMAND) {
|
||||
const data = interaction.data as {
|
||||
name?: string;
|
||||
type?: number;
|
||||
target_id?: string;
|
||||
options?: Array<{
|
||||
name: string;
|
||||
options?: Array<{
|
||||
name: string;
|
||||
value?: string;
|
||||
options?: Array<{ name: string; value?: string }>;
|
||||
}>;
|
||||
}>;
|
||||
resolved?: {
|
||||
messages?: Record<string, { embeds?: Array<{ url?: string }>; content?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
// Right-click (message context-menu) commands.
|
||||
if (data.type === COMMAND_TYPE.MESSAGE) {
|
||||
const op =
|
||||
data.name === MSG_CMD_ADD
|
||||
? "add"
|
||||
: data.name === MSG_CMD_EDIT
|
||||
? "edit"
|
||||
: data.name === MSG_CMD_DEL
|
||||
? "del"
|
||||
: null;
|
||||
if (!op) return;
|
||||
const target = data.target_id ? data.resolved?.messages?.[data.target_id] : undefined;
|
||||
const source = target?.embeds?.[0]?.url ?? target?.content ?? "";
|
||||
return commentOp(env, id, token, userId, op, source);
|
||||
}
|
||||
|
||||
// Slash command /gh ...
|
||||
if (data.name === "gh" && data.type === COMMAND_TYPE.CHAT_INPUT) {
|
||||
const top = data.options?.[0];
|
||||
if (top?.name === "login") return cmdLogin(env, id, token, userId);
|
||||
if (top?.name === "logout") return cmdLogout(env, id, token, userId);
|
||||
if (top?.name === "comment") {
|
||||
const sub = top.options?.[0];
|
||||
const op =
|
||||
sub?.name === "add"
|
||||
? "add"
|
||||
: sub?.name === "edit"
|
||||
? "edit"
|
||||
: sub?.name === "del"
|
||||
? "del"
|
||||
: null;
|
||||
if (!op) return;
|
||||
const link = sub?.options?.find((o) => o.name === "link")?.value ?? "";
|
||||
return commentOp(env, id, token, userId, op, link);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.type === INTERACTION_TYPE.MODAL_SUBMIT) {
|
||||
return modalSubmit(env, id, token, userId, interaction.data);
|
||||
}
|
||||
}
|
||||
|
||||
/** Respond to an interaction with an ephemeral text message. */
|
||||
async function respond(env: Env, id: string, token: string, content: string): Promise<void> {
|
||||
await interactionCallback(env, id, token, {
|
||||
type: CALLBACK_TYPE.MESSAGE,
|
||||
data: { content, flags: EPHEMERAL },
|
||||
});
|
||||
}
|
||||
|
||||
async function interactionCallback(env: Env, id: string, token: string, body: unknown): Promise<void> {
|
||||
const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/callback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log.warn({ status: res.status, err }, "Interaction callback failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the deferred (ephemeral) response body with the final result. */
|
||||
async function updateOriginal(env: Env, id: string, token: string, content: string): Promise<void> {
|
||||
const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/messages/@original`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log.warn({ status: res.status, err }, "Failed to update interaction response");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PR notification buttons: merge or close the PR as the clicker's linked
|
||||
* GitHub account. The clicker must have run `/gh login` first.
|
||||
*/
|
||||
async function handleButton(
|
||||
env: Env,
|
||||
id: string,
|
||||
token: string,
|
||||
userId: string | null,
|
||||
channelId: string | undefined,
|
||||
messageId: string | undefined,
|
||||
customId: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
||||
const githubUserId = await getDiscordLink(env.KV, userId);
|
||||
if (!githubUserId) {
|
||||
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||
}
|
||||
|
||||
let op: "merge" | "close";
|
||||
let rest: string;
|
||||
if (customId?.startsWith(BTN_MERGE)) {
|
||||
op = "merge";
|
||||
rest = customId.slice(BTN_MERGE.length);
|
||||
} else if (customId?.startsWith(BTN_CLOSE)) {
|
||||
op = "close";
|
||||
rest = customId.slice(BTN_CLOSE.length);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const [owner, repo, number] = rest.split("|");
|
||||
if (!owner || !repo || !number) return;
|
||||
|
||||
// Acknowledge first (deferred, ephemeral) so the clicker sees a spinner
|
||||
// while the GitHub API call runs.
|
||||
await interactionCallback(env, id, token, {
|
||||
type: CALLBACK_TYPE.DEFERRED_MESSAGE,
|
||||
data: { flags: EPHEMERAL },
|
||||
});
|
||||
|
||||
try {
|
||||
if (op === "merge") {
|
||||
await mergePullRequestAsUser(env.KV, githubUserId, owner, repo, Number(number));
|
||||
} else {
|
||||
await closePullRequestAsUser(env.KV, githubUserId, owner, repo, Number(number));
|
||||
}
|
||||
// Remove the buttons from the notification so nobody double-clicks.
|
||||
if (channelId && messageId) {
|
||||
await fetch(`${DISCORD_API}/channels/${channelId}/messages/${messageId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bot ${env.DISCORD_TOKEN ?? ""}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ components: [] }),
|
||||
}).catch((err) => log.warn({ err: String(err) }, "Failed to strip PR buttons"));
|
||||
}
|
||||
const label = op === "merge" ? "合并" : "关闭";
|
||||
await updateOriginal(env, id, token, `✅ 已${label} PR ${owner}/${repo}#${number}`);
|
||||
} catch (err) {
|
||||
await updateOriginal(env, id, token, errText(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdLogin(env: Env, id: string, token: string, userId: string | null): Promise<void> {
|
||||
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
||||
const clientId = env.GITHUB_CLIENT_ID;
|
||||
if (!clientId)
|
||||
return respond(env, id, token, "服务器未配置 GitHub OAuth(GITHUB_CLIENT_ID)。");
|
||||
|
||||
const state = crypto.randomUUID().replace(/-/g, "");
|
||||
await env.KV.put(
|
||||
`state:${state}`,
|
||||
JSON.stringify({ redirectTo: "/", discordUserId: userId, expiresAt: Date.now() + 600_000 }),
|
||||
{ expirationTtl: 600 },
|
||||
);
|
||||
const url = getOAuthURL(clientId, state);
|
||||
await respond(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
`点击链接授权 GitHub,即可用**本人身份**评论(仅你可见,10 分钟内有效):\n${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function cmdLogout(env: Env, id: string, token: string, userId: string | null): Promise<void> {
|
||||
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
||||
await removeDiscordLink(env.KV, userId);
|
||||
await respond(env, id, token, "已解绑你的 GitHub 账号。");
|
||||
}
|
||||
|
||||
/** Map a GitHub op error code to a user-facing (Chinese) message. */
|
||||
function errText(err: unknown): string {
|
||||
const t = err instanceof Error ? err.message : String(err);
|
||||
if (t === "GITHUB_TOKEN_EXPIRED")
|
||||
return "GitHub 授权已过期或无效,请重新使用 `/gh login` 绑定。";
|
||||
if (t === "GITHUB_FORBIDDEN") return "GitHub 拒绝了此操作:你的账号没有权限修改/删除这条评论。";
|
||||
if (t === "GITHUB_NOT_FOUND") return "找不到目标(可能评论已被删除或仓库不可访问)。";
|
||||
return `操作失败:${t}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entry for add/edit/del, from either a slash command (source = link
|
||||
* option) or a right-click message command (source = notification embed url).
|
||||
*/
|
||||
async function commentOp(
|
||||
env: Env,
|
||||
id: string,
|
||||
token: string,
|
||||
userId: string | null,
|
||||
op: "add" | "edit" | "del",
|
||||
source: string,
|
||||
): Promise<void> {
|
||||
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
||||
const githubUserId = await getDiscordLink(env.KV, userId);
|
||||
if (!githubUserId) {
|
||||
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||
}
|
||||
|
||||
if (op === "add") {
|
||||
const m = source.match(GITHUB_ISSUE_RE);
|
||||
if (!m)
|
||||
return respond(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
"找不到 issue / PR 链接(右键 issue/PR 通知,或用 link 传入链接)。",
|
||||
);
|
||||
return openCommentModal(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
`${MODAL_ADD}${m[1]}|${m[2]}|${m[3]}`,
|
||||
`评论 ${m[1]}/${m[2]}#${m[3]}`,
|
||||
);
|
||||
}
|
||||
|
||||
// edit / del both need a specific comment id.
|
||||
const m = source.match(GITHUB_COMMENT_RE);
|
||||
if (!m) {
|
||||
return respond(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
"找不到评论链接(需含 `#issuecomment-...`,请右键某条评论通知,或粘贴评论链接)。",
|
||||
);
|
||||
}
|
||||
const [, owner, repo, commentId] = m;
|
||||
|
||||
if (op === "del") {
|
||||
try {
|
||||
await deleteCommentAsUser(env.KV, githubUserId, owner!, repo!, Number(commentId));
|
||||
return respond(env, id, token, `已删除评论 ${owner}/${repo}#issuecomment-${commentId}。`);
|
||||
} catch (err) {
|
||||
return respond(env, id, token, errText(err));
|
||||
}
|
||||
}
|
||||
|
||||
// edit: fetch current body to prefill the modal.
|
||||
let prefill = "";
|
||||
try {
|
||||
const { body } = await getCommentAsUser(
|
||||
env.KV,
|
||||
githubUserId,
|
||||
owner!,
|
||||
repo!,
|
||||
Number(commentId),
|
||||
);
|
||||
prefill = body;
|
||||
} catch (err) {
|
||||
return respond(env, id, token, errText(err));
|
||||
}
|
||||
return openCommentModal(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
`${MODAL_EDIT}${owner}|${repo}|${commentId}`,
|
||||
`编辑评论 #${commentId}`,
|
||||
prefill,
|
||||
);
|
||||
}
|
||||
|
||||
/** Open a modal to collect/edit comment body. */
|
||||
async function openCommentModal(
|
||||
env: Env,
|
||||
id: string,
|
||||
token: string,
|
||||
customId: string,
|
||||
title: string,
|
||||
prefill = "",
|
||||
): Promise<void> {
|
||||
await interactionCallback(env, id, token, {
|
||||
type: CALLBACK_TYPE.MODAL,
|
||||
data: {
|
||||
custom_id: customId,
|
||||
title: title.slice(0, 45),
|
||||
components: [
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
{
|
||||
type: 4,
|
||||
custom_id: "body",
|
||||
label: "评论内容",
|
||||
style: 2,
|
||||
required: true,
|
||||
max_length: 2000,
|
||||
value: prefill.slice(0, 2000) || undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function modalSubmit(
|
||||
env: Env,
|
||||
id: string,
|
||||
token: string,
|
||||
userId: string | null,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
const d = data as {
|
||||
custom_id?: string;
|
||||
components?: Array<{ components?: Array<{ custom_id?: string; value?: string }> }>;
|
||||
};
|
||||
const customId = d.custom_id;
|
||||
if (!userId || !customId) return;
|
||||
|
||||
const body = d.components?.[0]?.components?.find((c) => c.custom_id === "body")?.value?.trim();
|
||||
if (!body) return respond(env, id, token, "评论内容不能为空。");
|
||||
|
||||
const githubUserId = await getDiscordLink(env.KV, userId);
|
||||
if (!githubUserId) {
|
||||
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||
}
|
||||
|
||||
// ghc|add|owner|repo|issueNumber
|
||||
if (customId.startsWith(MODAL_ADD)) {
|
||||
const [owner, repo, number] = customId.slice(MODAL_ADD.length).split("|");
|
||||
if (!owner || !repo || !number)
|
||||
return respond(env, id, token, "内部错误:无法解析目标 issue。");
|
||||
try {
|
||||
const { htmlUrl, login } = await commentAsUser(
|
||||
env.KV,
|
||||
githubUserId,
|
||||
owner,
|
||||
repo,
|
||||
Number(number),
|
||||
body,
|
||||
);
|
||||
return respond(env, id, token, `已以 **@${login}** 身份评论:${htmlUrl}`);
|
||||
} catch (err) {
|
||||
return respond(env, id, token, errText(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ghc|edit|owner|repo|commentId
|
||||
if (customId.startsWith(MODAL_EDIT)) {
|
||||
const [owner, repo, commentId] = customId.slice(MODAL_EDIT.length).split("|");
|
||||
if (!owner || !repo || !commentId)
|
||||
return respond(env, id, token, "内部错误:无法解析目标评论。");
|
||||
try {
|
||||
const { htmlUrl } = await editCommentAsUser(
|
||||
env.KV,
|
||||
githubUserId,
|
||||
owner,
|
||||
repo,
|
||||
Number(commentId),
|
||||
body,
|
||||
);
|
||||
return respond(env, id, token, `已更新评论:${htmlUrl}`);
|
||||
} catch (err) {
|
||||
return respond(env, id, token, errText(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Discord application id: env var → KV cache → Discord API
|
||||
* (then cached in KV for later runs).
|
||||
*/
|
||||
export async function getApplicationId(env: Env): Promise<string | null> {
|
||||
if (env.DISCORD_APPLICATION_ID) return env.DISCORD_APPLICATION_ID;
|
||||
try {
|
||||
const cached = await env.KV.get("config:discord-app-id");
|
||||
if (cached) return cached;
|
||||
} catch {
|
||||
// fall through to the API
|
||||
}
|
||||
const token = env.DISCORD_TOKEN ?? "";
|
||||
if (!token) return null;
|
||||
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
log.warn({ status: res.status }, "Failed to fetch Discord application id");
|
||||
return null;
|
||||
}
|
||||
const app = (await res.json()) as { id?: string };
|
||||
if (app.id) {
|
||||
try {
|
||||
await env.KV.put("config:discord-app-id", app.id);
|
||||
} catch {
|
||||
// cache is best-effort
|
||||
}
|
||||
return app.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Register commands globally (~1h propagation); dedup for a day. */
|
||||
export async function registerGlobalCommands(env: Env): Promise<void> {
|
||||
const token = env.DISCORD_TOKEN ?? "";
|
||||
if (!token) return;
|
||||
try {
|
||||
if (await env.KV.get("cmd:registered:global")) return;
|
||||
} catch {
|
||||
// fall through and register
|
||||
}
|
||||
const appId = await getApplicationId(env);
|
||||
if (!appId) return;
|
||||
const res = await fetch(`${DISCORD_API}/applications/${appId}/commands`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(APP_COMMANDS),
|
||||
});
|
||||
if (res.ok) {
|
||||
try {
|
||||
await env.KV.put("cmd:registered:global", "1", { expirationTtl: 86400 });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
log.info("Registered global application commands");
|
||||
} else {
|
||||
const err = await res.text();
|
||||
log.warn({ status: res.status, err }, "Global command registration failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Register commands per guild for instant availability (new guilds only). */
|
||||
export async function syncGuildCommands(env: Env): Promise<void> {
|
||||
const token = env.DISCORD_TOKEN ?? "";
|
||||
if (!token) return;
|
||||
const appId = await getApplicationId(env);
|
||||
if (!appId) return;
|
||||
const res = await fetch(`${DISCORD_API}/users/@me/guilds`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log.warn({ status: res.status, err }, "Failed to list guilds");
|
||||
return;
|
||||
}
|
||||
const guilds = (await res.json()) as Array<{ id: string }>;
|
||||
for (const guild of guilds) {
|
||||
try {
|
||||
if (await env.KV.get(`cmd:guild:${guild.id}`)) continue;
|
||||
const r = await fetch(
|
||||
`${DISCORD_API}/applications/${appId}/guilds/${guild.id}/commands`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(APP_COMMANDS),
|
||||
},
|
||||
);
|
||||
if (r.ok) {
|
||||
await env.KV.put(`cmd:guild:${guild.id}`, "1");
|
||||
log.info({ guildId: guild.id }, "Registered guild application commands");
|
||||
} else {
|
||||
const err = await r.text();
|
||||
log.warn({ guildId: guild.id, status: r.status, err }, "Command registration failed");
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ guildId: guild.id, err: String(err) }, "Command registration failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Entry point for the scheduled (cron) command sync. */
|
||||
export async function syncCommands(env: Env): Promise<void> {
|
||||
if (!env.DISCORD_TOKEN) return;
|
||||
await registerGlobalCommands(env);
|
||||
await syncGuildCommands(env);
|
||||
}
|
||||
|
|
@ -7,28 +7,6 @@ import { sendMessage } from "./discord-rest";
|
|||
import { recordSend } from "./send-log";
|
||||
import { loadGroups, groupAcceptsOwners } from "./groups";
|
||||
|
||||
export function isGatewayEnabled(env: Env): boolean {
|
||||
return env.DISCORD_GATEWAY_ENABLED === "true";
|
||||
}
|
||||
|
||||
async function getGatewayProxy(env: Env): Promise<DurableObjectStub> {
|
||||
const id = env.DISCORD_GATEWAY.idFromName("discord-gateway");
|
||||
return env.DISCORD_GATEWAY.get(id);
|
||||
}
|
||||
|
||||
export async function initGateway(env: Env): Promise<void> {
|
||||
if (!isGatewayEnabled(env)) return;
|
||||
if (!env.DISCORD_TOKEN) return;
|
||||
const stub = await getGatewayProxy(env);
|
||||
await stub.fetch(
|
||||
new Request("https://do.internal", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "start", token: env.DISCORD_TOKEN }),
|
||||
}),
|
||||
);
|
||||
log.info("Discord Gateway DO started");
|
||||
}
|
||||
|
||||
export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise<void> {
|
||||
const langs = [...new Set(config.routes.map((r) => r.lang ?? "en"))];
|
||||
const trMap = new Map<string, Translations>();
|
||||
|
|
@ -105,21 +83,6 @@ async function sendToChannel(
|
|||
threadId?: string,
|
||||
): Promise<void> {
|
||||
const token = env.DISCORD_TOKEN ?? "";
|
||||
if (!isGatewayEnabled(env)) {
|
||||
const result = await sendMessage(token, channelId, message, threadId);
|
||||
if (!result.ok) throw new Error(result.error ?? "Send failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const stub = await getGatewayProxy(env);
|
||||
const res = await stub.fetch(
|
||||
new Request("https://do.internal", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "send", channelId, message, threadId }),
|
||||
}),
|
||||
);
|
||||
const result = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error ?? "Send failed");
|
||||
}
|
||||
const result = await sendMessage(token, channelId, message, threadId);
|
||||
if (!result.ok) throw new Error(result.error ?? "Send failed");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import { createServer } from "./server";
|
||||
import { initGateway } from "./discord";
|
||||
import { DiscordGateway } from "./discord-gateway";
|
||||
import { syncCommands } from "./discord-interactions";
|
||||
import type { Env } from "./types";
|
||||
import { log } from "./log";
|
||||
|
||||
export { DiscordGateway };
|
||||
|
||||
const app = createServer();
|
||||
|
||||
export default {
|
||||
|
|
@ -15,9 +12,9 @@ export default {
|
|||
|
||||
async scheduled(_event: ScheduledEvent, env: Env): Promise<void> {
|
||||
try {
|
||||
await initGateway(env);
|
||||
await syncCommands(env);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Gateway init from cron failed");
|
||||
log.error({ err }, "Discord command sync from cron failed");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|||
import type { Env } from "./types";
|
||||
import { verifySignature, parseEvent } from "./webhook";
|
||||
import { dispatchEvent } from "./discord";
|
||||
import { handleInteractionRequest } from "./discord-interactions";
|
||||
import { createOAuthRoutes } from "./oauth-routes";
|
||||
import { createActionRoutes } from "./action-routes";
|
||||
import { createAdminRoutes } from "./admin-routes";
|
||||
|
|
@ -68,6 +69,8 @@ export function createServer(): Hono<{ Bindings: Env }> {
|
|||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/discord/interactions", (c) => handleInteractionRequest(c.req.raw, c.env));
|
||||
|
||||
app.notFound((c) => {
|
||||
if (c.env.ASSETS) {
|
||||
return c.env.ASSETS.fetch(c.req.raw);
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ export interface Env {
|
|||
DISCORD_CHANNEL_ID?: string;
|
||||
BASE_URL?: string;
|
||||
ADMIN_USER_IDS?: string;
|
||||
DISCORD_GATEWAY_ENABLED?: string;
|
||||
LEGAL_CONTACT?: string;
|
||||
DOCS_URL?: string;
|
||||
GITHUB_REPO_URL?: string;
|
||||
DISCORD_PUBLIC_KEY?: string;
|
||||
DISCORD_APPLICATION_ID?: string;
|
||||
ASSETS?: Fetcher;
|
||||
KV: KVNamespace;
|
||||
DISCORD_GATEWAY: DurableObjectNamespace;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue