Compare commits
3 Commits
cf7c8d4a95
...
fdbcfe3887
| Author | SHA1 | Date | |
|---|---|---|---|
| fdbcfe3887 | |||
| 6272bb62ff | |||
| 5b4832a2cb |
36
README.md
36
README.md
@ -1,11 +1,29 @@
|
|||||||
To install dependencies:
|
# Police Discord Bot
|
||||||
```sh
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
To run:
|
## Setup
|
||||||
```sh
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
||||||
30
bun.lock
30
bun.lock
@ -5,7 +5,9 @@
|
|||||||
"name": "police-discord-bot",
|
"name": "police-discord-bot",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hono": "^4.9.4",
|
"hono": "^4.9.4",
|
||||||
|
"pino": "^9.9.0",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
|
"zod": "^4.1.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
@ -19,14 +21,42 @@
|
|||||||
|
|
||||||
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
|
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
|
||||||
|
|
||||||
|
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
|
"bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
|
"fast-redact": ["fast-redact@3.5.0", "", {}, "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="],
|
||||||
|
|
||||||
"hono": ["hono@4.9.4", "", {}, "sha512-61hl6MF6ojTl/8QSRu5ran6GXt+6zsngIUN95KzF5v5UjiX/xnrLR358BNRawwIRO49JwUqJqQe3Rb2v559R8Q=="],
|
"hono": ["hono@4.9.4", "", {}, "sha512-61hl6MF6ojTl/8QSRu5ran6GXt+6zsngIUN95KzF5v5UjiX/xnrLR358BNRawwIRO49JwUqJqQe3Rb2v559R8Q=="],
|
||||||
|
|
||||||
|
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||||
|
|
||||||
|
"pino": ["pino@9.9.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-zxsRIQG9HzG+jEljmvmZupOMDUQ0Jpj0yAgE28jQvvrdYTlEaiGwelJpdndMl/MBuRr70heIj83QyqJUWaU8mQ=="],
|
||||||
|
|
||||||
|
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
||||||
|
|
||||||
|
"pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="],
|
||||||
|
|
||||||
|
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||||
|
|
||||||
|
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||||
|
|
||||||
|
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||||
|
|
||||||
|
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||||
|
|
||||||
|
"sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="],
|
||||||
|
|
||||||
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
|
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
||||||
|
|
||||||
"tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="],
|
"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=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.1.5", "", {}, "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hono": "^4.9.4",
|
"hono": "^4.9.4",
|
||||||
"tweetnacl": "^1.0.3"
|
"pino": "^9.9.0",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|||||||
40
src/commands.ts
Normal file
40
src/commands.ts
Normal file
@ -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),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
20
src/config.ts
Normal file
20
src/config.ts
Normal file
@ -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<typeof EnvSchema>;
|
||||||
|
|
||||||
|
export const Config: EnvConfig = EnvSchema.parse(process.env);
|
||||||
53
src/discord/audit-log.ts
Normal file
53
src/discord/audit-log.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { fetchDiscord } from "./core";
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
application_commands: any[];
|
||||||
|
audit_log_entries: AuditLogEntry[];
|
||||||
|
auto_moderation_rules: any[];
|
||||||
|
guild_scheduled_events: any[];
|
||||||
|
integrations: any[];
|
||||||
|
threads: any[];
|
||||||
|
users: any[];
|
||||||
|
webhooks: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
target_id: string | null;
|
||||||
|
changes?: AuditLogChange[];
|
||||||
|
user_id: string | null;
|
||||||
|
id: string;
|
||||||
|
action_type: number;
|
||||||
|
options?: any;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogChange {
|
||||||
|
new_value?: any;
|
||||||
|
old_value?: any;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAuditLog = async (options: {
|
||||||
|
guildId: string;
|
||||||
|
userId?: string;
|
||||||
|
actionType?: number;
|
||||||
|
before?: string;
|
||||||
|
after?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<AuditLog> => {
|
||||||
|
const { guildId, userId, actionType, before, after, limit } = options;
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (userId) query.append("user_id", userId);
|
||||||
|
if (actionType) query.append("action_type", String(actionType));
|
||||||
|
if (before) query.append("before", before);
|
||||||
|
if (after) query.append("after", after);
|
||||||
|
if (limit) query.append("limit", String(limit));
|
||||||
|
|
||||||
|
const queryString = query.toString();
|
||||||
|
const path = `/guilds/${guildId}/audit-logs${
|
||||||
|
queryString ? `?${queryString}` : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const response = await fetchDiscord(path);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
21
src/discord/core.ts
Normal file
21
src/discord/core.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Config } from "../config";
|
||||||
|
|
||||||
|
export const fetchDiscord = async (
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> => {
|
||||||
|
const response = await fetch(`${Config.DISCORD_API}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bot ${Config.BOT_TOKEN}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.status === 429) {
|
||||||
|
const data = await response.json().catch(() => ({}) as any);
|
||||||
|
const retry = (data?.retry_after ? Number(data.retry_after) : 1) * 1000;
|
||||||
|
await new Promise((r) => setTimeout(r, retry));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
252
src/discord/guild.ts
Normal file
252
src/discord/guild.ts
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import { fetchDiscord } from "./core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Discord guild.
|
||||||
|
* This is a partial interface based on the Discord API documentation.
|
||||||
|
*/
|
||||||
|
export interface Guild {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
owner_id: string;
|
||||||
|
verification_level: number;
|
||||||
|
roles: any[]; // Array of role objects
|
||||||
|
emojis: any[]; // Array of emoji objects
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Guild Member.
|
||||||
|
* This is a partial interface.
|
||||||
|
*/
|
||||||
|
export interface GuildMember {
|
||||||
|
user?: any; // User object
|
||||||
|
nick?: string | null;
|
||||||
|
roles: string[];
|
||||||
|
joined_at: string; // ISO8601 timestamp
|
||||||
|
deaf: boolean;
|
||||||
|
mute: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Role.
|
||||||
|
*/
|
||||||
|
export interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: number;
|
||||||
|
hoist: boolean;
|
||||||
|
position: number;
|
||||||
|
permissions: string;
|
||||||
|
managed: boolean;
|
||||||
|
mentionable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Ban.
|
||||||
|
*/
|
||||||
|
export interface Ban {
|
||||||
|
reason: string | null;
|
||||||
|
user: any; // User object
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a guild by ID.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @param withCounts Whether to include approximate member and presence counts.
|
||||||
|
* @returns A guild object.
|
||||||
|
*/
|
||||||
|
export const getGuild = async (
|
||||||
|
guildId: string,
|
||||||
|
withCounts?: boolean,
|
||||||
|
): Promise<Guild> => {
|
||||||
|
const path = `/guilds/${guildId}${withCounts ? "?with_counts=true" : ""}`;
|
||||||
|
const res = await fetchDiscord(path);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modify a guild's settings.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @param data The new guild data.
|
||||||
|
* @returns The updated guild object.
|
||||||
|
*/
|
||||||
|
export const modifyGuild = async (
|
||||||
|
guildId: string,
|
||||||
|
data: any,
|
||||||
|
): Promise<Guild> => {
|
||||||
|
const path = `/guilds/${guildId}`;
|
||||||
|
const res = await fetchDiscord(path, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of guild channel objects.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @returns A list of channel objects.
|
||||||
|
*/
|
||||||
|
export const getGuildChannels = async (guildId: string): Promise<any[]> => {
|
||||||
|
const path = `/guilds/${guildId}/channels`;
|
||||||
|
const res = await fetchDiscord(path);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new channel object for the guild.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @param data The channel data.
|
||||||
|
* @returns The new channel object.
|
||||||
|
*/
|
||||||
|
export const createGuildChannel = async (
|
||||||
|
guildId: string,
|
||||||
|
data: any,
|
||||||
|
): Promise<any> => {
|
||||||
|
const path = `/guilds/${guildId}/channels`;
|
||||||
|
const res = await fetchDiscord(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of guild member objects that are members of the guild.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @param options Options for listing members.
|
||||||
|
* @returns A list of guild member objects.
|
||||||
|
*/
|
||||||
|
export const listGuildMembers = async (
|
||||||
|
guildId: string,
|
||||||
|
options?: { limit?: number; after?: string },
|
||||||
|
): Promise<GuildMember[]> => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (options?.limit) query.append("limit", String(options.limit));
|
||||||
|
if (options?.after) query.append("after", options.after);
|
||||||
|
const queryString = query.toString();
|
||||||
|
const path = `/guilds/${guildId}/members${queryString ? `?${queryString}` : ""}`;
|
||||||
|
const res = await fetchDiscord(path);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a guild member object for the specified user.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @param userId The ID of the user.
|
||||||
|
* @returns A guild member object.
|
||||||
|
*/
|
||||||
|
export const getGuildMember = async (
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<GuildMember> => {
|
||||||
|
const path = `/guilds/${guildId}/members/${userId}`;
|
||||||
|
const res = await fetchDiscord(path);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modify attributes of a guild member.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @param userId The ID of the user.
|
||||||
|
* @param data The data to update.
|
||||||
|
* @returns The updated guild member.
|
||||||
|
*/
|
||||||
|
export const modifyGuildMember = async (
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
data: any,
|
||||||
|
): Promise<GuildMember> => {
|
||||||
|
const path = `/guilds/${guildId}/members/${userId}`;
|
||||||
|
const res = await fetchDiscord(path, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a member from a guild.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @param userId The ID of the user to remove.
|
||||||
|
*/
|
||||||
|
export const removeGuildMember = async (
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const path = `/guilds/${guildId}/members/${userId}`;
|
||||||
|
await fetchDiscord(path, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of role objects for the guild.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @returns A list of role objects.
|
||||||
|
*/
|
||||||
|
export const getGuildRoles = async (guildId: string): Promise<Role[]> => {
|
||||||
|
const path = `/guilds/${guildId}/roles`;
|
||||||
|
const res = await fetchDiscord(path);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of ban objects for the users banned from this guild.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @returns A list of ban objects.
|
||||||
|
*/
|
||||||
|
export const getGuildBans = async (guildId: string): Promise<Ban[]> => {
|
||||||
|
const path = `/guilds/${guildId}/bans`;
|
||||||
|
const res = await fetchDiscord(path);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a guild ban, and optionally delete previous messages sent by the banned user.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @param userId The ID of the user to ban.
|
||||||
|
* @param options Options for the ban.
|
||||||
|
*/
|
||||||
|
export const createGuildBan = async (
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
options?: { delete_message_seconds?: number },
|
||||||
|
): Promise<void> => {
|
||||||
|
const path = `/guilds/${guildId}/bans/${userId}`;
|
||||||
|
await fetchDiscord(path, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the ban for a user.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @param userId The ID of the user to unban.
|
||||||
|
*/
|
||||||
|
export const removeGuildBan = async (
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const path = `/guilds/${guildId}/bans/${userId}`;
|
||||||
|
await fetchDiscord(path, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a role from a guild member.
|
||||||
|
* @param guildId The ID of the guild.
|
||||||
|
* @param userId The ID of the user.
|
||||||
|
* @param roleId The ID of the role to remove.
|
||||||
|
*/
|
||||||
|
export const removeGuildMemberRole = async (
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
roleId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const path = `/guilds/${guildId}/members/${userId}/roles/${roleId}`;
|
||||||
|
await fetchDiscord(path, { method: "DELETE" });
|
||||||
|
};
|
||||||
5
src/discord/index.ts
Normal file
5
src/discord/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from "./audit-log";
|
||||||
|
export * from "./core";
|
||||||
|
export * from "./guild";
|
||||||
|
export * from "./message";
|
||||||
|
export * from "./poll";
|
||||||
219
src/discord/message.ts
Normal file
219
src/discord/message.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import { fetchDiscord } from "./core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a message sent in a channel within Discord.
|
||||||
|
* This is a partial interface based on the Discord API documentation.
|
||||||
|
*/
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
channel_id: string;
|
||||||
|
author: any; // User object
|
||||||
|
content: string;
|
||||||
|
timestamp: string; // ISO8601 timestamp
|
||||||
|
edited_timestamp: string | null; // ISO8601 timestamp
|
||||||
|
tts: boolean;
|
||||||
|
mention_everyone: boolean;
|
||||||
|
mentions: any[]; // array of user objects
|
||||||
|
mention_roles: string[]; // array of role object ids
|
||||||
|
mention_channels?: any[]; // array of channel mention objects
|
||||||
|
attachments: any[]; // array of attachment objects
|
||||||
|
embeds: any[]; // array of embed objects
|
||||||
|
reactions?: any[]; // array of reaction objects
|
||||||
|
nonce?: number | string;
|
||||||
|
pinned: boolean;
|
||||||
|
webhook_id?: string;
|
||||||
|
type: number;
|
||||||
|
activity?: any; // message activity object
|
||||||
|
application?: any; // partial application object
|
||||||
|
application_id?: string;
|
||||||
|
flags?: number;
|
||||||
|
message_reference?: any; // message reference object
|
||||||
|
referenced_message?: Message | null;
|
||||||
|
interaction?: any; // message interaction object
|
||||||
|
thread?: any; // channel object
|
||||||
|
components?: any[]; // array of message components
|
||||||
|
sticker_items?: any[]; // array of message sticker item objects
|
||||||
|
stickers?: any[]; // array of sticker objects
|
||||||
|
position?: number;
|
||||||
|
role_subscription_data?: any; // role subscription data object
|
||||||
|
resolved?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the messages in a channel.
|
||||||
|
* @param channelId The ID of the channel.
|
||||||
|
* @param options Options for fetching messages.
|
||||||
|
* @returns An array of message objects.
|
||||||
|
*/
|
||||||
|
export const getChannelMessages = async (
|
||||||
|
channelId: string,
|
||||||
|
options?: {
|
||||||
|
around?: string;
|
||||||
|
before?: string;
|
||||||
|
after?: string;
|
||||||
|
limit?: number;
|
||||||
|
},
|
||||||
|
): Promise<Message[]> => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (options?.around) query.append("around", options.around);
|
||||||
|
if (options?.before) query.append("before", options.before);
|
||||||
|
if (options?.after) query.append("after", options.after);
|
||||||
|
if (options?.limit) query.append("limit", String(options.limit));
|
||||||
|
|
||||||
|
const queryString = query.toString();
|
||||||
|
const path = `/channels/${channelId}/messages${queryString ? `?${queryString}` : ""}`;
|
||||||
|
|
||||||
|
const res = await fetchDiscord(path);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a specific message in the channel.
|
||||||
|
* @param channelId The ID of the channel.
|
||||||
|
* @param messageId The ID of the message.
|
||||||
|
* @returns A message object.
|
||||||
|
*/
|
||||||
|
export const getChannelMessage = async (
|
||||||
|
channelId: string,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<Message> => {
|
||||||
|
const path = `/channels/${channelId}/messages/${messageId}`;
|
||||||
|
const res = await fetchDiscord(path);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateMessageParams {
|
||||||
|
content?: string;
|
||||||
|
tts?: boolean;
|
||||||
|
embeds?: any[];
|
||||||
|
allowed_mentions?: any;
|
||||||
|
message_reference?: any;
|
||||||
|
components?: any[];
|
||||||
|
sticker_ids?: string[];
|
||||||
|
files?: any[];
|
||||||
|
payload_json?: string;
|
||||||
|
attachments?: any[];
|
||||||
|
flags?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a message to a guild text or DM channel.
|
||||||
|
* @param channelId The ID of the channel.
|
||||||
|
* @param data The message data.
|
||||||
|
* @returns The created message object.
|
||||||
|
*/
|
||||||
|
export const createMessage = async (
|
||||||
|
channelId: string,
|
||||||
|
data: CreateMessageParams,
|
||||||
|
): Promise<Message> => {
|
||||||
|
const path = `/channels/${channelId}/messages`;
|
||||||
|
const res = await fetchDiscord(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface EditMessageParams {
|
||||||
|
content?: string;
|
||||||
|
embeds?: any[];
|
||||||
|
flags?: number;
|
||||||
|
allowed_mentions?: any;
|
||||||
|
components?: any[];
|
||||||
|
files?: any[];
|
||||||
|
payload_json?: string;
|
||||||
|
attachments?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a previously sent message.
|
||||||
|
* @param channelId The ID of the channel.
|
||||||
|
* @param messageId The ID of the message to edit.
|
||||||
|
* @param data The new message data.
|
||||||
|
* @returns The updated message object.
|
||||||
|
*/
|
||||||
|
export const editMessage = async (
|
||||||
|
channelId: string,
|
||||||
|
messageId: string,
|
||||||
|
data: EditMessageParams,
|
||||||
|
): Promise<Message> => {
|
||||||
|
const path = `/channels/${channelId}/messages/${messageId}`;
|
||||||
|
const res = await fetchDiscord(path, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a message.
|
||||||
|
* @param channelId The ID of the channel.
|
||||||
|
* @param messageId The ID of the message to delete.
|
||||||
|
*/
|
||||||
|
export const deleteMessage = async (
|
||||||
|
channelId: string,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const path = `/channels/${channelId}/messages/${messageId}`;
|
||||||
|
await fetchDiscord(path, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple messages in a single request.
|
||||||
|
* @param channelId The ID of the channel.
|
||||||
|
* @param messageIds An array of message IDs to delete.
|
||||||
|
*/
|
||||||
|
export const bulkDeleteMessages = async (
|
||||||
|
channelId: string,
|
||||||
|
messageIds: string[],
|
||||||
|
): Promise<void> => {
|
||||||
|
const path = `/channels/${channelId}/messages/bulk-delete`;
|
||||||
|
await fetchDiscord(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ messages: messageIds }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a reaction for the message.
|
||||||
|
* @param channelId The ID of the channel.
|
||||||
|
* @param messageId The ID of the message.
|
||||||
|
* @param emoji The emoji to react with.
|
||||||
|
*/
|
||||||
|
export const addReaction = async (
|
||||||
|
channelId: string,
|
||||||
|
messageId: string,
|
||||||
|
emoji: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const path = `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(
|
||||||
|
emoji,
|
||||||
|
)}/@me`;
|
||||||
|
await fetchDiscord(path, { method: "PUT" });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of users that reacted with this emoji.
|
||||||
|
* @param channelId The ID of the channel.
|
||||||
|
* @param messageId The ID of the message.
|
||||||
|
* @param emoji The emoji.
|
||||||
|
* @param options Options for fetching reactions.
|
||||||
|
* @returns A list of user objects.
|
||||||
|
*/
|
||||||
|
export const getReactions = async (
|
||||||
|
channelId: string,
|
||||||
|
messageId: string,
|
||||||
|
emoji: string,
|
||||||
|
options?: { after?: string; limit?: number },
|
||||||
|
): Promise<any[]> => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (options?.after) query.append("after", options.after);
|
||||||
|
if (options?.limit) query.append("limit", String(options.limit ?? 100));
|
||||||
|
const queryString = query.toString();
|
||||||
|
const path = `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(
|
||||||
|
emoji,
|
||||||
|
)}${queryString ? `?${queryString}` : ""}`;
|
||||||
|
const res = await fetchDiscord(path);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
81
src/discord/poll.ts
Normal file
81
src/discord/poll.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { fetchDiscord } from "./core";
|
||||||
|
import { Message } from "./message";
|
||||||
|
|
||||||
|
export interface PollMedia {
|
||||||
|
text?: string;
|
||||||
|
emoji?: any; // Partial Emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollAnswer {
|
||||||
|
answer_id: number;
|
||||||
|
poll_media: PollMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollAnswerCount {
|
||||||
|
id: number;
|
||||||
|
count: number;
|
||||||
|
me_voted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollResults {
|
||||||
|
is_finalized: boolean;
|
||||||
|
answer_counts: PollAnswerCount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Poll {
|
||||||
|
question: PollMedia;
|
||||||
|
answers: PollAnswer[];
|
||||||
|
expiry: string | null; // ISO8601 timestamp
|
||||||
|
allow_multiselect: boolean;
|
||||||
|
layout_type: number;
|
||||||
|
results?: PollResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollCreateRequest {
|
||||||
|
question: PollMedia;
|
||||||
|
answers: PollAnswer[];
|
||||||
|
duration?: number;
|
||||||
|
allow_multiselect?: boolean;
|
||||||
|
layout_type?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of users that voted for a specific answer.
|
||||||
|
* @param channelId The ID of the channel.
|
||||||
|
* @param messageId The ID of the message with the poll.
|
||||||
|
* @param answerId The ID of the answer.
|
||||||
|
* @param options Options for fetching voters.
|
||||||
|
* @returns A list of user objects.
|
||||||
|
*/
|
||||||
|
export const getAnswerVoters = async (
|
||||||
|
channelId: string,
|
||||||
|
messageId: string,
|
||||||
|
answerId: number,
|
||||||
|
options?: { after?: string; limit?: number },
|
||||||
|
): Promise<{ users: any[] }> => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (options?.after) query.append("after", options.after);
|
||||||
|
if (options?.limit) query.append("limit", String(options.limit));
|
||||||
|
const queryString = query.toString();
|
||||||
|
|
||||||
|
const path = `/channels/${channelId}/polls/${messageId}/answers/${answerId}${queryString ? `?${queryString}` : ""}`;
|
||||||
|
const res = await fetchDiscord(path);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediately ends a poll.
|
||||||
|
* @param channelId The ID of the channel.
|
||||||
|
* @param messageId The ID of the message with the poll.
|
||||||
|
* @returns The updated message object.
|
||||||
|
*/
|
||||||
|
export const endPoll = async (
|
||||||
|
channelId: string,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<Message> => {
|
||||||
|
const path = `/channels/${channelId}/polls/${messageId}/expire`;
|
||||||
|
const res = await fetchDiscord(path, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
482
src/index.ts
482
src/index.ts
@ -1,113 +1,38 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { Config } from "./config";
|
||||||
|
import { logger } from "./logger";
|
||||||
import * as nacl from "tweetnacl";
|
import * as nacl from "tweetnacl";
|
||||||
|
import * as Discord from "./discord";
|
||||||
|
|
||||||
/** ====== Env ====== */
|
logger.info(
|
||||||
const BOT_TOKEN = process.env.BOT_TOKEN!;
|
{ port: Config.PORT, guildId: Config.DEFAULT_GUILD_ID },
|
||||||
const APP_ID = process.env.APP_ID!;
|
"Discord police bot is starting",
|
||||||
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const snowflakeToMs = (id: string) =>
|
const snowflakeToMs = (id: string) =>
|
||||||
Number((BigInt(id) >> BigInt(22)) + BigInt(1420070400000));
|
Number((BigInt(id) >> BigInt(22)) + BigInt(1420070400000));
|
||||||
|
|
||||||
async function discordFetch(
|
|
||||||
path: string,
|
|
||||||
init?: RequestInit
|
|
||||||
): Promise<Response> {
|
|
||||||
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) {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editOriginal(token: string, content: string) {
|
async function editOriginal(token: string, content: string) {
|
||||||
return fetch(`${API}/webhooks/${APP_ID}/${token}/messages/@original`, {
|
return Discord.fetchDiscord(
|
||||||
|
`/webhooks/${Config.APP_ID}/${token}/messages/@original`,
|
||||||
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({ content }),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ====== Audit utils ====== */
|
function findRecentVoicePunish(audit: Discord.AuditLog): {
|
||||||
/** Find most recent MEMBER_UPDATE with deaf=true or mute=true within window */
|
entry: Discord.AuditLogEntry;
|
||||||
function findRecentVoicePunish(
|
kind: "deafen" | "mute";
|
||||||
audit: any
|
} | null {
|
||||||
): { entry: any; kind: "deafen" | "mute" } | null {
|
|
||||||
const entries = audit?.audit_log_entries ?? [];
|
const entries = audit?.audit_log_entries ?? [];
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.action_type !== 24) continue; // MEMBER_UPDATE
|
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;
|
const mute = changes.find((c: any) => c.key === "mute")?.new_value === true;
|
||||||
if (!deaf && !mute) continue;
|
if (!deaf && !mute) continue;
|
||||||
const ageSec = (Date.now() - snowflakeToMs(e.id)) / 1000;
|
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 { entry: e, kind: deaf ? "deafen" : "mute" };
|
||||||
}
|
}
|
||||||
return null;
|
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(
|
async function listReactionUsersMulti(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
emojis: string[]
|
emojis: string[],
|
||||||
) {
|
) {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
emojis.map((e) => listReactionUsers(channelId, messageId, e))
|
emojis.map((e) => Discord.getReactions(channelId, messageId, e)),
|
||||||
);
|
);
|
||||||
const byId = new Map<string, any>();
|
const byId = new Map<string, any>();
|
||||||
for (const arr of results) {
|
for (const arr of results) {
|
||||||
@ -179,39 +62,6 @@ async function listReactionUsersMulti(
|
|||||||
return Array.from(byId.values());
|
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 randomSystem = () => {
|
||||||
const pool = ["คุณเป็นคนพูดจาเกรียนๆ"];
|
const pool = ["คุณเป็นคนพูดจาเกรียนๆ"];
|
||||||
return pool[Math.floor(Math.random() * pool.length)];
|
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", {
|
const r = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
Authorization: `Bearer ${Config.OPENROUTER_API_KEY}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -244,262 +94,198 @@ async function callOpenRouterChat(userPrompt: string) {
|
|||||||
return j?.choices?.[0]?.message?.content || "(no content)";
|
return j?.choices?.[0]?.message?.content || "(no content)";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ====== Hono app ====== */
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
/** Health */
|
app.get("/health", (c) => c.text("ok"));
|
||||||
app.get("/healthz", (c) => c.text("ok"));
|
|
||||||
|
|
||||||
/** Admin: register both commands */
|
const hexToUint8 = (hex: string) => {
|
||||||
app.post("/admin/register", async (c) => {
|
if (hex.length % 2 !== 0) throw new Error("Invalid hex");
|
||||||
if (c.req.header("X-Admin-Key") !== ADMIN_KEY)
|
const u = new Uint8Array(hex.length / 2);
|
||||||
return c.text("forbidden", 403);
|
for (let i = 0; i < hex.length; i += 2)
|
||||||
const { guildId = DEFAULT_GUILD_ID } = await c.req.json().catch(() => ({}));
|
u[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||||
|
return u;
|
||||||
|
};
|
||||||
|
|
||||||
const cmds = [
|
async function verifySignatureSafe(req: Request, rawBody: string) {
|
||||||
{
|
const sig = req.headers.get("X-Signature-Ed25519") || "";
|
||||||
name: "checkaudit",
|
const ts = req.headers.get("X-Signature-Timestamp") || "";
|
||||||
description: "Scan recent audit logs for mute/deafen",
|
if (!sig || !ts) return false;
|
||||||
type: 1,
|
const enc = new TextEncoder();
|
||||||
},
|
const msg = enc.encode(ts + rawBody);
|
||||||
{
|
return nacl.sign.detached.verify(
|
||||||
name: "talk",
|
msg,
|
||||||
description: "Chat with the AI",
|
hexToUint8(sig),
|
||||||
type: 1,
|
hexToUint8(Config.PUBLIC_KEY),
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: "prompt",
|
|
||||||
description: "Your message",
|
|
||||||
type: 3,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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) => {
|
app.post("/interactions", async (c) => {
|
||||||
const raw = await c.req.text();
|
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);
|
return c.text("unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
const interaction = JSON.parse(raw);
|
const interaction = JSON.parse(raw);
|
||||||
|
logger.info({ interaction }, "Received interaction");
|
||||||
const { type } = interaction;
|
const { type } = interaction;
|
||||||
|
|
||||||
// PING
|
if (type === 1) {
|
||||||
if (type === 1) return c.json({ type: 1 });
|
return c.json({ type: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
// APPLICATION_COMMAND
|
|
||||||
if (type === 2) {
|
if (type === 2) {
|
||||||
const name = interaction?.data?.name;
|
const { data, id, token, guild_id, channel_id, application_id } =
|
||||||
const id = interaction.id as string;
|
interaction;
|
||||||
const token = interaction.token as string;
|
const commandName = data.name;
|
||||||
const guildId = interaction.guild_id as string;
|
|
||||||
const channelId = interaction.channel_id as string;
|
|
||||||
|
|
||||||
if (name === "checkaudit") {
|
if (commandName === "checkaudit") {
|
||||||
// Immediate ephemeral ACK
|
|
||||||
await interactionCallback(id, token, { type: 5, data: { flags: 64 } });
|
await interactionCallback(id, token, { type: 5, data: { flags: 64 } });
|
||||||
|
|
||||||
// Background job
|
|
||||||
queueMicrotask(async () => {
|
queueMicrotask(async () => {
|
||||||
// Fetch audit logs
|
try {
|
||||||
const logsResp = await discordFetch(
|
const auditLogs = await Discord.getAuditLog({
|
||||||
`/guilds/${guildId}/audit-logs?action_type=24&limit=10`
|
guildId: Config.DEFAULT_GUILD_ID,
|
||||||
);
|
actionType: 24,
|
||||||
if (!logsResp.ok) {
|
limit: 10,
|
||||||
await editOriginal(token, "Failed to read audit logs.");
|
});
|
||||||
return;
|
|
||||||
}
|
const hit = findRecentVoicePunish(auditLogs);
|
||||||
const logs = await logsResp.json();
|
|
||||||
const hit = findRecentVoicePunish(logs);
|
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
await editOriginal(token, "No recent mute/deafen found.");
|
await editOriginal(token, "No recent mute/deafen found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const e = hit.entry;
|
const { entry, kind } = hit;
|
||||||
const executorId = e.user_id as string; // the user who did the action
|
const executorId = entry.user_id!;
|
||||||
const targetId = e.target_id as string; // the member affected
|
const targetId = entry.target_id!;
|
||||||
const kind = hit.kind === "deafen" ? "ปิดหู" : "ปิดไมค์";
|
const kindText = kind === "deafen" ? "ปิดหู" : "ปิดไมค์";
|
||||||
|
|
||||||
// Decision message
|
const RENAME_EMOJIS = ["✏️", "✏"];
|
||||||
const intro = `มีคนโดนทำร้าย😭😭 ${kind}\nคนทำ!: <@${executorId}> → คนโดน: <@${targetId}>\nเลือกการลงโทษโดยการกดส่งอีโมจิ:\n${RENAME_EMOJIS[0]} = เปลี่ยนชื่อเล่น, ${ROLE_EMOJIS[0]} = ลบ Role`;
|
const ROLE_EMOJIS = ["😭"];
|
||||||
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
|
const intro = `มีคนโดนทำร้าย😭😭 ${kindText}\nคนทำ!: <@${executorId}> → คนโดน: <@${targetId}>\nเลือกการลงโทษโดยการกดส่งอีโมจิ:\n${RENAME_EMOJIS[0]} = เปลี่ยนชื่อเล่น, ${ROLE_EMOJIS[0]} = ลบ Role`;
|
||||||
for (const e of RENAME_EMOJIS) await addReaction(channelId, msgId, e);
|
const sent = await Discord.createMessage(channel_id, {
|
||||||
for (const e of ROLE_EMOJIS) await addReaction(channelId, msgId, e);
|
content: intro,
|
||||||
|
});
|
||||||
|
|
||||||
// Identify the bot user ID (same as application_id for bot apps)
|
for (const e of RENAME_EMOJIS)
|
||||||
const botUserId = String(interaction.application_id);
|
await Discord.addReaction(channel_id, sent.id, e);
|
||||||
|
for (const e of ROLE_EMOJIS)
|
||||||
|
await Discord.addReaction(channel_id, sent.id, e);
|
||||||
|
|
||||||
// Poll reactions up to timeout
|
const botUserId = String(application_id);
|
||||||
const deadline = Date.now() + REACTION_TIMEOUT_SEC * 1000;
|
const deadline = Date.now() + Config.REACTION_TIMEOUT_SEC * 1000;
|
||||||
let chosen: "rename" | "role" | null = null;
|
let chosen: "rename" | "role" | null = null;
|
||||||
|
|
||||||
while (Date.now() < deadline && !chosen) {
|
while (Date.now() < deadline && !chosen) {
|
||||||
const [renameUsersAll, roleUsersAll] = await Promise.all([
|
const [renameUsers, roleUsers] = await Promise.all([
|
||||||
listReactionUsersMulti(channelId, msgId, RENAME_EMOJIS),
|
listReactionUsersMulti(channel_id, sent.id, RENAME_EMOJIS),
|
||||||
listReactionUsersMulti(channelId, msgId, ROLE_EMOJIS),
|
listReactionUsersMulti(channel_id, sent.id, ROLE_EMOJIS),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Exclude the bot itself from counts
|
const DECIDER_USER_ID = "311380871901085707";
|
||||||
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(
|
const deciderRename = renameUsers.some(
|
||||||
(u: any) => u?.id === DECIDER_USER_ID
|
(u) => 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}`
|
|
||||||
);
|
);
|
||||||
|
const deciderRole = roleUsers.some((u) => u.id === DECIDER_USER_ID);
|
||||||
|
|
||||||
if (deciderRename) chosen = "rename";
|
if (deciderRename) chosen = "rename";
|
||||||
else if (deciderRole) chosen = "role";
|
else if (deciderRole) chosen = "role";
|
||||||
else {
|
else if (renameUsers.filter((u) => u.id !== botUserId).length >= 2)
|
||||||
// Otherwise: first path to reach 2 human votes
|
chosen = "rename";
|
||||||
if (renameUsers.length >= 2) chosen = "rename";
|
else if (roleUsers.filter((u) => u.id !== botUserId).length >= 2)
|
||||||
else if (roleUsers.length >= 2) chosen = "role";
|
chosen = "role";
|
||||||
}
|
|
||||||
|
|
||||||
if (!chosen) await sleep(2000);
|
if (!chosen) await new Promise((r) => setTimeout(r, 2000));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!chosen) {
|
if (!chosen) {
|
||||||
await sendMessage(channelId, "หมดเวลา ไม่มีการเลือกลงโทษ รอดตัวไป");
|
await Discord.createMessage(channel_id, {
|
||||||
|
content: "หมดเวลา ไม่มีการเลือกลงโทษ รอดตัวไป",
|
||||||
|
});
|
||||||
await editOriginal(token, "No decision taken.");
|
await editOriginal(token, "No decision taken.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chosen === "rename") {
|
if (chosen === "rename") {
|
||||||
// Ask for new nickname
|
const ask = await Discord.createMessage(channel_id, {
|
||||||
const ask = await sendMessage(
|
content: `เลือก: เปลี่ยนชื่อเล่นให้ <@${executorId}>\nพิมพ์ชื่อใหม่ (≤32 ตัวอักษร) ภายใน ${Config.RENAME_TIMEOUT_SEC} วินาที`,
|
||||||
channelId,
|
});
|
||||||
`เลือก: เปลี่ยนชื่อเล่นให้ <@${executorId}>\nพิมพ์ชื่อใหม่ (≤32 ตัวอักษร) ภายใน ${RENAME_TIMEOUT_SEC} วินาที`
|
const renameDeadline =
|
||||||
);
|
Date.now() + Config.RENAME_TIMEOUT_SEC * 1000;
|
||||||
if (!ask.ok) {
|
|
||||||
await sendMessage(channelId, "ถามชื่อไม่ได้ ทำไมอ่า");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const askId = ask.data.id as string;
|
|
||||||
const renameDeadline = Date.now() + RENAME_TIMEOUT_SEC * 1000;
|
|
||||||
let newNick: string | null = null;
|
let newNick: string | null = null;
|
||||||
|
|
||||||
while (Date.now() < renameDeadline && !newNick) {
|
while (Date.now() < renameDeadline && !newNick) {
|
||||||
const news = await fetchNewMessagesAfter(channelId, askId);
|
const newMessages = await Discord.getChannelMessages(channel_id, {
|
||||||
const firstUserMsg = news.find(
|
after: ask.id,
|
||||||
(m: any) =>
|
});
|
||||||
!m?.author?.bot &&
|
const firstUserMsg = newMessages.find(
|
||||||
typeof m?.content === "string" &&
|
(m) => !m.author.bot && m.content?.trim().length > 0,
|
||||||
m.content.trim().length > 0
|
|
||||||
);
|
);
|
||||||
if (firstUserMsg)
|
if (firstUserMsg)
|
||||||
newNick = firstUserMsg.content.trim().slice(0, 32);
|
newNick = firstUserMsg.content.trim().slice(0, 32);
|
||||||
if (!newNick) await sleep(2000);
|
if (!newNick) await new Promise((r) => setTimeout(r, 2000));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!newNick) {
|
if (!newNick) {
|
||||||
await sendMessage(
|
await Discord.createMessage(channel_id, {
|
||||||
channelId,
|
content: "หมดเวลาการลงชื่อใหม่ ไม่เปลี่ยนแม่งละชื่อ",
|
||||||
"หมดเวลาการลงชื่อใหม่ ไม่เปลี่ยนแม่งละชื่อ"
|
});
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const ok = await setNickname(guildId, executorId, newNick);
|
await Discord.modifyGuildMember(guild_id, executorId, {
|
||||||
if (ok) {
|
nick: newNick,
|
||||||
await sendMessage(
|
});
|
||||||
channelId,
|
await Discord.createMessage(channel_id, {
|
||||||
`ตั้งชื่อใหม่ให้ <@${executorId}> เป็น **${newNick}** แล้วนะคราฟ`
|
content: `ตั้งชื่อใหม่ให้ <@${executorId}> เป็น **${newNick}** แล้วนะคราฟ`,
|
||||||
);
|
});
|
||||||
} else {
|
|
||||||
await sendMessage(
|
|
||||||
channelId,
|
|
||||||
"เปลี่ยนชื่อไม่สำเร็จ (สิทธิ์ไม่พอครับพี่)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (chosen === "role") {
|
} else if (chosen === "role") {
|
||||||
if (!PUNISH_ROLE_ID) {
|
if (!Config.PUNISH_ROLE_ID) {
|
||||||
await sendMessage(
|
await Discord.createMessage(channel_id, {
|
||||||
channelId,
|
content: "ยังไม่ได้ตั้งค่า PUNISH_ROLE_ID จึงลบ role ไม่ได้",
|
||||||
"ยังไม่ได้ตั้งค่า PUNISH_ROLE_ID จึงลบ role ไม่ได้"
|
});
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const ok = await removeRole(guildId, executorId, PUNISH_ROLE_ID);
|
await Discord.removeGuildMemberRole(
|
||||||
if (ok) {
|
guild_id,
|
||||||
await sendMessage(
|
executorId,
|
||||||
channelId,
|
Config.PUNISH_ROLE_ID,
|
||||||
`ลบ role ออกจาก <@${executorId}> แล้วนะคราฟ`
|
|
||||||
);
|
);
|
||||||
} else {
|
await Discord.createMessage(channel_id, {
|
||||||
await sendMessage(
|
content: `ลบ role ออกจาก <@${executorId}> แล้วนะคราฟ`,
|
||||||
channelId,
|
});
|
||||||
"ลบ role ไม่สำเร็จ (สิทธิ์ไม่พอครับนาย)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Taunt
|
await Discord.createMessage(channel_id, { content: "โดนซะบ้าง 🙂" });
|
||||||
await sendMessage(channelId, "โดนซะบ้าง 🙂");
|
|
||||||
await editOriginal(token, "Done.");
|
await editOriginal(token, "Done.");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, "Error in checkaudit command");
|
||||||
|
await editOriginal(token, "An error occurred.");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// We already deferred
|
|
||||||
return c.body(null, 204);
|
return c.body(null, 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === "talk") {
|
if (commandName === "talk") {
|
||||||
// Read optional prompt
|
const prompt =
|
||||||
const promptOpt = interaction?.data?.options?.find(
|
data.options?.find((o: any) => o.name === "prompt")?.value || "";
|
||||||
(o: any) => o.name === "prompt"
|
|
||||||
);
|
|
||||||
const prompt = promptOpt?.value || "";
|
|
||||||
|
|
||||||
// Deferred ephemeral
|
|
||||||
await interactionCallback(id, token, { type: 5, data: { flags: 64 } });
|
await interactionCallback(id, token, { type: 5, data: { flags: 64 } });
|
||||||
|
|
||||||
queueMicrotask(async () => {
|
queueMicrotask(async () => {
|
||||||
|
try {
|
||||||
const reply = await callOpenRouterChat(prompt);
|
const reply = await callOpenRouterChat(prompt);
|
||||||
await editOriginal(token, reply);
|
await editOriginal(token, reply);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, "Error in talk command");
|
||||||
|
await editOriginal(token, "An error occurred.");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.body(null, 204);
|
return c.body(null, 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown command
|
|
||||||
await interactionCallback(id, token, {
|
await interactionCallback(id, token, {
|
||||||
type: 4,
|
type: 4,
|
||||||
data: { content: "Unknown command", flags: 64 },
|
data: { content: "Unknown command", flags: 64 },
|
||||||
@ -511,6 +297,6 @@ app.post("/interactions", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
port: PORT,
|
port: Config.PORT,
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
};
|
};
|
||||||
|
|||||||
8
src/logger.ts
Normal file
8
src/logger.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { pino } from "pino";
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL || "info",
|
||||||
|
transport: {
|
||||||
|
target: "pino-pretty",
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user