Skip to content

Commit ae99527

Browse files
authored
Deploy Discord commands during bot build (#198)
* Proof of concept command deployment * Make sure scripts build * Proof of concept github action * Build complete set of command types * Get proper dev mode working for command deployment * Only restart deploy command if scripts/ changes * Working command deploy script
1 parent d09f2a8 commit ae99527

File tree

6 files changed

+1139
-16
lines changed

6 files changed

+1139
-16
lines changed

.github/workflows/node.js.yml

+12
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ jobs:
4848
script: |
4949
cd reactibot
5050
sudo docker build -t reactiflux/reactibot:latest .
51+
- name: Deploy Discord commands
52+
uses: applyboy/ssh-action@master
53+
env:
54+
DISCORD_HASH: ${{ secrets.DISCORD_HASH }}
55+
with:
56+
host: ${{ secrets.SSH_HOST }}
57+
username: root
58+
key: ${{ secrets. SSH_KEY }}
59+
envs: DISCORD_HASH
60+
script: |
61+
yarn prod:commands
62+
5163
- name: Start server
5264
uses: appleboy/ssh-action@master
5365
env:

package.json

+9-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
"private": true,
66
"scripts": {
77
"start": "node -r dotenv/config dist/index.js",
8-
"dev": "ts-node-dev -r dotenv/config src/index.ts",
8+
"dev": "npm-run-all --parallel 'dev:bot' 'dev:commands'",
9+
"dev:bot": "ts-node-dev -r dotenv/config src/index.ts",
10+
"dev:commands": "nodemon --watch scripts --exec 'ts-node -r dotenv/config' ./scripts/deploy-commands.ts",
11+
"start:commands": "node -r dotenv/config dist/scripts/deploy-commands.js",
912
"build": "tsc",
1013
"test": "eslint --ext js,ts . && prettier --check .",
1114
"prettier": "prettier --write ."
@@ -16,12 +19,16 @@
1619
},
1720
"license": "MIT",
1821
"dependencies": {
22+
"@discordjs/builders": "^0.12.0",
23+
"@discordjs/rest": "^0.3.0",
1924
"@types/open-graph-scraper": "^4.8.1",
2025
"date-fns": "^2.27.0",
26+
"discord-api-types": "^0.29.0",
2127
"discord.js": "^13.5.0",
2228
"dotenv": "^10.0.0",
2329
"gists": "^2.0.0",
2430
"node-fetch": "^2.6.7",
31+
"npm-run-all": "^4.1.5",
2532
"open-graph-scraper": "^4.11.0",
2633
"query-string": "^6.2.0"
2734
},
@@ -32,6 +39,7 @@
3239
"@typescript-eslint/parser": "^5.9.0",
3340
"eslint": "^8.6.0",
3441
"eslint-config-prettier": "^8.3.0",
42+
"nodemon": "^2.0.15",
3543
"prettier": "^2.3.2",
3644
"ts-node-dev": "^1.0.0-pre.44",
3745
"typescript": "^4.5.4"

scripts/deploy-commands.ts

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
ContextMenuCommandBuilder,
3+
SlashCommandBuilder,
4+
} from "@discordjs/builders";
5+
import { REST } from "@discordjs/rest";
6+
import {
7+
APIApplicationCommand,
8+
ApplicationCommandType,
9+
Routes,
10+
} from "discord-api-types/v9";
11+
import { applicationId, discordToken, guildId } from "../src/constants";
12+
import { logger } from "../src/features/log";
13+
import { difference } from "../src/helpers/sets";
14+
15+
// TODO: make this a global command in production
16+
const upsertUrl = () => Routes.applicationGuildCommands(applicationId, guildId);
17+
const deleteUrl = (commandId: string) =>
18+
Routes.applicationGuildCommand(applicationId, guildId, commandId);
19+
20+
interface CommandConfig {
21+
name: string;
22+
description: string;
23+
type: ApplicationCommandType;
24+
}
25+
const cmds: CommandConfig[] = [];
26+
27+
const commands = [
28+
...cmds
29+
.filter((x) => x.type === ApplicationCommandType.ChatInput)
30+
.map((c) =>
31+
new SlashCommandBuilder()
32+
.setName(c.name)
33+
.setDescription(c.description)
34+
.toJSON(),
35+
),
36+
...cmds
37+
.filter((x) => x.type === ApplicationCommandType.Message)
38+
.map((c) =>
39+
new ContextMenuCommandBuilder()
40+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
41+
// @ts-expect-error Discord.js doesn't export the union we need
42+
.setType(ApplicationCommandType.Message)
43+
.setName(c.name)
44+
.toJSON(),
45+
),
46+
...cmds
47+
.filter((x) => x.type === ApplicationCommandType.User)
48+
.map((c) =>
49+
new ContextMenuCommandBuilder()
50+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
51+
// @ts-expect-error Discord.js doesn't export the union we need
52+
.setType(ApplicationCommandType.User)
53+
.setName(c.name)
54+
.toJSON(),
55+
),
56+
];
57+
const names = new Set(commands.map((c) => c.name));
58+
59+
const rest = new REST({ version: "9" }).setToken(discordToken);
60+
const deploy = async () => {
61+
const remoteCommands = (await rest.get(
62+
upsertUrl(),
63+
)) as APIApplicationCommand[];
64+
65+
// Take the list of names to delete and swap it out for IDs to delete
66+
const remoteNames = new Set(remoteCommands.map((c) => c.name));
67+
const deleteNames = [...difference(remoteNames, names)];
68+
const toDelete = deleteNames
69+
.map((x) => remoteCommands.find((y) => y.name === x)?.id)
70+
.filter((x): x is string => Boolean(x));
71+
72+
logger.log(
73+
"DEPLOY",
74+
`Removing ${toDelete.length} commands: [${deleteNames.join(",")}]`,
75+
);
76+
await Promise.allSettled(
77+
toDelete.map((commandId) => rest.delete(deleteUrl(commandId))),
78+
);
79+
80+
// Grab a list of commands that need to be updated
81+
const toUpdate = remoteCommands.filter(
82+
(c) =>
83+
// Check all necessary fields to see if any changed. User and Message
84+
// commands don't have a description.
85+
!commands.find((x) => {
86+
const {
87+
type = ApplicationCommandType.ChatInput,
88+
name,
89+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
90+
// @ts-expect-error Unions are weird
91+
description = "",
92+
} = x;
93+
switch (type as ApplicationCommandType) {
94+
case ApplicationCommandType.User:
95+
case ApplicationCommandType.Message:
96+
return name === c.name && type === c.type;
97+
case ApplicationCommandType.ChatInput:
98+
default:
99+
return (
100+
name === c.name &&
101+
type === c.type &&
102+
description === c.description
103+
);
104+
}
105+
}),
106+
);
107+
108+
logger.log(
109+
"DEPLOY",
110+
`Updating ${toUpdate.length} commands: [${toUpdate
111+
.map((x) => x.name)
112+
.join(",")}]`,
113+
);
114+
115+
await rest.put(upsertUrl(), { body: commands });
116+
};
117+
try {
118+
deploy();
119+
} catch (e) {
120+
logger.log("DEPLOY EXCEPTION", e as string);
121+
}

src/helpers/sets.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const difference = <T>(a: Set<T>, b: Set<T>) =>
2+
new Set(Array.from(a).filter((x) => !b.has(x)));

tsconfig.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"compilerOptions": {
3-
"target": "es6",
3+
"target": "ES2020",
44
"module": "commonjs",
55
"sourceMap": true,
66
"outDir": "dist",
@@ -10,6 +10,6 @@
1010
"esModuleInterop": true,
1111
"forceConsistentCasingInFileNames": true
1212
},
13-
"include": ["src/**/*.ts"],
13+
"include": ["src/**/*.ts", "scripts"],
1414
"exclude": ["node_modules"]
1515
}

0 commit comments

Comments
 (0)