diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..707ee55 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 +FROM oven/bun:1.2.20-alpine + +RUN adduser -D -H -u 10001 app +WORKDIR /app + +COPY package.json bun.lockb* ./ +RUN bun install --frozen-lockfile + +COPY src ./src + +USER app +ENV NODE_ENV=production +EXPOSE 3000 +CMD ["bun", "run", "src/index.ts"] diff --git a/bun.lock b/bun.lock index 0631f6b..4a6e65f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "name": "police-discord-bot", "dependencies": { "hono": "^4.9.4", + "tweetnacl": "^1.0.3", }, "devDependencies": { "@types/bun": "latest", @@ -24,6 +25,8 @@ "hono": ["hono@4.9.4", "", {}, "sha512-61hl6MF6ojTl/8QSRu5ran6GXt+6zsngIUN95KzF5v5UjiX/xnrLR358BNRawwIRO49JwUqJqQe3Rb2v559R8Q=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], } } diff --git a/package.json b/package.json index 22af6c4..7f02784 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "police-discord-bot", + "private": true, "scripts": { "dev": "bun run --hot src/index.ts" }, "dependencies": { - "hono": "^4.9.4" + "hono": "^4.9.4", + "tweetnacl": "^1.0.3" }, "devDependencies": { "@types/bun": "latest" } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 3191383..9551e0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,207 @@ -import { Hono } from 'hono' +import { Hono } from "hono"; +import nacl from "tweetnacl"; -const app = new Hono() +/** ====== 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 PORT = Number(process.env.PORT || 3000); +const MAX_EVENT_AGE_SEC = Number(process.env.MAX_EVENT_AGE_SEC || 300); -app.get('/', (c) => { - return c.text('Hello Hono!') -}) +if (!BOT_TOKEN || !APP_ID || !PUBLIC_KEY || !ADMIN_KEY) { + console.error("Missing env: BOT_TOKEN, APP_ID, PUBLIC_KEY, ADMIN_KEY"); + process.exit(1); +} -export default app +/** ====== Helpers ====== */ +const API = "https://discord.com/api/v10"; + +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); + const sigBin = hexToUint8(sig); + const pub = hexToUint8(PUBLIC_KEY); + return nacl.sign.detached.verify(msg, sigBin, pub); +} + +const snowflakeToMs = (id: string) => + Number((BigInt(id) >> BigInt(22)) + BigInt(1420070400000)); // Discord epoch + +async function discordFetch(path: string, init?: RequestInit) { + return fetch(`${API}${path}`, { + ...init, + headers: { + Authorization: `Bot ${BOT_TOKEN}`, + "Content-Type": "application/json", + ...(init?.headers || {}), + }, + }); +} + +async function interactionCallback(id: string, token: string, body: any) { + return fetch(`${API}/interactions/${id}/${token}/callback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** Find most recent MEMBER_UPDATE with deaf=true within window */ +function findRecentDeafen(audit: any): { entry: any } | null { + const entries = audit?.audit_log_entries ?? []; + for (const e of entries) { + if (e.action_type !== 24) continue; // MEMBER_UPDATE + const changes = e.changes || []; + const deafChange = changes.find((c: any) => c.key === "deaf"); + if (!deafChange || deafChange.new_value !== true) continue; + const ageSec = (Date.now() - snowflakeToMs(e.id)) / 1000; + if (ageSec <= MAX_EVENT_AGE_SEC) return { entry: e }; + } + return null; +} + +async function createPoll(channelId: string, content: string) { + const body = { + content, + poll: { + question: { text: "Choose punishment" }, + answers: [ + { text: "Change role" }, + { text: "Change nickname" }, + { text: "Do nothing" }, + ], + duration: 3600, + }, + }; + const r = await discordFetch(`/channels/${channelId}/messages`, { + method: "POST", + body: JSON.stringify(body), + }); + if (!r.ok) { + // Fallback to message + reactions if Poll API not available + await discordFetch(`/channels/${channelId}/messages`, { + method: "POST", + body: JSON.stringify({ + content: + "Poll unavailable. React with: 🧩 = Change role, ✏️ = Change nickname, ✅ = Do nothing.", + }), + }); + } +} + +async function sendMessage(channelId: string, content: string) { + await discordFetch(`/channels/${channelId}/messages`, { + method: "POST", + body: JSON.stringify({ content }), + }); +} + +/** ====== Hono app ====== */ +const app = new Hono(); + +/** Health */ +app.get("/healthz", (c) => c.text("ok")); + +/** Register or upsert the /checkaudit command for a guild */ +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 body = { + name: "checkaudit", + description: "Scan recent audit logs for deafen events", + type: 1, + }; + const r = await discordFetch( + `/applications/${APP_ID}/guilds/${guildId}/commands`, + { + method: "POST", + body: JSON.stringify(body), + } + ); + const j = await r.json().catch(() => ({})); + return c.json({ ok: r.ok, status: r.status, resp: j }); +}); + +/** Discord Interactions endpoint */ +app.post("/interactions", async (c) => { + const raw = await c.req.text(); + + if (!(await verifySignature(c.req.raw, raw))) + return c.text("bad request", 401); + + const interaction = JSON.parse(raw); + const { type } = interaction; + + // Ping + if (type === 1) return c.json({ type: 1 }); + + // Application Command + if (type === 2) { + 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; + + // Immediate ephemeral ack to avoid 3s timeout + await interactionCallback(id, token, { + type: 4, // CHANNEL_MESSAGE_WITH_SOURCE + data: { content: "Scanning audit logs…", flags: 64 }, // ephemeral + }); + + // Background work + queueMicrotask(async () => { + // Fetch recent MEMBER_UPDATE logs + const logsResp = await discordFetch( + `/guilds/${guildId}/audit-logs?action_type=24&limit=10` + ); + if (!logsResp.ok) { + await sendMessage(channelId, "Failed to read audit logs."); + return; + } + const logs = await logsResp.json(); + + const hit = findRecentDeafen(logs); + if (!hit) { + await sendMessage(channelId, "No recent deafen event found."); + return; + } + + const e = hit.entry; + const executorId = e.user_id; + const targetId = e.target_id; + + // Create poll + await createPoll( + channelId, + `Detected deafen event. Executor: <@${executorId}> → Target: <@${targetId}>` + ); + + // Taunt + await sendMessage(channelId, "Justice incoming."); + }); + + // We already responded above + return c.body(null, 204); + } + + // Ignore others + return c.body(null, 204); +}); + +export default { + port: PORT, + fetch: app.fetch, +}; diff --git a/tsconfig.json b/tsconfig.json index c442b33..9136c14 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,4 +4,4 @@ "jsx": "react-jsx", "jsxImportSource": "hono/jsx" } -} \ No newline at end of file +}