feat: modernize and harden bot deployment
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
.env
|
||||
config.json
|
||||
README.md
|
||||
@@ -130,3 +130,6 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Runtime configuration (mounted as persistent data in production)
|
||||
config.json
|
||||
|
||||
|
||||
+7
-8
@@ -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"]
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
[]
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
-1227
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user