feat: modernize and harden bot deployment

This commit is contained in:
2026-08-13 18:31:06 +09:00
parent 09ccb03626
commit 061dbf0f3f
18 changed files with 698 additions and 1702 deletions
+4
View File
@@ -0,0 +1,4 @@
.git
.env
config.json
README.md
+3
View File
@@ -130,3 +130,6 @@ dist
.yarn/install-state.gz
.pnp.*
# Runtime configuration (mounted as persistent data in production)
config.json
+7 -8
View File
@@ -1,13 +1,12 @@
FROM node:22-alpine
FROM denoland/deno:alpine-2.9.4@sha256:13851184d6705150b8b230c5377f26c3bd182865d28700bb72bc0b2c271b504a
WORKDIR /app
COPY package.json /app
COPY package-lock.json /app
COPY .env /app
COPY tsconfig.json /app
COPY src /app/src
COPY --chown=deno:deno deno.json deno.lock ./
COPY --chown=deno:deno src ./src
RUN npm i
RUN deno cache --frozen src/index.ts
CMD ["npm", "run", "start"]
USER deno
CMD ["task", "start"]
+35
View File
@@ -1 +1,36 @@
# discord-reaction-award
A Discord bot that republishes highly reacted messages into an award channel. Each user's first
reaction contributes 1 point; additional emoji from the same user have diminishing weight (1/3, 1/5,
...).
## Configure Discord
The bot needs the Guilds, Guild Messages, Guild Message Reactions, and privileged Message Content
gateway intents. It needs these channel permissions:
- View Channel
- Read Message History
- Send Messages
- Embed Links
Set TOKEN and CLIENT_ID in .env, then start the bot:
```sh
cp default.env .env
deno task start
```
Global slash commands are registered at startup. Use `/config output` first, followed by the
optional channel list, emoji list, and score settings. Server owners and administrators can run
configuration commands.
The runtime configuration is stored in `config.json`. Set `CONFIG_PATH` to move it elsewhere. Both
`.env` and `config.json` are intentionally excluded from Git and Docker build contexts.
## Develop
```sh
deno task check
docker build -t discord-reaction-award .
```
-1
View File
@@ -1 +0,0 @@
[]
+2 -2
View File
@@ -1,2 +1,2 @@
TOKEN = 0 # Your bot token
CLIENT_ID = 0 # Your bot client ID
TOKEN=your-bot-token
CLIENT_ID=your-application-id
+18
View File
@@ -0,0 +1,18 @@
{
"tasks": {
"check": "deno fmt --check && deno lint && deno check src/index.ts && deno test",
"start": "deno run --allow-env=TOKEN,CLIENT_ID,CONFIG_PATH --allow-net=discord.com,gateway.discord.gg --allow-read=./config.json,/data/config.json --allow-write=.,/data src/index.ts"
},
"compilerOptions": {
"lib": ["deno.window"]
},
"imports": {
"@std/assert": "jsr:@std/assert@1.0.19",
"discord.js": "npm:discord.js@14.27.0"
},
"fmt": {
"lineWidth": 100,
"semiColons": true,
"singleQuote": false
}
}
Generated
+161
View File
@@ -0,0 +1,161 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@1.0.19": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14",
"npm:discord.js@14.27.0": "14.27.0"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
}
},
"npm": {
"@discordjs/builders@1.14.1": {
"integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
"dependencies": [
"@discordjs/formatters",
"@discordjs/util",
"@sapphire/shapeshift",
"discord-api-types",
"fast-deep-equal",
"ts-mixer",
"tslib"
]
},
"@discordjs/collection@1.5.3": {
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="
},
"@discordjs/collection@2.1.1": {
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="
},
"@discordjs/formatters@0.6.2": {
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"dependencies": [
"discord-api-types"
]
},
"@discordjs/rest@2.6.3": {
"integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==",
"dependencies": [
"@discordjs/collection@2.1.1",
"@discordjs/util",
"@sapphire/async-queue",
"@sapphire/snowflake",
"@vladfrangu/async_event_emitter",
"discord-api-types",
"magic-bytes.js",
"tslib",
"undici"
]
},
"@discordjs/util@1.2.0": {
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"dependencies": [
"discord-api-types"
]
},
"@discordjs/ws@1.2.3": {
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
"dependencies": [
"@discordjs/collection@2.1.1",
"@discordjs/rest",
"@discordjs/util",
"@sapphire/async-queue",
"@types/ws",
"@vladfrangu/async_event_emitter",
"discord-api-types",
"tslib",
"ws"
]
},
"@sapphire/async-queue@1.5.5": {
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="
},
"@sapphire/shapeshift@4.0.0": {
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"dependencies": [
"fast-deep-equal",
"lodash"
]
},
"@sapphire/snowflake@3.5.5": {
"integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="
},
"@types/node@26.2.0": {
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dependencies": [
"undici-types"
]
},
"@types/ws@8.18.1": {
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dependencies": [
"@types/node"
]
},
"@vladfrangu/async_event_emitter@2.4.7": {
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="
},
"discord-api-types@0.38.53": {
"integrity": "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A=="
},
"discord.js@14.27.0": {
"integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==",
"dependencies": [
"@discordjs/builders",
"@discordjs/collection@1.5.3",
"@discordjs/formatters",
"@discordjs/rest",
"@discordjs/util",
"@discordjs/ws",
"@sapphire/snowflake",
"discord-api-types",
"fast-deep-equal",
"lodash.snakecase",
"magic-bytes.js",
"tslib",
"undici"
]
},
"fast-deep-equal@3.1.3": {
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"lodash.snakecase@4.1.1": {
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="
},
"lodash@4.18.1": {
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
},
"magic-bytes.js@1.13.1": {
"integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw=="
},
"ts-mixer@6.0.4": {
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"undici-types@8.3.0": {
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="
},
"undici@6.28.0": {
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="
},
"ws@8.21.3": {
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@1.0.19",
"npm:discord.js@14.27.0"
]
}
}
-1227
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
{
"scripts": {
"start": "tsx src/index.ts"
},
"type": "module",
"dependencies": {
"discord.js": "^14.16.1",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/node": "^22.5.4",
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}
+19 -16
View File
@@ -1,24 +1,27 @@
import { EmbedBuilder, MessageReaction, PartialMessageReaction } from 'discord.js';
import { EmbedBuilder, MessageReaction, PartialMessageReaction } from "discord.js";
export default function awardEmbed(
reaction: MessageReaction | PartialMessageReaction,
score: number,
): EmbedBuilder {
const message = reaction.message;
const reactionSummary = message.reactions.cache
.map((item) => `${item.emoji} ×${item.count}`)
.join(" · ");
export default function awardEmbed(reaction: MessageReaction | PartialMessageReaction, score: number) {
function formatTime(time: Date) {
const yyyy = time.getFullYear();
const mm = time.getMonth().toString().padStart(2, '0');
const dd = time.getDate().toString().padStart(2, '0');
const hh = time.getHours().toString().padStart(2, '0');
const MM = time.getMinutes().toString().padStart(2, '0');
return `${yyyy}-${mm}-${dd} ${hh}:${MM}`;
}
return new EmbedBuilder()
.setColor(0x777777)
.setAuthor({
name: reaction.message.author?.displayName || "unknown",
iconURL: reaction.message.author?.avatarURL() || undefined,
name: message.author?.displayName || "Unknown user",
iconURL: message.author?.avatarURL() || undefined,
})
.setDescription(reaction.message.content || null)
.setThumbnail(reaction.message.attachments.filter((attachment) => !(!attachment.height && !attachment.width)).first()?.url || null)
.setDescription(message.content || null)
.setThumbnail(
message.attachments.find((attachment) => attachment.height || attachment.width)?.url ?? null,
)
.addFields({
name: `${reaction.message.reactions.cache.map((reaction) => `${reaction.emoji} x${reaction.count},`).join(' ')} / ${score.toFixed(1)}`,
value: `${reaction.message.url}${formatTime(reaction.message.createdAt)}\n-# ${reaction.message.id}`,
name: `${reactionSummary} / ${score.toFixed(1)}`.slice(0, 256),
value: `${message.url}<t:${Math.floor(message.createdTimestamp / 1000)}:f>`,
})
.setFooter({ text: `Source message: ${message.id}` });
}
+85 -72
View File
@@ -1,83 +1,96 @@
import { SlashCommandBuilder, SlashCommandSubcommandBuilder } from '@discordjs/builders';
import { ChannelType, SlashCommandBuilder, SlashCommandSubcommandBuilder } from "discord.js";
const output = new SlashCommandSubcommandBuilder()
.setName('output')
.setDescription('Set the output channel')
.addChannelOption(option =>
option.setName('channel')
.setDescription('The channel to send messages to')
.setRequired(true));
const channels = new SlashCommandSubcommandBuilder()
.setName('channels')
.setDescription('Configure channel list')
.addStringOption(option =>
option.setName('add_remove')
.setDescription('add_remove')
.setName("output")
.setDescription("Set the output channel")
.addChannelOption((option) =>
option.setName("channel")
.setDescription("The channel to send messages to")
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
.setRequired(true)
.addChoices(
{ name: 'Add', value: 'add' },
{ name: 'Remove', value: 'remove' }
))
.addChannelOption(option =>
option.setName('channel')
.setDescription('The channel to list')
.setRequired(true));
const channels_type = new SlashCommandSubcommandBuilder()
.setName('channels_type')
.setDescription('Set the channel list type')
.addStringOption(option =>
option.setName('channels_type')
.setDescription('include or exclude')
.setRequired(true)
.addChoices(
{ name: 'Include', value: 'include' },
{ name: 'Exclude', value: 'exclude' }
));
const emojis = new SlashCommandSubcommandBuilder()
.setName('emojis')
.setDescription('Configure emoji list')
.addStringOption(option =>
option.setName('add_remove')
.setDescription('add_remove')
.setRequired(true)
.addChoices(
{ name: 'Add', value: 'add' },
{ name: 'Remove', value: 'remove' }
))
.addStringOption(option =>
option.setName('emoji')
.setDescription('The emoji to list')
.setRequired(true));
const emojis_type = new SlashCommandSubcommandBuilder()
.setName('emojis_type')
.setDescription('Set the emoji list type')
.addStringOption(option =>
option.setName('emojis_type')
.setDescription('include or exclude')
.setRequired(true)
.addChoices(
{ name: 'Include', value: 'include' },
{ name: 'Exclude', value: 'exclude' }
));
const score = new SlashCommandSubcommandBuilder()
.setName('score')
.setDescription('Set the number of scores to trigger')
.addIntegerOption(option =>
option.setName('score')
.setDescription('The number of scores to trigger')
.setRequired(true));
);
const channels = new SlashCommandSubcommandBuilder()
.setName("channels")
.setDescription("Configure the channel list")
.addStringOption((option) =>
option.setName("action")
.setDescription("Add or remove the channel")
.setRequired(true)
.addChoices(
{ name: "Add", value: "add" },
{ name: "Remove", value: "remove" },
)
)
.addChannelOption((option) =>
option.setName("channel")
.setDescription("The channel to add or remove")
.setRequired(true)
);
const channelsType = new SlashCommandSubcommandBuilder()
.setName("channels_type")
.setDescription("Set the channel list type")
.addStringOption((option) =>
option.setName("type")
.setDescription("Use only listed channels, or ignore listed channels")
.setRequired(true)
.addChoices(
{ name: "Include", value: "include" },
{ name: "Exclude", value: "exclude" },
)
);
const emojis = new SlashCommandSubcommandBuilder()
.setName("emojis")
.setDescription("Configure the emoji list")
.addStringOption((option) =>
option.setName("action")
.setDescription("Add or remove the emoji")
.setRequired(true)
.addChoices(
{ name: "Add", value: "add" },
{ name: "Remove", value: "remove" },
)
)
.addStringOption((option) =>
option.setName("emoji")
.setDescription("Unicode emoji or custom emoji")
.setRequired(true)
);
const emojisType = new SlashCommandSubcommandBuilder()
.setName("emojis_type")
.setDescription("Set the emoji list type")
.addStringOption((option) =>
option.setName("type")
.setDescription("Use only listed emoji, or ignore listed emoji")
.setRequired(true)
.addChoices(
{ name: "Include", value: "include" },
{ name: "Exclude", value: "exclude" },
)
);
const score = new SlashCommandSubcommandBuilder()
.setName("score")
.setDescription("Set the score needed to award a message")
.addIntegerOption((option) =>
option.setName("score")
.setDescription("The score needed to award a message")
.setMinValue(1)
.setRequired(true)
);
const config = new SlashCommandBuilder()
.setName('config')
.setDescription('Configure the bot')
.setName("config")
.setDescription("Configure the bot")
.setDMPermission(false)
.addSubcommand(output)
.addSubcommand(channels)
.addSubcommand(channels_type)
.addSubcommand(channelsType)
.addSubcommand(emojis)
.addSubcommand(emojis_type)
.addSubcommand(emojisType)
.addSubcommand(score);
const commands = [config];
export default commands;
export default [config];
+60 -13
View File
@@ -1,21 +1,68 @@
import fs from 'fs';
import { createEntry, Entry, Type } from "./entry.ts";
import { Entry } from './entry.ts';
const configPath = Deno.env.get("CONFIG_PATH") ?? "./config.json";
let pendingWrite = Promise.resolve();
export async function writeConfig(entries: Entry[]) {
const data = JSON.stringify(entries, null, 2);
fs.writeFile('./config.json', data, (err) => {
if (err) {
console.error(err);
return;
}
export function writeConfig(entries: Entry[]): Promise<void> {
const data = `${JSON.stringify(entries, null, 2)}\n`;
pendingWrite = pendingWrite
.catch(() => undefined)
.then(async () => {
const temporaryPath = `${configPath}.tmp`;
await Deno.writeTextFile(temporaryPath, data);
await Deno.chmod(temporaryPath, 0o600);
await Deno.rename(temporaryPath, configPath);
});
return pendingWrite;
}
export function readConfig(): Entry[] {
if (!fs.existsSync('./config.json')) {
return [];
let data: string;
try {
data = Deno.readTextFileSync(configPath);
} catch (error) {
if (error instanceof Deno.errors.NotFound) return [];
throw error;
}
const data = fs.readFileSync('./config.json', 'utf8');
return JSON.parse(data);
const parsed: unknown = JSON.parse(data);
if (!Array.isArray(parsed)) throw new Error("config must contain an array");
return parsed.map((value, index) => parseEntry(value, index));
}
function parseEntry(value: unknown, index: number): Entry {
if (!value || typeof value !== "object") {
throw new Error(`config entry ${index} is invalid`);
}
const raw = value as Record<string, unknown>;
if (typeof raw.server !== "string" || raw.server.length === 0) {
throw new Error(`config entry ${index} has no server ID`);
}
const entry = createEntry(raw.server);
if (typeof raw.output_channel === "string") entry.output_channel = raw.output_channel;
if (raw.channel_type === Type.INCLUDE || raw.channel_type === Type.EXCLUDE) {
entry.channel_type = raw.channel_type;
}
if (Array.isArray(raw.channels)) entry.channels = uniqueStrings(raw.channels);
if (raw.emoji_type === Type.INCLUDE || raw.emoji_type === Type.EXCLUDE) {
entry.emoji_type = raw.emoji_type;
}
if (Array.isArray(raw.emojis)) entry.emojis = uniqueStrings(raw.emojis);
if (typeof raw.score === "number" && Number.isFinite(raw.score) && raw.score > 0) {
entry.score = raw.score;
}
if (raw.awards && typeof raw.awards === "object" && !Array.isArray(raw.awards)) {
entry.awards = Object.fromEntries(
Object.entries(raw.awards).filter(
(item): item is [string, string] => typeof item[1] === "string",
),
);
}
return entry;
}
function uniqueStrings(values: unknown[]): string[] {
return [...new Set(values.filter((item): item is string => typeof item === "string"))];
}
+21 -11
View File
@@ -1,18 +1,28 @@
export enum Type {
INCLUDE = "include",
EXCLUDE = "exclude"
EXCLUDE = "exclude",
}
export class Entry {
export interface Entry {
server: string;
output_channel: string = "";
output_hook: string = "";
channel_type: Type = Type.EXCLUDE;
channels: string[] = [];
emoji_type: Type = Type.EXCLUDE;
emojis: string[] = [];
score: number = 5;
constructor(server: string) {
this.server = server;
output_channel: string;
channel_type: Type;
channels: string[];
emoji_type: Type;
emojis: string[];
score: number;
awards: Record<string, string>;
}
export function createEntry(server: string): Entry {
return {
server,
output_channel: "",
channel_type: Type.EXCLUDE,
channels: [],
emoji_type: Type.EXCLUDE,
emojis: [],
score: 5,
awards: {},
};
}
+222 -299
View File
@@ -1,34 +1,38 @@
import "dotenv/config";
const TOKEN = process.env.TOKEN;
const CLIENT_ID = process.env.CLIENT_ID;
if (!TOKEN || !CLIENT_ID) {
console.error(`No ${TOKEN ? "CLIENT_ID" : "TOKEN"} provided`);
process.exit();
}
import commands from "./commands.ts";
import {
BaseGuildTextChannel,
ChatInputCommandInteraction,
Client,
Events,
GatewayIntentBits,
Message,
MessageReaction,
PartialMessage,
PartialMessageReaction,
Partials,
PermissionFlagsBits,
REST,
Routes,
Client,
Partials,
GatewayIntentBits,
Events,
TextChannel,
Message,
PartialMessage,
} from "discord.js";
import awardEmbed from "./award_message.ts";
import commands from "./commands.ts";
import { readConfig, writeConfig } from "./config.ts";
import { createEntry, Entry, Type } from "./entry.ts";
import { totalScore } from "./scoring.ts";
const rest = new REST({ version: "10" }).setToken(TOKEN);
try {
console.log("refreshing slash commands...");
await rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands });
console.log("OK");
} catch (error) {
console.error(error);
const TOKEN = Deno.env.get("TOKEN");
const CLIENT_ID = Deno.env.get("CLIENT_ID");
if (!TOKEN || !CLIENT_ID) {
console.error(`Missing required environment variable: ${TOKEN ? "CLIENT_ID" : "TOKEN"}`);
Deno.exit(1);
}
import { writeConfig, readConfig } from "./config.ts";
const rest = new REST({ version: "10" }).setToken(TOKEN);
console.log("Refreshing slash commands...");
await rest.put(Routes.applicationCommands(CLIENT_ID), {
body: commands.map((command) => command.toJSON()),
});
console.log("Slash commands refreshed.");
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -39,310 +43,229 @@ const client = new Client({
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
});
client.on("ready", () => {
if (!client.user) {
console.error("Failed to login");
process.exit();
}
const entries = readConfig();
const updateQueues = new Map<string, Promise<void>>();
console.log(`Logged in as ${client.user.tag}!`);
client.user.setActivity("👑let's award funny messages!");
client.once(Events.ClientReady, (readyClient) => {
console.log(
`Logged in as ${readyClient.user.tag}; serving ${readyClient.guilds.cache.size} guild(s).`,
);
readyClient.user.setActivity("👑 awarding funny messages");
});
import { Entry, Type } from "./entry.ts";
const entries: Entry[] = readConfig();
writeConfig(entries);
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== "config") return;
client.on("interactionCreate", async (interaction) => {
if (interaction.isChatInputCommand()) {
if (interaction.commandName === "config") {
if (interaction.user.id !== interaction.guild?.ownerId) {
await interaction.reply({
content: "Only the server owner can configure the bot",
ephemeral: true,
});
return;
}
const subcommand = interaction.options.getSubcommand(true);
if (subcommand === "output") {
const channel = interaction.options.getChannel("channel", true);
try {
await interaction.deferReply({ ephemeral: true });
let entry = entries.find(
(entry) => entry.server === interaction.guildId
);
if (!entry) {
entry = new Entry(interaction.guildId!);
entries.push(entry);
}
entry.output_channel = channel.id;
writeConfig(entries);
await configure(interaction);
} catch (error) {
console.error(error);
} finally {
await interaction.followUp({
content: `Set output to <#${channel.id}>`,
});
}
} else if (subcommand === "channels") {
const channel = interaction.options.getChannel("channel", true);
const mode = interaction.options.getString("add_remove", true);
try {
await interaction.deferReply({ ephemeral: true });
let entry = entries.find(
(entry) => entry.server === interaction.guildId
);
if (!entry) {
entry = new Entry(interaction.guildId!);
entries.push(entry);
}
if (mode === "add") entry.channels.push(channel.id);
else entry.channels = entry.channels.filter((c) => c !== channel.id);
writeConfig(entries);
} catch (error) {
console.error(error);
} finally {
await interaction.followUp({
content: `Set to watch for reactions in <#${channel.id}>`,
});
}
} else if (subcommand === "channels_type") {
const channel_type = interaction.options.getString(
"channels_type",
true
);
try {
await interaction.deferReply({ ephemeral: true });
let entry = entries.find(
(entry) => entry.server === interaction.guildId
);
if (!entry) {
entry = new Entry(interaction.guildId!);
entries.push(entry);
}
entry.channel_type = channel_type as Type;
writeConfig(entries);
} catch (error) {
console.error(error);
} finally {
await interaction.followUp({
content: `channel list type set to ${channel_type}`,
ephemeral: true,
});
}
} else if (subcommand === "emojis") {
const emoji = interaction.options.getString("emoji", true);
const mode = interaction.options.getString("add_remove", true);
try {
await interaction.deferReply({ ephemeral: true });
let entry = entries.find(
(entry) => entry.server === interaction.guildId
);
if (!entry) {
entry = new Entry(interaction.guildId!);
entries.push(entry);
}
if (mode === "add") entry.emojis.push(emoji);
else entry.emojis = entry.emojis.filter((e) => e !== emoji);
await writeConfig(entries);
} catch (error) {
console.error(error);
} finally {
await interaction.followUp({
content: `Set to watch for ${emoji}`,
ephemeral: true,
});
}
} else if (subcommand === "emojis_type") {
const emoji_type = interaction.options.getString("emojis_type", true);
try {
await interaction.deferReply({ ephemeral: true });
let entry = entries.find(
(entry) => entry.server === interaction.guildId
);
if (!entry) {
entry = new Entry(interaction.guildId!);
entries.push(entry);
}
entry.emoji_type = emoji_type as Type;
writeConfig(entries);
} catch (error) {
console.error(error);
} finally {
await interaction.followUp({
content: `emoji list type set to ${emoji_type}`,
ephemeral: true,
});
}
} else if (subcommand === "score") {
const score = interaction.options.getInteger("score", true);
try {
await interaction.deferReply({ ephemeral: true });
let entry = entries.find(
(entry) => entry.server === interaction.guildId
);
if (!entry) {
entry = new Entry(interaction.guildId!);
entries.push(entry);
}
entry.score = score;
await writeConfig(entries);
} catch (error) {
console.error(error);
} finally {
await interaction.followUp({
content: `The score needed to trigger is now ${score}`,
ephemeral: true,
});
}
}
console.error("Configuration command failed:", error);
const content = "Configuration failed. Check the bot logs and try again.";
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content });
} else {
await interaction.reply({ content, ephemeral: true });
}
}
});
import awardEmbed from "./award_message.ts";
client.on(Events.MessageReactionAdd, async (reaction, user) => {
if (reaction.partial) {
try {
await reaction.fetch();
} catch (error) {
console.error("Something went wrong when fetching the message:", error);
return;
}
}
if (!reaction.message.guild) return;
const entry = entries.find(
(entry) => entry.server === reaction.message.guildId
);
if (entry === undefined) return;
if (
(entry.channel_type === Type.EXCLUDE) ===
entry.channels.includes(reaction.message.channelId)
)
return;
if (
(entry.emoji_type === Type.EXCLUDE) ===
entry.emojis.includes(`${reaction.emoji.id || reaction.emoji.name}`)
)
return;
console.log(`reaction added: ${reaction.emoji.id || reaction.emoji.name}`);
const totalScore = await getMessageScore(entry, reaction.message);
if (totalScore >= entry.score) {
const channel = reaction.message.guild.channels.cache.get(
entry.output_channel
) as TextChannel;
channel.messages.fetch({ limit: 10 }).then((messages) => {
messages.find((message) => {
if (message.embeds[0]?.fields[0].value.includes(reaction.message.id)) {
message.delete();
console.log("old message deleted.");
return true;
}
return false;
});
channel.send({
embeds: [awardEmbed(reaction, totalScore)],
});
console.log("message sent.");
});
}
if (!user.bot) await queueAwardUpdate(reaction);
});
client.on(Events.MessageReactionRemove, async (reaction, user) => {
if (reaction.partial) {
try {
await reaction.fetch();
} catch (error) {
console.error("Something went wrong when fetching the message:", error);
if (!user.bot) await queueAwardUpdate(reaction);
});
client.on(Events.Error, (error) => console.error("Discord client error:", error));
client.on(Events.Warn, (message) => console.warn("Discord client warning:", message));
await client.login(TOKEN);
async function configure(interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.inCachedGuild()) {
await interaction.reply({ content: "Run this command in a server.", ephemeral: true });
return;
}
}
if (!reaction.message.guild) return;
const entry = entries.find(
(entry) => entry.server === reaction.message.guildId
if (
interaction.user.id !== interaction.guild.ownerId &&
!interaction.memberPermissions.has(PermissionFlagsBits.Administrator)
) {
await interaction.reply({
content: "Only the server owner or an administrator can configure the bot.",
ephemeral: true,
});
return;
}
await interaction.deferReply({ ephemeral: true });
let entry = entries.find((item) => item.server === interaction.guildId);
if (!entry) {
entry = createEntry(interaction.guildId);
entries.push(entry);
}
const subcommand = interaction.options.getSubcommand(true);
let content: string;
switch (subcommand) {
case "output": {
const channel = interaction.options.getChannel("channel", true);
if (entry.output_channel !== channel.id) entry.awards = {};
entry.output_channel = channel.id;
content = `Award messages will be sent to <#${channel.id}>.`;
break;
}
case "channels": {
const channel = interaction.options.getChannel("channel", true);
const action = interaction.options.getString("action", true);
entry.channels = updateList(entry.channels, channel.id, action);
content = `${action === "add" ? "Added" : "Removed"} <#${channel.id}> ` +
`${action === "add" ? "to" : "from"} the channel list.`;
break;
}
case "channels_type": {
entry.channel_type = interaction.options.getString("type", true) as Type;
content = `Channel list mode is now ${entry.channel_type}.`;
break;
}
case "emojis": {
const emojiInput = interaction.options.getString("emoji", true);
const emoji = normalizeEmoji(emojiInput);
const action = interaction.options.getString("action", true);
entry.emojis = updateList(entry.emojis, emoji, action);
content = `${action === "add" ? "Added" : "Removed"} ${emojiInput} ` +
`${action === "add" ? "to" : "from"} the emoji list.`;
break;
}
case "emojis_type": {
entry.emoji_type = interaction.options.getString("type", true) as Type;
content = `Emoji list mode is now ${entry.emoji_type}.`;
break;
}
case "score": {
entry.score = interaction.options.getInteger("score", true);
content = `Messages are now awarded at a score of ${entry.score}.`;
break;
}
default:
throw new Error(`Unknown subcommand: ${subcommand}`);
}
await writeConfig(entries);
await interaction.editReply({ content });
}
function updateList(values: string[], value: string, action: string): string[] {
if (action === "add") return [...new Set([...values, value])];
return values.filter((item) => item !== value);
}
function normalizeEmoji(value: string): string {
return value.match(/^<a?:[^:>]+:(\d+)>$/)?.[1] ?? value;
}
function queueAwardUpdate(
reaction: MessageReaction | PartialMessageReaction,
): Promise<void> {
const messageId = reaction.message.id;
const previous = updateQueues.get(messageId) ?? Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => updateAward(reaction))
.catch((error) => console.error(`Failed to update award for message ${messageId}:`, error))
.finally(() => {
if (updateQueues.get(messageId) === current) updateQueues.delete(messageId);
});
updateQueues.set(messageId, current);
return current;
}
async function updateAward(
reaction: MessageReaction | PartialMessageReaction,
): Promise<void> {
if (reaction.partial) await reaction.fetch();
if (reaction.message.partial) await reaction.message.fetch();
const message = reaction.message;
const entry = watchedEntry(message);
if (!message.guild || !entry) return;
const outputChannel = await message.guild.channels.fetch(entry.output_channel);
if (!(outputChannel instanceof BaseGuildTextChannel)) {
console.warn(
`Configured output channel ${entry.output_channel} is missing or cannot contain messages.`,
);
if (entry === undefined) return;
if (
(entry.channel_type === Type.EXCLUDE) ===
entry.channels.includes(reaction.message.channelId)
)
return;
if (
(entry.emoji_type === Type.EXCLUDE) ===
entry.emojis.includes(`${reaction.emoji.id || reaction.emoji.name}`)
}
const score = await getMessageScore(entry, message);
const mappedAwardId = entry.awards[message.id];
let oldAward = mappedAwardId
? await outputChannel.messages.fetch(mappedAwardId).catch(() => undefined)
: undefined;
if (!oldAward) {
const recent = await outputChannel.messages.fetch({ limit: 100 });
oldAward = recent.find((item) =>
item.author.id === client.user?.id &&
item.embeds.some((embed) =>
embed.footer?.text === `Source message: ${message.id}` ||
embed.fields.some((field) => field.value.includes(message.id))
)
);
}
if (score < entry.score) {
if (oldAward) await oldAward.delete();
if (mappedAwardId) {
delete entry.awards[message.id];
await writeConfig(entries);
}
return;
console.log(`reaction removed: ${reaction.emoji.id || reaction.emoji.name}`);
const totalScore = await getMessageScore(entry, reaction.message);
const channel = reaction.message.guild.channels.cache.get(
entry.output_channel
) as TextChannel;
channel.messages.fetch({ limit: 10 }).then((messages) => {
messages.find((message) => {
if (message.embeds[0]?.fields[0].value.includes(reaction.message.id)) {
message.delete();
console.log("old message deleted.");
if (totalScore >= entry.score) {
channel.send({
embeds: [awardEmbed(reaction, totalScore)],
});
console.log("message sent.");
}
return true;
}
return false;
});
});
});
client.login(TOKEN);
const payload = { embeds: [awardEmbed(reaction, score)] };
if (oldAward) {
await oldAward.edit(payload);
if (entry.awards[message.id] !== oldAward.id) {
entry.awards[message.id] = oldAward.id;
await writeConfig(entries);
}
} else {
const award = await outputChannel.send(payload);
entry.awards[message.id] = award.id;
await writeConfig(entries);
}
}
function watchedEntry(message: Message | PartialMessage): Entry | undefined {
const entry = entries.find((item) => item.server === message.guildId);
if (!entry?.output_channel) return undefined;
if (message.channelId === entry.output_channel) return undefined;
if (!listAllows(entry.channel_type, entry.channels, message.channelId)) return undefined;
return entry;
}
function listAllows(type: Type, values: string[], value: string): boolean {
return type === Type.INCLUDE ? values.includes(value) : !values.includes(value);
}
async function getMessageScore(
entry: Entry,
message: Message | PartialMessage
message: Message | PartialMessage,
): Promise<number> {
/* Message Score Calculation *
* Per user: 1 / (-1 + 2x) */
const reactionCounts = new Map<string, number>();
for (const item of message.reactions.cache.values()) {
const emoji = item.emoji.id ?? item.emoji.name ?? "";
if (!listAllows(entry.emoji_type, entry.emojis, emoji)) continue;
const allReactions: Map<string, number> = new Map();
const reactions = message.reactions.cache;
for (const r of reactions.values()) {
if (
(entry.emoji_type === Type.EXCLUDE) ===
entry.emojis.includes(`${r.emoji.id || r.emoji.name}`)
)
continue;
const users = await r.users.fetch();
const users = await item.users.fetch();
for (const user of users.values()) {
if (user.bot) continue;
const score = allReactions.get(user.id) || 0;
allReactions.set(user.id, score + 1 / (-1 + 2 * (score + 1)));
if (!user.bot) {
reactionCounts.set(user.id, (reactionCounts.get(user.id) ?? 0) + 1);
}
}
let totalScore = 0;
allReactions.forEach((value) => {
totalScore += value;
});
// "message" by "user"
// id : score
// ----------------
// Total: 1.5
console.log(`"${message.id}" by "${message.author?.username}"`);
allReactions.forEach((value, key) => {
console.log(`${key} : ${value}`);
});
console.log(`----------------`);
console.log(`Total: ${totalScore}`);
return totalScore;
}
return totalScore(reactionCounts.values());
}
+24
View File
@@ -0,0 +1,24 @@
export function reactionWeight(reactionIndex: number): number {
if (!Number.isInteger(reactionIndex) || reactionIndex < 1) {
throw new RangeError("reaction index must be a positive integer");
}
return 1 / (2 * reactionIndex - 1);
}
export function scoreForUser(reactionCount: number): number {
if (!Number.isInteger(reactionCount) || reactionCount < 1) {
throw new RangeError("reaction count must be a positive integer");
}
let score = 0;
for (let index = 1; index <= reactionCount; index++) {
score += reactionWeight(index);
}
return score;
}
export function totalScore(reactionCountsByUser: Iterable<number>): number {
let total = 0;
for (const count of reactionCountsByUser) total += scoreForUser(count);
return total;
}
+19
View File
@@ -0,0 +1,19 @@
import { assertAlmostEquals, assertThrows } from "@std/assert";
import { reactionWeight, scoreForUser, totalScore } from "./scoring.ts";
Deno.test("one reaction by one user scores one point", () => {
assertAlmostEquals(scoreForUser(1), 1);
});
Deno.test("repeat reactions by one user have diminishing weight", () => {
assertAlmostEquals(reactionWeight(2), 1 / 3);
assertAlmostEquals(scoreForUser(3), 1 + 1 / 3 + 1 / 5);
});
Deno.test("total score sums each user's score", () => {
assertAlmostEquals(totalScore([1, 1, 2]), 3 + 1 / 3);
});
Deno.test("invalid reaction counts are rejected", () => {
assertThrows(() => scoreForUser(0), RangeError);
});
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2023"
],
"module": "node16",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"outDir": "./dist",
},
"include": [
"src/**/*.ts"
]
}