add Dockerfile and implement initial bot functionality with Hono framework
This commit is contained in:
parent
4c373d16e3
commit
fdfb5304d9
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@ -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"]
|
||||||
3
bun.lock
3
bun.lock
@ -5,6 +5,7 @@
|
|||||||
"name": "police-discord-bot",
|
"name": "police-discord-bot",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hono": "^4.9.4",
|
"hono": "^4.9.4",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
@ -24,6 +25,8 @@
|
|||||||
|
|
||||||
"hono": ["hono@4.9.4", "", {}, "sha512-61hl6MF6ojTl/8QSRu5ran6GXt+6zsngIUN95KzF5v5UjiX/xnrLR358BNRawwIRO49JwUqJqQe3Rb2v559R8Q=="],
|
"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=="],
|
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "police-discord-bot",
|
"name": "police-discord-bot",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --hot src/index.ts"
|
"dev": "bun run --hot src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hono": "^4.9.4"
|
"hono": "^4.9.4",
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
210
src/index.ts
210
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) => {
|
if (!BOT_TOKEN || !APP_ID || !PUBLIC_KEY || !ADMIN_KEY) {
|
||||||
return c.text('Hello Hono!')
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@ -4,4 +4,4 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "hono/jsx"
|
"jsxImportSource": "hono/jsx"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user