diff --git a/README.md b/README.md index 6dd13e7..79fe309 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,29 @@ -To install dependencies: -```sh -bun install -``` +# Police Discord Bot -To run: -```sh -bun run dev -``` +## Setup -open http://localhost:3000 +1. Install dependencies: + ```sh + bun install + ``` + +2. Create a `.env` file in the root of the project and add the following environment variables: + ``` + BOT_TOKEN= + APP_ID= + PUBLIC_KEY= + DEFAULT_GUILD_ID= + ADMIN_KEY= + OPENROUTER_API_KEY= + PUNISH_ROLE_ID= + ``` + +3. Run the bot: + ```sh + bun run dev + ``` + +4. The bot will be running at `http://localhost:3000`. You will need to use a tool like `ngrok` to expose it to the internet for Discord to send interactions. + +5. Register the slash commands: + The bot registers the commands on startup. Check the logs for confirmation. \ No newline at end of file diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..66ad04c --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,40 @@ +import { Config } from "./config"; +import { logger } from "./logger"; +import { fetchDiscord } from "./discord/core"; + +const COMMANDS = [ + { + name: "checkaudit", + description: "Scan recent audit logs for mute/deafen", + type: 1, + }, + { + name: "talk", + description: "Chat with the AI", + type: 1, + options: [ + { + name: "prompt", + description: "Your message", + type: 3, + required: false, + }, + ], + }, +]; + +export const registerCommand = async () => { + logger.info( + { command: COMMANDS, guildId: Config.DEFAULT_GUILD_ID }, + "Registering commands to discord server", + ); + for (const commmand of COMMANDS) { + const response = await fetchDiscord( + `/applications/${Config.APP_ID}/guilds/${Config.DEFAULT_GUILD_ID}/commands`, + { + method: "POST", + body: JSON.stringify(commmand), + }, + ); + } +}; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d4ba67e --- /dev/null +++ b/src/config.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +const EnvSchema = z.object({ + BOT_TOKEN: z.string(), + APP_ID: z.string(), + PUBLIC_KEY: z.string(), + DEFAULT_GUILD_ID: z.string(), + ADMIN_KEY: z.string(), + OPENROUTER_API_KEY: z.string(), + PUNISH_ROLE_ID: z.string().default(""), + PORT: z.coerce.number().default(3000), + MAX_EVENT_AGE_SEC: z.coerce.number().default(300), + REACTION_TIMEOUT_SEC: z.coerce.number().default(120), + RENAME_TIMEOUT_SEC: z.coerce.number().default(60), + DISCORD_API: z.string().default("https://discord.com/api/v10"), +}); + +type EnvConfig = z.infer; + +export const Config: EnvConfig = EnvSchema.parse(process.env); diff --git a/src/index.ts b/src/index.ts index b2c265c..eda9e00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,113 +1,38 @@ import { Hono } from "hono"; +import { Config } from "./config"; +import { logger } from "./logger"; import * as nacl from "tweetnacl"; +import * as Discord from "./discord"; -/** ====== Env ====== */ -const BOT_TOKEN = process.env.BOT_TOKEN!; -const APP_ID = process.env.APP_ID!; -const PUBLIC_KEY = process.env.PUBLIC_KEY!; -const DEFAULT_GUILD_ID = process.env.DEFAULT_GUILD_ID!; -const ADMIN_KEY = process.env.ADMIN_KEY!; -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY!; -const PUNISH_ROLE_ID = process.env.PUNISH_ROLE_ID || ""; -const PORT = Number(process.env.PORT || 3000); -const MAX_EVENT_AGE_SEC = Number(process.env.MAX_EVENT_AGE_SEC || 300); -const REACTION_TIMEOUT_SEC = Number(process.env.REACTION_TIMEOUT_SEC || 120); -const RENAME_TIMEOUT_SEC = Number(process.env.RENAME_TIMEOUT_SEC || 60); - -if (!BOT_TOKEN || !APP_ID || !PUBLIC_KEY || !ADMIN_KEY) { - console.error("[BOOT] Missing required envs"); - process.exit(1); -} - -console.log("[BOOT] Discord Audit Bot starting…"); - -/** ====== Constants ====== */ -const API = "https://discord.com/api/v10"; -const DECIDER_USER_ID = "311380871901085707"; -const RENAME_EMOJIS = ["✏️", "✏"]; // VS16 + no-VS16 -const ROLE_EMOJIS = ["😭"]; - -/** ====== Helpers ====== */ -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -const hexToUint8 = (hex: string) => { - if (hex.length % 2 !== 0) throw new Error("Invalid hex"); - const u = new Uint8Array(hex.length / 2); - for (let i = 0; i < hex.length; i += 2) - u[i / 2] = parseInt(hex.slice(i, i + 2), 16); - return u; -}; - -async function verifySignature(req: Request, rawBody: string) { - const sig = req.headers.get("X-Signature-Ed25519") || ""; - const ts = req.headers.get("X-Signature-Timestamp") || ""; - if (!sig || !ts) return false; - const msg = new TextEncoder().encode(ts + rawBody); - return nacl.sign.detached.verify( - hexToUint8(Buffer.from(msg).toString("hex")), - hexToUint8(sig), - hexToUint8(PUBLIC_KEY) - ); -} - -// Use direct uint8 approach (safer in Bun) -async function verifySignatureSafe(req: Request, rawBody: string) { - const sig = req.headers.get("X-Signature-Ed25519") || ""; - const ts = req.headers.get("X-Signature-Timestamp") || ""; - if (!sig || !ts) return false; - const enc = new TextEncoder(); - const msg = enc.encode(ts + rawBody); - return nacl.sign.detached.verify( - msg, - hexToUint8(sig), - hexToUint8(PUBLIC_KEY) - ); -} +logger.info( + { port: Config.PORT, guildId: Config.DEFAULT_GUILD_ID }, + "Discord police bot is starting", +); const snowflakeToMs = (id: string) => Number((BigInt(id) >> BigInt(22)) + BigInt(1420070400000)); -async function discordFetch( - path: string, - init?: RequestInit -): Promise { - const r = await fetch(`${API}${path}`, { - ...init, - headers: { - Authorization: `Bot ${BOT_TOKEN}`, - "Content-Type": "application/json", - ...(init?.headers || {}), - }, - }); - if (r.status === 429) { - const j = await r.json().catch(() => ({} as any)); - const retry = (j?.retry_after ? Number(j.retry_after) : 1) * 1000; - await sleep(retry); - } - return r; -} - async function interactionCallback(id: string, token: string, body: any) { - return fetch(`${API}/interactions/${id}/${token}/callback`, { + return Discord.fetchDiscord(`/interactions/${id}/${token}/callback`, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } async function editOriginal(token: string, content: string) { - return fetch(`${API}/webhooks/${APP_ID}/${token}/messages/@original`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); + return Discord.fetchDiscord( + `/webhooks/${Config.APP_ID}/${token}/messages/@original`, + { + method: "PATCH", + body: JSON.stringify({ content }), + }, + ); } -/** ====== Audit utils ====== */ -/** Find most recent MEMBER_UPDATE with deaf=true or mute=true within window */ -function findRecentVoicePunish( - audit: any -): { entry: any; kind: "deafen" | "mute" } | null { +function findRecentVoicePunish(audit: Discord.AuditLog): { + entry: Discord.AuditLogEntry; + kind: "deafen" | "mute"; +} | null { const entries = audit?.audit_log_entries ?? []; for (const e of entries) { if (e.action_type !== 24) continue; // MEMBER_UPDATE @@ -116,61 +41,19 @@ function findRecentVoicePunish( const mute = changes.find((c: any) => c.key === "mute")?.new_value === true; if (!deaf && !mute) continue; const ageSec = (Date.now() - snowflakeToMs(e.id)) / 1000; - if (ageSec <= MAX_EVENT_AGE_SEC) + if (ageSec <= Config.MAX_EVENT_AGE_SEC) return { entry: e, kind: deaf ? "deafen" : "mute" }; } return null; } -/** ====== Message + Reaction helpers (REST polling) ====== */ -const encEmoji = (e: string) => encodeURIComponent(e); - -async function sendMessage(channelId: string, content: string) { - const r = await discordFetch(`/channels/${channelId}/messages`, { - method: "POST", - body: JSON.stringify({ content }), - }); - const j = await r.json().catch(() => ({})); - return { ok: r.ok, status: r.status, data: j }; -} - -async function addReaction( - channelId: string, - messageId: string, - emoji: string -) { - // PUT /channels/{channel.id}/messages/{message.id}/reactions/{emoji}/@me - const r = await discordFetch( - `/channels/${channelId}/messages/${messageId}/reactions/${encEmoji( - emoji - )}/@me`, - { method: "PUT", headers: { "Content-Type": "application/json" } } - ); - return r.ok; -} - -async function listReactionUsers( - channelId: string, - messageId: string, - emoji: string -) { - const r = await discordFetch( - `/channels/${channelId}/messages/${messageId}/reactions/${encEmoji( - emoji - )}?limit=100` - ); - if (!r.ok) return []; - const users = await r.json().catch(() => []); - return Array.isArray(users) ? users : []; -} - async function listReactionUsersMulti( channelId: string, messageId: string, - emojis: string[] + emojis: string[], ) { const results = await Promise.all( - emojis.map((e) => listReactionUsers(channelId, messageId, e)) + emojis.map((e) => Discord.getReactions(channelId, messageId, e)), ); const byId = new Map(); for (const arr of results) { @@ -179,39 +62,6 @@ async function listReactionUsersMulti( return Array.from(byId.values()); } -async function fetchNewMessagesAfter( - channelId: string, - afterMessageId: string -) { - const r = await discordFetch( - `/channels/${channelId}/messages?after=${afterMessageId}&limit=50` - ); - if (!r.ok) return []; - const arr = await r.json().catch(() => []); - if (!Array.isArray(arr)) return []; - // Newest first; reverse to oldest→newest - return arr.reverse(); -} - -async function setNickname(guildId: string, userId: string, nick: string) { - const r = await discordFetch(`/guilds/${guildId}/members/${userId}`, { - method: "PATCH", - body: JSON.stringify({ nick }), - }); - return r.ok; -} - -async function removeRole(guildId: string, userId: string, roleId: string) { - const r = await discordFetch( - `/guilds/${guildId}/members/${userId}/roles/${roleId}`, - { - method: "DELETE", - } - ); - return r.ok; -} - -/** ====== OpenRouter /talk ====== */ const randomSystem = () => { const pool = ["คุณเป็นคนพูดจาเกรียนๆ"]; return pool[Math.floor(Math.random() * pool.length)]; @@ -221,7 +71,7 @@ async function callOpenRouterChat(userPrompt: string) { const r = await fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { - Authorization: `Bearer ${OPENROUTER_API_KEY}`, + Authorization: `Bearer ${Config.OPENROUTER_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -244,262 +94,198 @@ async function callOpenRouterChat(userPrompt: string) { return j?.choices?.[0]?.message?.content || "(no content)"; } -/** ====== Hono app ====== */ const app = new Hono(); -/** Health */ -app.get("/healthz", (c) => c.text("ok")); +app.get("/health", (c) => c.text("ok")); -/** Admin: register both commands */ -app.post("/admin/register", async (c) => { - if (c.req.header("X-Admin-Key") !== ADMIN_KEY) - return c.text("forbidden", 403); - const { guildId = DEFAULT_GUILD_ID } = await c.req.json().catch(() => ({})); +const hexToUint8 = (hex: string) => { + if (hex.length % 2 !== 0) throw new Error("Invalid hex"); + const u = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) + u[i / 2] = parseInt(hex.slice(i, i + 2), 16); + return u; +}; - const cmds = [ - { - name: "checkaudit", - description: "Scan recent audit logs for mute/deafen", - type: 1, - }, - { - name: "talk", - description: "Chat with the AI", - type: 1, - options: [ - { - name: "prompt", - description: "Your message", - type: 3, - required: false, - }, - ], - }, - ]; +async function verifySignatureSafe(req: Request, rawBody: string) { + const sig = req.headers.get("X-Signature-Ed25519") || ""; + const ts = req.headers.get("X-Signature-Timestamp") || ""; + if (!sig || !ts) return false; + const enc = new TextEncoder(); + const msg = enc.encode(ts + rawBody); + return nacl.sign.detached.verify( + msg, + hexToUint8(sig), + hexToUint8(Config.PUBLIC_KEY), + ); +} - const results: any[] = []; - for (const body of cmds) { - const r = await discordFetch( - `/applications/${APP_ID}/guilds/${guildId}/commands`, - { - method: "POST", - body: JSON.stringify(body), - } - ); - results.push({ - ok: r.ok, - status: r.status, - body: await r.json().catch(() => ({})), - }); - } - return c.json({ results }); -}); - -/** Interactions endpoint */ app.post("/interactions", async (c) => { const raw = await c.req.text(); - if (!(await verifySignatureSafe(c.req.raw, raw))) + if (!(await verifySignatureSafe(c.req.raw, raw))) { + logger.warn("Unauthorized request"); return c.text("unauthorized", 401); + } const interaction = JSON.parse(raw); + logger.info({ interaction }, "Received interaction"); const { type } = interaction; - // PING - if (type === 1) return c.json({ type: 1 }); + if (type === 1) { + return c.json({ type: 1 }); + } - // APPLICATION_COMMAND if (type === 2) { - const name = interaction?.data?.name; - const id = interaction.id as string; - const token = interaction.token as string; - const guildId = interaction.guild_id as string; - const channelId = interaction.channel_id as string; + const { data, id, token, guild_id, channel_id, application_id } = + interaction; + const commandName = data.name; - if (name === "checkaudit") { - // Immediate ephemeral ACK + if (commandName === "checkaudit") { await interactionCallback(id, token, { type: 5, data: { flags: 64 } }); - // Background job queueMicrotask(async () => { - // Fetch audit logs - const logsResp = await discordFetch( - `/guilds/${guildId}/audit-logs?action_type=24&limit=10` - ); - if (!logsResp.ok) { - await editOriginal(token, "Failed to read audit logs."); - return; - } - const logs = await logsResp.json(); - const hit = findRecentVoicePunish(logs); - if (!hit) { - await editOriginal(token, "No recent mute/deafen found."); - return; - } + try { + const auditLogs = await Discord.getAuditLog({ + guildId: Config.DEFAULT_GUILD_ID, + actionType: 24, + limit: 10, + }); - const e = hit.entry; - const executorId = e.user_id as string; // the user who did the action - const targetId = e.target_id as string; // the member affected - const kind = hit.kind === "deafen" ? "ปิดหู" : "ปิดไมค์"; - - // Decision message - const intro = `มีคนโดนทำร้าย😭😭 ${kind}\nคนทำ!: <@${executorId}> → คนโดน: <@${targetId}>\nเลือกการลงโทษโดยการกดส่งอีโมจิ:\n${RENAME_EMOJIS[0]} = เปลี่ยนชื่อเล่น, ${ROLE_EMOJIS[0]} = ลบ Role`; - const sent = await sendMessage(channelId, intro); - if (!sent.ok) { - await editOriginal(token, "Cannot post decision message."); - return; - } - const msgId = sent.data.id as string; - - // Seed reactions so users can tap - for (const e of RENAME_EMOJIS) await addReaction(channelId, msgId, e); - for (const e of ROLE_EMOJIS) await addReaction(channelId, msgId, e); - - // Identify the bot user ID (same as application_id for bot apps) - const botUserId = String(interaction.application_id); - - // Poll reactions up to timeout - const deadline = Date.now() + REACTION_TIMEOUT_SEC * 1000; - let chosen: "rename" | "role" | null = null; - - while (Date.now() < deadline && !chosen) { - const [renameUsersAll, roleUsersAll] = await Promise.all([ - listReactionUsersMulti(channelId, msgId, RENAME_EMOJIS), - listReactionUsersMulti(channelId, msgId, ROLE_EMOJIS), - ]); - - // Exclude the bot itself from counts - const renameUsers = renameUsersAll.filter( - (u: any) => u?.id !== botUserId - ); - const roleUsers = roleUsersAll.filter( - (u: any) => u?.id !== botUserId - ); - - // Priority: specific decider - const deciderRename = renameUsers.some( - (u: any) => u?.id === DECIDER_USER_ID - ); - const deciderRole = roleUsers.some( - (u: any) => u?.id === DECIDER_USER_ID - ); - - console.log( - `[VOTE] rename=${renameUsers.length} role=${roleUsers.length} deciderRename=${deciderRename} deciderRole=${deciderRole}` - ); - - if (deciderRename) chosen = "rename"; - else if (deciderRole) chosen = "role"; - else { - // Otherwise: first path to reach 2 human votes - if (renameUsers.length >= 2) chosen = "rename"; - else if (roleUsers.length >= 2) chosen = "role"; - } - - if (!chosen) await sleep(2000); - } - - if (!chosen) { - await sendMessage(channelId, "หมดเวลา ไม่มีการเลือกลงโทษ รอดตัวไป"); - await editOriginal(token, "No decision taken."); - return; - } - - if (chosen === "rename") { - // Ask for new nickname - const ask = await sendMessage( - channelId, - `เลือก: เปลี่ยนชื่อเล่นให้ <@${executorId}>\nพิมพ์ชื่อใหม่ (≤32 ตัวอักษร) ภายใน ${RENAME_TIMEOUT_SEC} วินาที` - ); - if (!ask.ok) { - await sendMessage(channelId, "ถามชื่อไม่ได้ ทำไมอ่า"); + const hit = findRecentVoicePunish(auditLogs); + if (!hit) { + await editOriginal(token, "No recent mute/deafen found."); return; } - const askId = ask.data.id as string; - const renameDeadline = Date.now() + RENAME_TIMEOUT_SEC * 1000; - let newNick: string | null = null; - while (Date.now() < renameDeadline && !newNick) { - const news = await fetchNewMessagesAfter(channelId, askId); - const firstUserMsg = news.find( - (m: any) => - !m?.author?.bot && - typeof m?.content === "string" && - m.content.trim().length > 0 + const { entry, kind } = hit; + const executorId = entry.user_id!; + const targetId = entry.target_id!; + const kindText = kind === "deafen" ? "ปิดหู" : "ปิดไมค์"; + + const RENAME_EMOJIS = ["✏️", "✏"]; + const ROLE_EMOJIS = ["😭"]; + + const intro = `มีคนโดนทำร้าย😭😭 ${kindText}\nคนทำ!: <@${executorId}> → คนโดน: <@${targetId}>\nเลือกการลงโทษโดยการกดส่งอีโมจิ:\n${RENAME_EMOJIS[0]} = เปลี่ยนชื่อเล่น, ${ROLE_EMOJIS[0]} = ลบ Role`; + const sent = await Discord.createMessage(channel_id, { + content: intro, + }); + + for (const e of RENAME_EMOJIS) + await Discord.addReaction(channel_id, sent.id, e); + for (const e of ROLE_EMOJIS) + await Discord.addReaction(channel_id, sent.id, e); + + const botUserId = String(application_id); + const deadline = Date.now() + Config.REACTION_TIMEOUT_SEC * 1000; + let chosen: "rename" | "role" | null = null; + + while (Date.now() < deadline && !chosen) { + const [renameUsers, roleUsers] = await Promise.all([ + listReactionUsersMulti(channel_id, sent.id, RENAME_EMOJIS), + listReactionUsersMulti(channel_id, sent.id, ROLE_EMOJIS), + ]); + + const DECIDER_USER_ID = "311380871901085707"; + const deciderRename = renameUsers.some( + (u) => u.id === DECIDER_USER_ID, ); - if (firstUserMsg) - newNick = firstUserMsg.content.trim().slice(0, 32); - if (!newNick) await sleep(2000); + const deciderRole = roleUsers.some((u) => u.id === DECIDER_USER_ID); + + if (deciderRename) chosen = "rename"; + else if (deciderRole) chosen = "role"; + else if (renameUsers.filter((u) => u.id !== botUserId).length >= 2) + chosen = "rename"; + else if (roleUsers.filter((u) => u.id !== botUserId).length >= 2) + chosen = "role"; + + if (!chosen) await new Promise((r) => setTimeout(r, 2000)); } - if (!newNick) { - await sendMessage( - channelId, - "หมดเวลาการลงชื่อใหม่ ไม่เปลี่ยนแม่งละชื่อ" - ); - } else { - const ok = await setNickname(guildId, executorId, newNick); - if (ok) { - await sendMessage( - channelId, - `ตั้งชื่อใหม่ให้ <@${executorId}> เป็น **${newNick}** แล้วนะคราฟ` + if (!chosen) { + await Discord.createMessage(channel_id, { + content: "หมดเวลา ไม่มีการเลือกลงโทษ รอดตัวไป", + }); + await editOriginal(token, "No decision taken."); + return; + } + + if (chosen === "rename") { + const ask = await Discord.createMessage(channel_id, { + content: `เลือก: เปลี่ยนชื่อเล่นให้ <@${executorId}>\nพิมพ์ชื่อใหม่ (≤32 ตัวอักษร) ภายใน ${Config.RENAME_TIMEOUT_SEC} วินาที`, + }); + const renameDeadline = + Date.now() + Config.RENAME_TIMEOUT_SEC * 1000; + let newNick: string | null = null; + + while (Date.now() < renameDeadline && !newNick) { + const newMessages = await Discord.getChannelMessages(channel_id, { + after: ask.id, + }); + const firstUserMsg = newMessages.find( + (m) => !m.author.bot && m.content?.trim().length > 0, ); + if (firstUserMsg) + newNick = firstUserMsg.content.trim().slice(0, 32); + if (!newNick) await new Promise((r) => setTimeout(r, 2000)); + } + + if (!newNick) { + await Discord.createMessage(channel_id, { + content: "หมดเวลาการลงชื่อใหม่ ไม่เปลี่ยนแม่งละชื่อ", + }); } else { - await sendMessage( - channelId, - "เปลี่ยนชื่อไม่สำเร็จ (สิทธิ์ไม่พอครับพี่)" - ); - } - } - } else if (chosen === "role") { - if (!PUNISH_ROLE_ID) { - await sendMessage( - channelId, - "ยังไม่ได้ตั้งค่า PUNISH_ROLE_ID จึงลบ role ไม่ได้" - ); - } else { - const ok = await removeRole(guildId, executorId, PUNISH_ROLE_ID); - if (ok) { - await sendMessage( - channelId, - `ลบ role ออกจาก <@${executorId}> แล้วนะคราฟ` - ); - } else { - await sendMessage( - channelId, - "ลบ role ไม่สำเร็จ (สิทธิ์ไม่พอครับนาย)" + await Discord.modifyGuildMember(guild_id, executorId, { + nick: newNick, + }); + await Discord.createMessage(channel_id, { + content: `ตั้งชื่อใหม่ให้ <@${executorId}> เป็น **${newNick}** แล้วนะคราฟ`, + }); + } + } else if (chosen === "role") { + if (!Config.PUNISH_ROLE_ID) { + await Discord.createMessage(channel_id, { + content: "ยังไม่ได้ตั้งค่า PUNISH_ROLE_ID จึงลบ role ไม่ได้", + }); + } else { + await Discord.removeGuildMemberRole( + guild_id, + executorId, + Config.PUNISH_ROLE_ID, ); + await Discord.createMessage(channel_id, { + content: `ลบ role ออกจาก <@${executorId}> แล้วนะคราฟ`, + }); } } + + await Discord.createMessage(channel_id, { content: "โดนซะบ้าง 🙂" }); + await editOriginal(token, "Done."); + } catch (error) { + logger.error(error, "Error in checkaudit command"); + await editOriginal(token, "An error occurred."); } - - // Taunt - await sendMessage(channelId, "โดนซะบ้าง 🙂"); - await editOriginal(token, "Done."); }); - // We already deferred return c.body(null, 204); } - if (name === "talk") { - // Read optional prompt - const promptOpt = interaction?.data?.options?.find( - (o: any) => o.name === "prompt" - ); - const prompt = promptOpt?.value || ""; - - // Deferred ephemeral + if (commandName === "talk") { + const prompt = + data.options?.find((o: any) => o.name === "prompt")?.value || ""; await interactionCallback(id, token, { type: 5, data: { flags: 64 } }); queueMicrotask(async () => { - const reply = await callOpenRouterChat(prompt); - await editOriginal(token, reply); + try { + const reply = await callOpenRouterChat(prompt); + await editOriginal(token, reply); + } catch (error) { + logger.error(error, "Error in talk command"); + await editOriginal(token, "An error occurred."); + } }); return c.body(null, 204); } - // Unknown command await interactionCallback(id, token, { type: 4, data: { content: "Unknown command", flags: 64 }, @@ -511,6 +297,6 @@ app.post("/interactions", async (c) => { }); export default { - port: PORT, + port: Config.PORT, fetch: app.fetch, };