├── src ├── guildDefinitions.ts ├── constants.ts ├── global.d.ts ├── roles.ts ├── response.ts ├── commandRouter.ts ├── verify.ts ├── command │ ├── register.ts │ ├── listRoles.ts │ ├── setPronounRoles.ts │ ├── unassignRole.ts │ ├── version.ts │ ├── createCustomRole.ts │ ├── deleteRole.ts │ ├── createRoles.ts │ └── sendPronounPicker.ts ├── commandMap.ts ├── sanitization.ts ├── register.ts ├── errors.ts ├── types.ts ├── discordAPI.ts ├── messageComponentHandler.ts ├── storage.ts ├── server.ts ├── commandDefinitions.ts ├── registerGuild.ts └── strings.ts ├── .prettierignore ├── .gitignore ├── .gitattributes ├── renovate.json ├── .prettierrc ├── wrangler.example.toml ├── .github └── workflows │ └── webpack.yml ├── tsconfig.json ├── package.json ├── webpack.config.js ├── LICENSE.md └── README.md /src/guildDefinitions.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | wrangler.toml -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=LF 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "rangeStrategy": "bump" 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "trailingComma": "es5", 5 | "tabWidth": 2, 6 | "printWidth": 90, 7 | "arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const Constants = { 2 | REDIRECT_URL: 'https://wlf.is/pronouns', 3 | DOCS_URL: 'https://wlf.is/pronouns', 4 | BUILD_DATE: _BUILD_DATE, 5 | API_URL: `https://discord.com/api/v10`, 6 | }; 7 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const DISCORD_PUBLIC_KEY: string; 2 | declare const DISCORD_APPLICATION_ID: string; 3 | declare const PRONOUNS_BOT_TOKEN: string; 4 | declare const PRONOUNS_BOT_TEST_GUILD_ID: string; 5 | declare const PRONOUNS_BOT_GUILD_SETTINGS: KVNamespace; 6 | declare const _BUILD_DATE: string; 7 | declare const _COMMIT_HASH: string; 8 | -------------------------------------------------------------------------------- /wrangler.example.toml: -------------------------------------------------------------------------------- 1 | name = "discord-pronouns-bot" 2 | account_id = "[CLOUDFLARE_ACCOUNT_ID]" 3 | workers_dev = true 4 | main = "./dist/worker.js" 5 | compatibility_date = "2022-06-19" 6 | kv_namespaces = [ 7 | { binding = "PRONOUNS_BOT_GUILD_SETTINGS", id = "[CLOUDFLARE_WORKERS_KV_ID]" } 8 | ] 9 | 10 | [build] 11 | command = "npm run build" -------------------------------------------------------------------------------- /.github/workflows/webpack.yml: -------------------------------------------------------------------------------- 1 | name: Webpack 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: '16' 17 | cache: 'npm' 18 | cache-dependency-path: package-lock.json 19 | - run: npm install 20 | - run: npx webpack 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "module": "commonjs", 5 | "target": "es2022", 6 | "lib": ["es2022"], 7 | "alwaysStrict": true, 8 | "strict": true, 9 | "preserveConstEnums": true, 10 | "moduleResolution": "node", 11 | "allowJs": true, 12 | "sourceMap": true, 13 | "esModuleInterop": true, 14 | "types": ["@cloudflare/workers-types"] 15 | }, 16 | "include": ["src"], 17 | "exclude": ["node_modules", "dist", "test"] 18 | } 19 | -------------------------------------------------------------------------------- /src/roles.ts: -------------------------------------------------------------------------------- 1 | import { getGuildSettings } from './storage'; 2 | 3 | export const getGuildPronouns = async (guild_id: string) => { 4 | let roles = []; 5 | const guildSettings = await getGuildSettings(guild_id); 6 | let keys = Object.keys(guildSettings.roles); 7 | 8 | for (const pronoun_str in keys) { 9 | const pronoun: string = keys[pronoun_str]; 10 | console.log('pronoun', pronoun); 11 | if (guildSettings.roles[pronoun]?.id) { 12 | roles.push({ 13 | keyName: pronoun, 14 | name: pronoun, 15 | roleId: guildSettings.roles[pronoun].id, 16 | special: guildSettings.roles[pronoun].special, 17 | }); 18 | } 19 | } 20 | 21 | return roles; 22 | }; 23 | -------------------------------------------------------------------------------- /src/response.ts: -------------------------------------------------------------------------------- 1 | import { InteractionResponseType } from '../node_modules/discord-api-types/payloads/v10/_interactions/responses'; 2 | import { MessageFlags } from '../node_modules/discord-api-types/payloads/v10/channel'; 3 | 4 | export class JsonResponse extends Response { 5 | constructor(body: unknown, headers: { [header: string]: string } = {}) { 6 | super(JSON.stringify(body), { 7 | headers: { 8 | 'content-type': 'application/json;charset=UTF-8', 9 | ...headers, 10 | }, 11 | }); 12 | } 13 | } 14 | 15 | export class CommandResponse extends JsonResponse { 16 | constructor(content: string) { 17 | super({ 18 | type: InteractionResponseType.ChannelMessageWithSource, 19 | data: { 20 | content, 21 | flags: MessageFlags.Ephemeral, 22 | }, 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/commandRouter.ts: -------------------------------------------------------------------------------- 1 | import { OptionedCommandInteraction, OptionsList } from './types'; 2 | import { Strings } from './strings'; 3 | import { CommandFailed } from './errors'; 4 | import { CommandMap } from './commandMap'; 5 | 6 | export const routeCommand = async ( 7 | interaction: OptionedCommandInteraction, 8 | request: Request 9 | ) => { 10 | const commandName = interaction.data.name.toLowerCase(); 11 | const command = CommandMap[commandName]; 12 | 13 | let rawOptions = interaction.data.options || []; 14 | let options: OptionsList = {}; 15 | 16 | for (let i = 0; i < rawOptions.length; i++) { 17 | options[rawOptions[i].name] = rawOptions[i]; 18 | } 19 | 20 | console.log('Options: ', options); 21 | 22 | if (command) { 23 | return await command(interaction, options, request); 24 | } else { 25 | throw new CommandFailed( 26 | Strings.COMMAND_NOT_FOUND.format({ command: interaction.data.name }) 27 | ); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pronouns-bot", 3 | "version": "1.0.0", 4 | "description": "Cloudflare worker TypeScript template", 5 | "main": "dist/worker.js", 6 | "scripts": { 7 | "build": "webpack", 8 | "publish": "wrangler publish", 9 | "log": "wrangler tail", 10 | "reload": "wrangler publish && wrangler tail", 11 | "register": "node src/register.js", 12 | "prettier": "prettier --write ." 13 | }, 14 | "author": "dangered wolf", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@cloudflare/workers-types": "^3.15.0", 18 | "@types/service-worker-mock": "^2.0.1", 19 | "prettier": "^2.7.1", 20 | "service-worker-mock": "^2.0.5", 21 | "ts-loader": "^9.3.1", 22 | "typescript": "^4.8.2", 23 | "webpack": "^5.74.0", 24 | "webpack-cli": "^4.10.0", 25 | "wrangler": "^2.0.28" 26 | }, 27 | "dependencies": { 28 | "discord-api-types": "^0.37.5", 29 | "itty-router": "^2.6.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/verify.ts: -------------------------------------------------------------------------------- 1 | // from https://gist.github.com/devsnek/77275f6e3f810a9545440931ed314dc1 2 | 3 | const hex2bin = (hex: string) => { 4 | const buf = new Uint8Array(Math.ceil(hex.length / 2)); 5 | for (var i = 0; i < buf.length; i++) { 6 | buf[i] = parseInt(hex.substr(i * 2, 2), 16); 7 | } 8 | return buf; 9 | }; 10 | 11 | const PUBLIC_KEY = crypto.subtle.importKey( 12 | 'raw', 13 | hex2bin(DISCORD_PUBLIC_KEY), 14 | { 15 | name: 'NODE-ED25519', 16 | namedCurve: 'NODE-ED25519', 17 | }, 18 | true, 19 | ['verify'] 20 | ); 21 | 22 | const encoder = new TextEncoder(); 23 | 24 | export const verify = async (request: Request) => { 25 | const signature = hex2bin(request.headers.get('X-Signature-Ed25519')!); 26 | const timestamp = request.headers.get('X-Signature-Timestamp'); 27 | const body = await request.clone().text(); 28 | 29 | return await crypto.subtle.verify( 30 | 'NODE-ED25519', 31 | await PUBLIC_KEY, 32 | signature, 33 | encoder.encode(timestamp + body) 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | const date = String(new Date().toISOString()); 5 | 6 | let commitHash = require('child_process') 7 | .execSync('git rev-parse --short HEAD') 8 | .toString() 9 | .trim(); 10 | 11 | module.exports = { 12 | entry: { 13 | worker: './src/server.ts', 14 | }, 15 | output: { 16 | filename: '[name].js', 17 | path: path.join(__dirname, 'dist'), 18 | }, 19 | mode: 'production', 20 | resolve: { 21 | extensions: ['.ts', '.tsx', '.js'], 22 | fallback: { util: false }, 23 | }, 24 | plugins: [ 25 | new webpack.DefinePlugin({ 26 | _BUILD_DATE: `'${date}'`, 27 | }), 28 | new webpack.DefinePlugin({ 29 | _COMMIT_HASH: `'${commitHash}'`, 30 | }), 31 | ], 32 | optimization: { 33 | mangleExports: 'size', 34 | }, 35 | module: { 36 | rules: [ 37 | { 38 | test: /\.tsx?$/, 39 | loader: 'ts-loader', 40 | options: { 41 | transpileOnly: true, 42 | }, 43 | }, 44 | ], 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /src/command/register.ts: -------------------------------------------------------------------------------- 1 | import { CommandResponse } from '../response'; 2 | import { registerGlobalCommands } from '../register'; 3 | import { Strings } from '../strings'; 4 | import { CommandFailed, getErrorString } from '../errors'; 5 | import { registerGuildCommands } from '../registerGuild'; 6 | 7 | export const RegisterGuildCommand = async () => { 8 | const response = await registerGuildCommands(PRONOUNS_BOT_TEST_GUILD_ID); 9 | 10 | if (response.ok) { 11 | return new CommandResponse(Strings.GUILD_COMMAND_REGISTER_SUCCESS); 12 | } else { 13 | throw new CommandFailed( 14 | Strings.GUILD_COMMAND_REGISTER_FAIL.format({ 15 | error: getErrorString(response), 16 | }) 17 | ); 18 | } 19 | }; 20 | 21 | export const RegisterGlobalCommand = async () => { 22 | const response = await registerGlobalCommands(); 23 | 24 | if (response.ok) { 25 | return new CommandResponse(Strings.GLOBAL_COMMAND_REGISTER_SUCCESS); 26 | } else { 27 | throw new CommandFailed( 28 | Strings.GLOBAL_COMMAND_REGISTER_FAIL.format({ 29 | error: getErrorString(response), 30 | }) 31 | ); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 dangered wolf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/command/listRoles.ts: -------------------------------------------------------------------------------- 1 | import { registerGuildCommands } from '../registerGuild'; 2 | import { CommandResponse } from '../response'; 3 | import { getGuildPronouns } from '../roles'; 4 | import { assertGuild } from '../sanitization'; 5 | import { Strings } from '../strings'; 6 | import { OptionedCommandInteraction } from '../types'; 7 | 8 | export const ListRolesCommand = async (interaction: OptionedCommandInteraction) => { 9 | assertGuild(interaction); 10 | 11 | const guild_id: string = interaction.guild_id as string; 12 | 13 | const roleStrings: string[] = []; 14 | const roles = await getGuildPronouns(guild_id); 15 | 16 | setTimeout(async () => { 17 | try { 18 | await registerGuildCommands(interaction.guild_id as string); 19 | } catch (e) { 20 | console.log(e); 21 | } 22 | }); 23 | 24 | for (let i in roles) { 25 | roleStrings.push( 26 | Strings.LIST_ROLE_ENTRY.format({ 27 | pronoun: roles[i].name, 28 | role_id: roles[i].roleId, 29 | }) 30 | ); 31 | } 32 | 33 | return new CommandResponse( 34 | Strings.LIST_ROLES_RESULT.format({ roles: roleStrings.join('\n') }) 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/command/setPronounRoles.ts: -------------------------------------------------------------------------------- 1 | import { CommandResponse } from '../response'; 2 | import { assertGuild, assertOption } from '../sanitization'; 3 | import { getGuildSettings, setGuildSettings } from '../storage'; 4 | import { Strings } from '../strings'; 5 | import { OptionedCommandInteraction, OptionsList } from '../types'; 6 | import { registerGuildCommands } from '../registerGuild'; 7 | 8 | export const SetPronounsRoleCommand = async ( 9 | interaction: OptionedCommandInteraction, 10 | options: OptionsList 11 | ) => { 12 | assertGuild(interaction); 13 | 14 | const pronounOption = assertOption(options.pronoun).value; 15 | const roleOption = assertOption(options.role).value; 16 | const specialOption = options?.special?.value; 17 | 18 | let settings = await getGuildSettings(interaction.guild_id as string); 19 | settings.roles[pronounOption] = { id: roleOption as string }; 20 | if (specialOption) { 21 | settings.roles[pronounOption].special = true; 22 | } 23 | setTimeout(async () => { 24 | try { 25 | await registerGuildCommands(interaction.guild_id as string); 26 | } catch (e) { 27 | console.log(e); 28 | } 29 | }); 30 | await setGuildSettings(interaction.guild_id as string, settings); 31 | 32 | return new CommandResponse( 33 | Strings.SET_ROLE_SUCCESS.format({ 34 | pronoun: options.pronoun.value, 35 | role_id: options.role.value, 36 | }) 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/commandMap.ts: -------------------------------------------------------------------------------- 1 | import { CreateCustomPronounCommand } from './command/createCustomRole'; 2 | import { CreateRolesCommand } from './command/createRoles'; 3 | import { DeleteRoleCommand } from './command/deleteRole'; 4 | import { UnassignRoleCommand } from './command/unassignRole'; 5 | import { ListRolesCommand } from './command/listRoles'; 6 | import { RegisterGlobalCommand, RegisterGuildCommand } from './command/register'; 7 | import { SendPronounPickerCommand } from './command/sendPronounPicker'; 8 | import { SetPronounsRoleCommand } from './command/setPronounRoles'; 9 | import { VersionCommand } from './command/version'; 10 | import { DELETE_ROLE, UNASSIGN_ROLE } from './registerGuild'; 11 | import { 12 | REGISTER_GLOBAL, 13 | REGISTER_GUILD, 14 | SET_ROLE, 15 | CREATE_ROLES, 16 | VERSION, 17 | LIST_ROLES, 18 | SEND_PRONOUN_PICKER, 19 | CREATE_CUSTOM_ROLE, 20 | } from './commandDefinitions'; 21 | 22 | export const CommandMap = { 23 | [REGISTER_GLOBAL.name]: RegisterGlobalCommand, 24 | [REGISTER_GUILD.name]: RegisterGuildCommand, 25 | [SET_ROLE.name]: SetPronounsRoleCommand, 26 | [CREATE_ROLES.name]: CreateRolesCommand, 27 | [VERSION.name]: VersionCommand, 28 | [LIST_ROLES.name]: ListRolesCommand, 29 | [SEND_PRONOUN_PICKER.name]: SendPronounPickerCommand, 30 | [CREATE_CUSTOM_ROLE.name]: CreateCustomPronounCommand, 31 | [DELETE_ROLE.name]: DeleteRoleCommand, 32 | [UNASSIGN_ROLE.name]: UnassignRoleCommand, 33 | }; 34 | -------------------------------------------------------------------------------- /src/sanitization.ts: -------------------------------------------------------------------------------- 1 | import { OptionedCommandInteraction } from './types'; 2 | import { APIPingInteraction } from '../node_modules/discord-api-types/payloads/v10/_interactions/ping'; 3 | import { APIApplicationCommandInteraction } from '../node_modules/discord-api-types/payloads/v10/_interactions/applicationCommands'; 4 | import { APIMessageComponentInteraction } from '../node_modules/discord-api-types/payloads/v10/_interactions/messageComponents'; 5 | import { GuildOnlyCommandError, MissingOptionError } from './errors'; 6 | 7 | export const assertGuild = ( 8 | interaction: 9 | | APIPingInteraction 10 | | APIApplicationCommandInteraction 11 | | APIMessageComponentInteraction 12 | | OptionedCommandInteraction 13 | ): void => { 14 | if (typeof interaction.guild_id !== 'string') { 15 | throw new GuildOnlyCommandError(); 16 | } 17 | }; 18 | 19 | export const assertOption = (option: any) => { 20 | if (typeof option === 'undefined' || option === null) { 21 | throw new MissingOptionError(); 22 | } 23 | return option; 24 | }; 25 | 26 | export const sanitizePronoun = (pronoun: string): string => { 27 | switch (pronoun) { 28 | case 'he': 29 | return 'He/Him'; 30 | case 'she': 31 | return 'She/Her'; 32 | case 'they': 33 | return 'They/Them'; 34 | case 'it': 35 | return 'It/Its'; 36 | case 'any': 37 | return 'Any Pronouns'; 38 | case 'ask': 39 | return 'Pronouns: Ask'; 40 | default: 41 | return pronoun; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/register.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CREATE_ROLES, 3 | LIST_ROLES, 4 | SEND_PRONOUN_PICKER, 5 | SET_ROLE, 6 | CREATE_CUSTOM_ROLE, 7 | VERSION, 8 | } from './commandDefinitions'; 9 | import { discordApiCall } from './discordAPI'; 10 | import { getErrorString } from './errors'; 11 | import { registerGuildCommands } from './registerGuild'; 12 | 13 | const publicCommands = [ 14 | SET_ROLE, 15 | CREATE_ROLES, 16 | VERSION, 17 | LIST_ROLES, 18 | CREATE_CUSTOM_ROLE, 19 | SEND_PRONOUN_PICKER, 20 | ]; 21 | 22 | if (!DISCORD_APPLICATION_ID) { 23 | throw new Error('The PRONOUNS_BOT_APPLICATION_ID environment variable is required.'); 24 | } 25 | 26 | /** 27 | * Register all commands globally. This can take o(minutes), so wait until 28 | * you're sure these are the commands you want. 29 | */ 30 | 31 | export const registerGlobalCommands = async (): Promise => { 32 | const url = `/applications/${DISCORD_APPLICATION_ID}/commands`; 33 | return await registerCommands(url, publicCommands); 34 | }; 35 | 36 | export const registerCommands = async ( 37 | url: string, 38 | commands: any[] 39 | ): Promise => { 40 | const response = await discordApiCall(url, 'PUT', commands); 41 | 42 | if (response.ok) { 43 | console.log(`Registered commands on ${url}`); 44 | } else { 45 | console.error(`Error registering commands: ${getErrorString(response)}`); 46 | const text = await response.text(); 47 | console.error(text); 48 | } 49 | return response; 50 | }; 51 | 52 | export const doRegisterCommands = async () => { 53 | await registerGlobalCommands(); 54 | await registerGuildCommands(PRONOUNS_BOT_TEST_GUILD_ID); 55 | }; 56 | -------------------------------------------------------------------------------- /src/command/unassignRole.ts: -------------------------------------------------------------------------------- 1 | import { DiscordAPI } from '../discordAPI'; 2 | import { registerGuildCommands } from '../registerGuild'; 3 | import { CommandResponse } from '../response'; 4 | import { assertGuild, assertOption } from '../sanitization'; 5 | import { getGuildSettings, setGuildSettings } from '../storage'; 6 | import { APIRole } from 'discord-api-types/payloads/v10/permissions'; 7 | 8 | import { GuildSettings, OptionedCommandInteraction, OptionsList } from '../types'; 9 | import { Strings } from '../strings'; 10 | 11 | const deleteExtraneousRole = async ( 12 | guildId: string, 13 | roleOption: string, 14 | guildSettings: GuildSettings 15 | ) => { 16 | delete guildSettings.roles?.[roleOption]; 17 | await setGuildSettings(guildId, guildSettings); 18 | return guildSettings; 19 | }; 20 | 21 | export const UnassignRoleCommand = async ( 22 | interaction: OptionedCommandInteraction, 23 | options: OptionsList 24 | ) => { 25 | assertGuild(interaction); 26 | const roleOption: string = assertOption(options.pronoun).value; 27 | const guildId = interaction.guild_id as string; 28 | let guildSettings = await getGuildSettings(guildId); 29 | const roleId = guildSettings.roles[roleOption]?.id || ''; 30 | const roles: APIRole[] = await DiscordAPI.getRoles(guildId); 31 | 32 | delete guildSettings.roles[roleOption]; 33 | 34 | await deleteExtraneousRole(guildId, roleOption, guildSettings); 35 | setTimeout(async () => { 36 | try { 37 | await registerGuildCommands(interaction.guild_id as string); 38 | } catch (e) { 39 | console.log(e); 40 | } 41 | }); 42 | 43 | return new CommandResponse(Strings.UNASSIGN_ROLE_SUCCESS.format({ name: roleOption })); 44 | }; 45 | -------------------------------------------------------------------------------- /src/command/version.ts: -------------------------------------------------------------------------------- 1 | import { CommandResponse, JsonResponse } from '../response'; 2 | import { InteractionResponseType } from '../../node_modules/discord-api-types/payloads/v10/_interactions/responses'; 3 | import { MessageFlags } from '../../node_modules/discord-api-types/payloads/v10/channel'; 4 | import { Strings } from '../strings'; 5 | 6 | export const VersionCommand = async ( 7 | _interaction: any, 8 | _options: any, 9 | request: Request 10 | ) => { 11 | console.log(request); 12 | console.log('sdfsdfsdfsdfds'); 13 | 14 | const discordLocation = `${request?.cf?.city}, ${request?.cf?.regionCode}, ${request?.cf?.country}`; 15 | 16 | const response = { 17 | type: InteractionResponseType.ChannelMessageWithSource, 18 | data: { 19 | embeds: [ 20 | { 21 | type: 'rich', 22 | title: Strings.VERSION_INFO, 23 | description: Strings.VERSION_DESCRIPTION.format({ 24 | discordLocation: discordLocation, 25 | cfLocation: "", 26 | }), 27 | image: { 28 | url: Strings.VERSION_LOGO_URL, 29 | }, 30 | }, 31 | ], 32 | flags: MessageFlags.Ephemeral, 33 | components: [ 34 | { 35 | type: 1, 36 | components: [ 37 | { 38 | type: 2, 39 | label: 'GitHub', 40 | style: 5, 41 | url: Strings.GITHUB_URL, 42 | }, 43 | { 44 | type: 2, 45 | label: 'Docs', 46 | style: 5, 47 | url: Strings.DOCS_URL, 48 | }, 49 | ], 50 | }, 51 | ], 52 | }, 53 | }; 54 | console.log(response); 55 | return new JsonResponse(response); 56 | }; 57 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { CommandResponse } from './response'; 2 | import { Strings } from './strings'; 3 | 4 | export class CommandRuntimeError extends Error { 5 | constructor(message: string) { 6 | super(message); 7 | } 8 | } 9 | 10 | export class CommandFailed extends CommandRuntimeError { 11 | constructor(message: string) { 12 | super(message); 13 | } 14 | } 15 | 16 | export class NetworkError extends Error { 17 | constructor(message: string) { 18 | super(message); 19 | } 20 | } 21 | 22 | export class MissingOptionError extends CommandRuntimeError { 23 | constructor(optionName?: string) { 24 | super( 25 | optionName 26 | ? Strings.MISSING_FIELD.format({ field: optionName }) 27 | : Strings.GENERIC_MISSING_FIELD 28 | ); 29 | } 30 | } 31 | 32 | export class InvalidOptionError extends CommandRuntimeError { 33 | constructor(optionName: string) { 34 | super(Strings.INVALID_FIELD.format({ field: optionName })); 35 | } 36 | } 37 | 38 | export class GuildOnlyCommandError extends CommandRuntimeError { 39 | constructor() { 40 | super(Strings.GENERIC_GUILD_MISSING); 41 | } 42 | } 43 | 44 | export const getErrorString = (response: Response): string => 45 | (response as any).message || response.statusText; 46 | 47 | export const handleCommandError = async (error: Error): Promise => { 48 | console.error(error); 49 | 50 | const errString = String(error).replace(/^Error: /g, ''); 51 | const errStack = String(error.stack).replace(/^Error: /g, ''); 52 | 53 | if (error instanceof CommandRuntimeError) { 54 | return new CommandResponse(errString); 55 | } else { 56 | return new CommandResponse(Strings.UNKNOWN_COMMAND_ERROR.format({ error: errStack })); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | APIMessageApplicationCommandInteractionData, 3 | APIUserApplicationCommandInteractionData, 4 | } from 'discord-api-types/payloads/v10/_interactions/_applicationCommands/contextMenu'; 5 | import { APIChatInputApplicationCommandInteractionData } from 'discord-api-types/payloads/v10/_interactions/_applicationCommands/chatInput'; 6 | import { APIApplicationCommandInteraction } from '../node_modules/discord-api-types/payloads/v10/_interactions/applicationCommands'; 7 | import { APIApplicationCommandOptionChoice } from '../node_modules/discord-api-types/payloads/v10/_interactions/_applicationCommands/_chatInput/shared'; 8 | 9 | export interface OptionsList { 10 | [optionName: string]: APIApplicationCommandOptionChoice; 11 | } 12 | 13 | export interface GuildSettings { 14 | roles: { 15 | [key: string]: { 16 | id: string; 17 | special?: boolean; 18 | }; 19 | }; 20 | } 21 | 22 | export type APIInteractionData = 23 | | APIChatInputApplicationCommandInteractionData 24 | | APIUserApplicationCommandInteractionData 25 | | APIMessageApplicationCommandInteractionData; 26 | 27 | /* 28 | Not sure if I'm just stupid but APIApplicationCommandInteraction does not support data.options 29 | even though it very much exists. 30 | 31 | Looking at advaith's code, he seems to be using some kind of workaround for this 32 | https://github.com/advaith1/activities/blob/b8805b991abbfb9f65e183c194f4616626e93ddb/src/bot.ts#L34 33 | 34 | However, with my TS settings while it will compile, VS Code will complain about the types constantly. 35 | So we'll just use our OptionedCommandInteraction to make it happier. 36 | */ 37 | export type OptionedCommandInteraction = APIApplicationCommandInteraction & { 38 | data: APIChatInputApplicationCommandInteractionData & { 39 | options: APIApplicationCommandOptionChoice[]; 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/discordAPI.ts: -------------------------------------------------------------------------------- 1 | import { Constants } from './constants'; 2 | import { APIRole } from '../node_modules/discord-api-types/payloads/v10/permissions'; 3 | import { NetworkError } from './errors'; 4 | 5 | export const discordApiCall = async ( 6 | url: string, 7 | method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET', 8 | body?: any 9 | ): Promise => { 10 | return await fetch(`${Constants.API_URL}${url}`, { 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | Authorization: `Bot ${PRONOUNS_BOT_TOKEN}`, 14 | }, 15 | method: method, 16 | body: typeof body === 'string' ? body : JSON.stringify(body), 17 | }); 18 | }; 19 | 20 | export const DiscordAPI = { 21 | getRoles: async (guildId: string): Promise => { 22 | const response = await discordApiCall(`/guilds/${guildId}/roles`); 23 | const roles = (await response.json()) as APIRole[]; 24 | 25 | if (response.ok !== true) { 26 | throw new NetworkError(`A network error occurred ${guildId}`); 27 | } 28 | return roles; 29 | }, 30 | getRole: async (guildId: string, roleId: string): Promise => { 31 | const roles = await DiscordAPI.getRoles(guildId); 32 | return roles.find(role => role.id === roleId) || null; 33 | }, 34 | createRole: async (guildId: string, name: string): Promise => { 35 | console.log('Creating role...'); 36 | let response = await discordApiCall(`/guilds/${guildId}/roles`, 'POST', { 37 | name: name, 38 | }); 39 | console.log('Request to create role finished, response: ', response); 40 | return response; 41 | }, 42 | deleteRole: async (guildId: string, roleId: string): Promise => { 43 | console.log('Deleting role...'); 44 | let response = await discordApiCall(`/guilds/${guildId}/roles/${roleId}`, 'DELETE'); 45 | return response; 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /src/command/createCustomRole.ts: -------------------------------------------------------------------------------- 1 | import { APIRole } from '../../node_modules/discord-api-types/payloads/v10/permissions'; 2 | import { CommandResponse } from '../response'; 3 | import { DiscordAPI } from '../discordAPI'; 4 | import { assertGuild } from '../sanitization'; 5 | import { getGuildSettings, setGuildSettings } from '../storage'; 6 | import { Strings } from '../strings'; 7 | import { OptionedCommandInteraction, OptionsList } from '../types'; 8 | import { CommandFailed } from '../errors'; 9 | import { registerGuildCommands } from '../registerGuild'; 10 | 11 | export const CreateCustomPronounCommand = async ( 12 | interaction: OptionedCommandInteraction, 13 | options: OptionsList 14 | ) => { 15 | assertGuild(interaction); 16 | 17 | const guild_id: string = interaction.guild_id as string; 18 | const roles: APIRole[] = await DiscordAPI.getRoles(guild_id); 19 | const guildSettings = await getGuildSettings(guild_id); 20 | 21 | const pronounName: string = options?.pronoun?.value as string; 22 | const special: boolean = Boolean(options?.special?.value); 23 | 24 | let roleMap = {} as { [role_id: string]: boolean }; 25 | 26 | for (let id in roles) { 27 | roleMap[roles[id]?.id] = true; 28 | } 29 | 30 | if (typeof guildSettings.roles === 'undefined') { 31 | guildSettings.roles = {}; 32 | } 33 | 34 | let existingRole = guildSettings.roles[pronounName]; 35 | 36 | if (typeof existingRole !== 'undefined' && roleMap[existingRole.id] === true) { 37 | throw new CommandFailed(Strings.CUSTOM_ROLE_EXISTS.format({ pronoun: pronounName })); 38 | } 39 | 40 | const createRoleResponse = await DiscordAPI.createRole(guild_id, pronounName); 41 | const role: APIRole = await createRoleResponse.json(); 42 | guildSettings.roles[pronounName] = { id: role.id }; 43 | 44 | if (special) { 45 | guildSettings.roles[pronounName].special = true; 46 | } 47 | 48 | await setGuildSettings(guild_id, guildSettings); 49 | 50 | setTimeout(async () => { 51 | try { 52 | await registerGuildCommands(interaction.guild_id as string); 53 | } catch (e) { 54 | console.log(e); 55 | } 56 | }) 57 | 58 | return new CommandResponse( 59 | Strings.CUSTOM_ROLE_CREATED.format({ pronoun: pronounName }) 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/command/deleteRole.ts: -------------------------------------------------------------------------------- 1 | import { DiscordAPI } from '../discordAPI'; 2 | import { registerGuildCommands } from '../registerGuild'; 3 | import { CommandResponse } from '../response'; 4 | import { assertGuild, assertOption } from '../sanitization'; 5 | import { getGuildSettings, setGuildSettings } from '../storage'; 6 | import { APIRole } from '../../node_modules/discord-api-types/payloads/v10/permissions'; 7 | 8 | import { GuildSettings, OptionedCommandInteraction, OptionsList } from '../types'; 9 | import { Strings } from '../strings'; 10 | 11 | const deleteExtraneousRole = async ( 12 | guildId: string, 13 | roleOption: string, 14 | guildSettings: GuildSettings 15 | ) => { 16 | delete guildSettings.roles?.[roleOption]; 17 | await setGuildSettings(guildId, guildSettings); 18 | return guildSettings; 19 | }; 20 | 21 | export const DeleteRoleCommand = async ( 22 | interaction: OptionedCommandInteraction, 23 | options: OptionsList 24 | ) => { 25 | assertGuild(interaction); 26 | const roleOption: string = assertOption(options.pronoun).value; 27 | const guildId = interaction.guild_id as string; 28 | let guildSettings = await getGuildSettings(guildId); 29 | const roleId = guildSettings.roles[roleOption]?.id || ''; 30 | const roles: APIRole[] = await DiscordAPI.getRoles(guildId); 31 | 32 | console.log('roleId', roleId); 33 | console.log('guildId', guildId); 34 | console.log('roleOption', roleOption); 35 | 36 | let roleMap = {} as { [role_id: string]: boolean }; 37 | 38 | roles.forEach(role => { 39 | roleMap[role?.id] = true; 40 | }); 41 | 42 | // If there's any missing roles referenced by us, delete them 43 | for (const pronoun in guildSettings.roles) { 44 | if (roleMap[guildSettings.roles[pronoun]?.id] !== true) { 45 | delete guildSettings.roles[pronoun]; 46 | } 47 | } 48 | 49 | const role = await DiscordAPI.getRole(guildId, roleId); 50 | if (role) { 51 | await DiscordAPI.deleteRole(guildId, roleId); 52 | } 53 | 54 | await deleteExtraneousRole(guildId, roleOption, guildSettings); 55 | setTimeout(async () => { 56 | try { 57 | await registerGuildCommands(interaction.guild_id as string); 58 | } catch (e) { 59 | console.log(e); 60 | } 61 | }); 62 | 63 | if (role) { 64 | return new CommandResponse(Strings.DELETE_ROLE_SUCCESS.format({ name: role.name })); 65 | } else { 66 | return new CommandResponse(Strings.DELETE_ROLE_MISSING.format({ name: roleOption })); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pronouns 2 | 3 | [![Webpack](https://github.com/dangeredwolf/pronouns-bot/actions/workflows/webpack.yml/badge.svg)](https://github.com/dangeredwolf/pronouns-bot/actions/workflows/webpack.yml) 4 | 5 | ### A next-gen Discord bot for managing pronouns, written for Cloudflare Workers for free and low-cost hosting, world-class reliability, infinite scalability, and fast response times around the world. 6 | 7 | ![The finished prompt](https://cdn.discordapp.com/attachments/165560751363325952/988283919738736650/2022-06-19T232721.978_chrome.png) 8 | 9 | It is built for Discord interactions, like slash commands and message components, from the very beginning, so they have an experience that feels like a native part of Discord. 10 | 11 | ### [Check out the docs of how to use it](https://wlf.is/pronouns) 12 | 13 | ### [Invite to your server](https://wlf.is/pronouns/invite) 14 | 15 | --- 16 | 17 | This bot began with [cloudflare/worker-typescript-template](https://github.com/cloudflare/worker-typescript-template) as a template, and also took inspiration from [advaith1/activities](https://github.com/advaith1/activities) and [discord/cloudflare-sample-app](https://github.com/discord/cloudflare-sample-app) to figure out how to best set-up and use Discord Interaction APIs. 18 | 19 | # Quick Start 20 | 21 | Pronouns is a decent bot to use as a basis for your own Cloudflare Workers Discord bot as it contains pretty much everything you'd need from an HTTP router to a command router to per-guild storage management. 22 | 23 | Things you need 24 | 25 | - A Cloudflare account, set up with Workers 26 | - A Cloudflare Workers KV store (We use the name PRONOUNS_BOT_GUILD_SETTINGS) 27 | - A Discord bot created at https://discord.com/developers 28 | 29 | This bot uses [wrangler](https://github.com/cloudflare/wrangler), so you should familiarize yourself with it to learn how to deploy your bot. 30 | 31 | Check out [discord/cloudflare-sample-app](https://github.com/discord/cloudflare-sample-app) to learn how to set up your Discord bot for interactions. You'll need to set up your worker to configure interactions. 32 | 33 | You'll want to configure the following secrets using wrangler: 34 | 35 | `DISCORD_PUBLIC_KEY` 36 | 37 | `DISCORD_APPLICATION_ID` 38 | 39 | `PRONOUNS_BOT_TOKEN` 40 | 41 | `PRONOUNS_BOT_TEST_GUILD_ID` 42 | 43 | To register commands, you can enable the `/__register` HTTP endpoint in `src/server.ts` to register your commands initially. It's suggested you disable this endpoint afterward though to prevent third parties from trying to re-run command registration. Once this is done once, you can register commands globally or for your test guild using the `/register_guild` and `/register_global` commands in your test server (specified in `PRONOUNS_BOT_TEST_GUILD_ID`). 44 | -------------------------------------------------------------------------------- /src/messageComponentHandler.ts: -------------------------------------------------------------------------------- 1 | import { APIMessageComponentInteraction } from '../node_modules/discord-api-types/payloads/v10/_interactions/messageComponents'; 2 | import { APIGuildMember } from '../node_modules/discord-api-types/payloads/v10/guild'; 3 | import { CommandResponse } from './response'; 4 | import { discordApiCall } from './discordAPI'; 5 | import { getGuildSettings } from './storage'; 6 | import { Strings } from './strings'; 7 | import { CommandFailed, getErrorString } from './errors'; 8 | import { sanitizePronoun } from './sanitization'; 9 | 10 | const throwNotFound = async () => { 11 | throw new CommandFailed(Strings.ROLE_NOT_CONFIGURED); 12 | }; 13 | 14 | export const handleMessageComponent = async (data: APIMessageComponentInteraction) => { 15 | console.log(data); 16 | const selectedPronoun = sanitizePronoun(data.data.custom_id as string); 17 | const settings = await getGuildSettings(data.guild_id as string); 18 | const user = data.member as APIGuildMember; 19 | console.log('settings.roles', settings.roles); 20 | console.log('settings.roles[selectedPronoun]', settings.roles[selectedPronoun]); 21 | console.log('selectedPronoun', selectedPronoun); 22 | const role_id = settings.roles[selectedPronoun]?.id || (await throwNotFound()); 23 | 24 | return await toggleRole(selectedPronoun, data.guild_id as string, role_id, user); 25 | }; 26 | 27 | const processError = (response: Response, failPrompt: string): CommandResponse => { 28 | let message = getErrorString(response); 29 | if (message === 'Unknown Role') { 30 | throw new CommandFailed(Strings.ROLE_NOT_CONFIGURED); 31 | } else if (message === 'Missing Permissions' || message === 'Missing Access') { 32 | throw new CommandFailed(Strings.ROLE_NO_PERMISSION); 33 | } else if (message === 'Forbidden') { 34 | throw new CommandFailed(Strings.ROLE_TOO_HIGH); 35 | } 36 | throw new CommandFailed(failPrompt.format({ error: message })); 37 | }; 38 | 39 | const toggleRole = async ( 40 | pronoun: string, 41 | guild_id: string, 42 | roleId: string = '0', 43 | member: APIGuildMember 44 | ) => { 45 | if (member.roles.includes(roleId)) { 46 | const deleteRole = await discordApiCall( 47 | `/guilds/${guild_id}/members/${member.user?.id}/roles/${roleId}`, 48 | 'DELETE' 49 | ); 50 | if (deleteRole.ok) { 51 | return new CommandResponse( 52 | Strings.ROLE_REMOVE_SUCCESS.format({ pronoun: pronoun }) 53 | ); 54 | } else { 55 | return processError(deleteRole, Strings.ROLE_REMOVE_FAIL); 56 | } 57 | } else { 58 | const addRole = await discordApiCall( 59 | `/guilds/${guild_id}/members/${member.user?.id}/roles/${roleId}`, 60 | 'PUT' 61 | ); 62 | if (addRole.ok) { 63 | return new CommandResponse(Strings.ROLE_ADD_SUCCESS.format({ pronoun: pronoun })); 64 | } else { 65 | processError(addRole, Strings.ROLE_ADD_FAIL); 66 | } 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | import { GuildSettings } from './types'; 2 | 3 | export const getGuildSettings = async (guild_id: string): Promise => { 4 | let guildSettings: GuildSettings = 5 | (await PRONOUNS_BOT_GUILD_SETTINGS.get(guild_id, { type: 'json' })) || 6 | ({ roles: {} } as GuildSettings); 7 | 8 | if (typeof guildSettings.roles === 'undefined') { 9 | guildSettings.roles = {}; 10 | } 11 | 12 | let migratedLegacySettings = false; 13 | 14 | if (typeof guildSettings.roles['he'] === 'string') { 15 | guildSettings.roles['He/Him'] = { id: guildSettings.roles['he'] }; 16 | delete guildSettings.roles['he']; 17 | migratedLegacySettings = true; 18 | } 19 | if (typeof guildSettings.roles['she'] === 'string') { 20 | guildSettings.roles['She/Her'] = { id: guildSettings.roles['she'] }; 21 | delete guildSettings.roles['she']; 22 | migratedLegacySettings = true; 23 | } 24 | if (typeof guildSettings.roles['they'] === 'string') { 25 | guildSettings.roles['They/Them'] = { id: guildSettings.roles['they'] }; 26 | delete guildSettings.roles['they']; 27 | migratedLegacySettings = true; 28 | } 29 | if (typeof guildSettings.roles['it'] === 'string') { 30 | guildSettings.roles['It/Its'] = { id: guildSettings.roles['it'] }; 31 | delete guildSettings.roles['it']; 32 | migratedLegacySettings = true; 33 | } 34 | if (typeof guildSettings.roles['ask'] === 'string') { 35 | guildSettings.roles['Pronouns: Ask'] = { 36 | id: guildSettings.roles['ask'], 37 | special: true, 38 | }; 39 | delete guildSettings.roles['ask']; 40 | migratedLegacySettings = true; 41 | } 42 | if (typeof guildSettings.roles['any'] === 'string') { 43 | guildSettings.roles['Any Pronouns'] = { 44 | id: guildSettings.roles['any'], 45 | special: true, 46 | }; 47 | delete guildSettings.roles['any']; 48 | migratedLegacySettings = true; 49 | } 50 | if ( 51 | guildSettings.roles['Pronouns: Ask'] && 52 | !guildSettings.roles['Pronouns: Ask'].special 53 | ) { 54 | guildSettings.roles['Pronouns: Ask'] = { 55 | id: guildSettings.roles['Pronouns: Ask'].id, 56 | special: true, 57 | }; 58 | migratedLegacySettings = true; 59 | } 60 | if ( 61 | guildSettings.roles['Any Pronouns'] && 62 | !guildSettings.roles['Any Pronouns'].special 63 | ) { 64 | guildSettings.roles['Any Pronouns'] = { 65 | id: guildSettings.roles['Any Pronouns'].id, 66 | special: true, 67 | }; 68 | migratedLegacySettings = true; 69 | } 70 | 71 | if (migratedLegacySettings) { 72 | await setGuildSettings(guild_id, guildSettings); 73 | } 74 | 75 | return guildSettings; 76 | }; 77 | 78 | export const setGuildSettings: any = async ( 79 | guild_id: string, 80 | settings: GuildSettings 81 | ) => { 82 | // If any custom pronouns have enums from regular roles, remove them 83 | return await PRONOUNS_BOT_GUILD_SETTINGS.put(guild_id, JSON.stringify(settings)); 84 | }; 85 | -------------------------------------------------------------------------------- /src/command/createRoles.ts: -------------------------------------------------------------------------------- 1 | import { APIRole } from '../../node_modules/discord-api-types/payloads/v10/permissions'; 2 | import { CommandResponse } from '../response'; 3 | import { DiscordAPI } from '../discordAPI'; 4 | import { assertGuild } from '../sanitization'; 5 | import { getGuildSettings, setGuildSettings } from '../storage'; 6 | import { Strings } from '../strings'; 7 | import { OptionedCommandInteraction } from '../types'; 8 | import { CommandFailed, getErrorString } from '../errors'; 9 | import { registerGuildCommands } from '../registerGuild'; 10 | 11 | export const CreateRolesCommand = async (interaction: OptionedCommandInteraction) => { 12 | assertGuild(interaction); 13 | 14 | const guild_id: string = interaction.guild_id as string; 15 | const roles: APIRole[] = await DiscordAPI.getRoles(guild_id); 16 | const guildSettings = await getGuildSettings(guild_id); 17 | 18 | console.log('Roles found: ', roles); 19 | console.log('Guild settings: ', guildSettings); 20 | 21 | let createdPronouns: string[] = []; 22 | 23 | let roleMap = {} as { [role_id: string]: boolean }; 24 | 25 | roles.forEach(role => { 26 | roleMap[role?.id] = true; 27 | }); 28 | 29 | // If there's any missing roles referenced by us, delete them 30 | for (const pronoun in guildSettings.roles) { 31 | if (roleMap[guildSettings.roles[pronoun]?.id] !== true) { 32 | delete guildSettings.roles[pronoun]; 33 | } 34 | } 35 | 36 | const DefaultPronouns = [ 37 | 'He/Him', 38 | 'She/Her', 39 | 'They/Them', 40 | 'It/Its', 41 | 'Any Pronouns', 42 | 'Pronouns: Ask', 43 | ]; 44 | 45 | // Create roles that are completely missing 46 | for (let pronoun_num in DefaultPronouns) { 47 | const pronoun = DefaultPronouns[pronoun_num]; 48 | if (roleMap[guildSettings.roles[pronoun]?.id] !== true) { 49 | const createRoleResponse = await DiscordAPI.createRole(guild_id, pronoun); 50 | const role: APIRole = await createRoleResponse.json(); 51 | 52 | if (createRoleResponse.ok !== true) { 53 | throw new CommandFailed( 54 | Strings.CREATE_ROLES_FAIL.format({ 55 | error: getErrorString(createRoleResponse), 56 | }) 57 | ); 58 | } 59 | 60 | if (pronoun === 'Pronouns: Ask' || pronoun === 'Any Pronouns') { 61 | guildSettings.roles[pronoun] = { id: role.id, special: true }; 62 | } else { 63 | guildSettings.roles[pronoun] = { id: role.id }; 64 | } 65 | 66 | console.log(role); 67 | createdPronouns.push(pronoun); 68 | } 69 | } 70 | 71 | await setGuildSettings(guild_id, guildSettings); 72 | setTimeout(async () => { 73 | try { 74 | await registerGuildCommands(interaction.guild_id as string); 75 | } catch (e) { 76 | console.log(e); 77 | } 78 | }); 79 | 80 | if (createdPronouns.length > 0) { 81 | return new CommandResponse( 82 | Strings.CREATE_ROLES_SUCCESS.format({ 83 | pronounList: createdPronouns.join(', '), 84 | }) 85 | ); 86 | } else { 87 | return new CommandResponse(Strings.CREATE_ROLES_ALREADY_DONE); 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'itty-router'; 2 | 3 | /* 4 | Useful little function to format strings for us 5 | */ 6 | 7 | declare global { 8 | interface String { 9 | format(options: any): string; 10 | } 11 | } 12 | 13 | String.prototype.format = function (options: any) { 14 | return this.replace(/{([^{}]+)}/g, (match: string, name: string) => { 15 | if (options[name] !== undefined) { 16 | return options[name]; 17 | } 18 | return match; 19 | }); 20 | }; 21 | 22 | import { OptionedCommandInteraction } from './types'; 23 | 24 | import { APIPingInteraction } from '../node_modules/discord-api-types/payloads/v10/_interactions/ping'; 25 | import { APIApplicationCommandInteraction } from '../node_modules/discord-api-types/payloads/v10/_interactions/applicationCommands'; 26 | import { APIMessageComponentInteraction } from '../node_modules/discord-api-types/payloads/v10/_interactions/messageComponents'; 27 | import { 28 | InteractionResponseType, 29 | InteractionType, 30 | } from '../node_modules/discord-api-types/payloads/v10/_interactions/responses'; 31 | 32 | import { verify } from './verify'; 33 | import { routeCommand } from './commandRouter'; 34 | import { JsonResponse } from './response'; 35 | import { Constants } from './constants'; 36 | import { handleMessageComponent } from './messageComponentHandler'; 37 | import { handleCommandError } from './errors'; 38 | 39 | const router = Router(); 40 | 41 | router.post('/api/interactions', async (request: Request) => { 42 | const isValidRequest = await verify(request.clone()); 43 | 44 | // If the request is not properly signed, we must return a 401 error 45 | if (!isValidRequest) { 46 | return new Response('Invalid signature', { status: 401 }); 47 | } 48 | 49 | console.log('Cloudflare: ', request.cf); 50 | let headersObject = Object.fromEntries(request.headers); 51 | let requestHeaders = JSON.stringify(headersObject, null, 2); 52 | console.log(`Request headers: ${requestHeaders}`); 53 | 54 | const data = (await request.json()) as 55 | | APIPingInteraction 56 | | APIApplicationCommandInteraction 57 | | APIMessageComponentInteraction 58 | | OptionedCommandInteraction; 59 | 60 | console.log(data); 61 | 62 | switch (data.type) { 63 | /* 64 | * Ping/Pong needed for the initial interaction URL handshake 65 | * https://discord.com/developers/docs/interactions/receiving-and-responding#receiving-an-interaction 66 | */ 67 | case InteractionType.Ping: { 68 | return new JsonResponse({ 69 | type: InteractionResponseType.Pong, 70 | }); 71 | } 72 | /* 73 | * Handles application commands 74 | */ 75 | case InteractionType.ApplicationCommand: { 76 | try { 77 | return await routeCommand(data as OptionedCommandInteraction, request); 78 | } catch (error: any) { 79 | return await handleCommandError(error); 80 | } 81 | } 82 | 83 | /* 84 | * Handles message components 85 | */ 86 | case InteractionType.MessageComponent: { 87 | try { 88 | return await handleMessageComponent(data as APIMessageComponentInteraction); 89 | } catch (error: any) { 90 | return await handleCommandError(error); 91 | } 92 | } 93 | } 94 | }); 95 | 96 | /* 97 | You can use this for initial command registration. 98 | Afterwards, comment it out and use /register_guild or /register_global commands 99 | in your test server (PRONOUNS_BOT_TEST_GUILD_ID) 100 | */ 101 | 102 | // router.get('/__register', async (request: Request) => { 103 | // await doRegisterCommands(); 104 | 105 | // return new Response('Registered commands', {status: 200}); 106 | // }); 107 | 108 | router.all('*', async request => { 109 | return Response.redirect(Constants.REDIRECT_URL); 110 | }); 111 | 112 | /* 113 | Event to receive web requests on Cloudflare Worker 114 | */ 115 | addEventListener('fetch', (event: FetchEvent) => { 116 | event.respondWith(router.handle(event.request)); 117 | }); 118 | -------------------------------------------------------------------------------- /src/commandDefinitions.ts: -------------------------------------------------------------------------------- 1 | import { PermissionFlagsBits } from '../node_modules/discord-api-types/payloads/common'; 2 | import { 3 | ApplicationCommandType, 4 | ApplicationCommandOptionType, 5 | } from '../node_modules/discord-api-types/payloads/v10/_interactions/applicationCommands'; 6 | 7 | // BigInt can't be JSON.stringified, and 1 << 5 isn't big enough to cause issues with floats anyway 8 | const ManageGuild = Number(PermissionFlagsBits.ManageGuild); 9 | 10 | export const SET_ROLE = { 11 | name: 'set_role', 12 | type: ApplicationCommandType.ChatInput, 13 | default_member_permissions: ManageGuild, 14 | description: 'Assign an existing role to a pronoun', 15 | options: [ 16 | { 17 | name: 'pronoun', 18 | description: 'Pronoun to assign the role to', 19 | type: ApplicationCommandOptionType.String, 20 | required: true, 21 | }, 22 | { 23 | name: 'role', 24 | description: 'Role corresponding to the pronoun', 25 | type: ApplicationCommandOptionType.Role, 26 | required: true, 27 | }, 28 | { 29 | name: 'special', 30 | description: 31 | 'True = Display separately from other pronouns (used for Any Pronouns/Pronouns: Ask)', 32 | type: ApplicationCommandOptionType.Boolean, 33 | required: false, 34 | }, 35 | ], 36 | }; 37 | 38 | export const CREATE_CUSTOM_ROLE = { 39 | name: 'create_role', 40 | type: ApplicationCommandType.ChatInput, 41 | default_member_permissions: ManageGuild, 42 | description: 'Create a custom pronoun with a new role', 43 | options: [ 44 | { 45 | name: 'pronoun', 46 | description: 'Pronoun to create a role for', 47 | type: ApplicationCommandOptionType.String, 48 | required: true, 49 | }, 50 | { 51 | name: 'special', 52 | description: 53 | 'True = Display separately from other pronouns (used for Any Pronouns/Pronouns: Ask)', 54 | type: ApplicationCommandOptionType.Boolean, 55 | required: false, 56 | }, 57 | ], 58 | }; 59 | 60 | export const VERSION = { 61 | name: 'version', 62 | type: ApplicationCommandType.ChatInput, 63 | default_member_permissions: ManageGuild, 64 | description: 'Displays the current running build of the bot', 65 | }; 66 | 67 | export const CREATE_ROLES = { 68 | name: 'create_default_roles', 69 | type: ApplicationCommandType.ChatInput, 70 | default_member_permissions: ManageGuild, 71 | description: 72 | 'Create these default roles: He/Him, She/Her, They/Them, It/Its, Any Pronouns, Pronouns: Ask', 73 | }; 74 | 75 | export const REGISTER_GLOBAL = { 76 | name: 'register_global', 77 | type: ApplicationCommandType.ChatInput, 78 | default_member_permissions: 0, // Administrator only 79 | description: 'Re-register global bot commands', 80 | }; 81 | 82 | export const REGISTER_GUILD = { 83 | name: 'register_guild', 84 | type: ApplicationCommandType.ChatInput, 85 | default_member_permissions: 0, // Administrator only 86 | description: 'Re-register test guild bot commands', 87 | }; 88 | 89 | export const LIST_ROLES = { 90 | name: 'list_roles', 91 | type: ApplicationCommandType.ChatInput, 92 | default_member_permissions: ManageGuild, 93 | description: 'List all roles for pronouns in the guild', 94 | }; 95 | 96 | export const SEND_PRONOUN_PICKER = { 97 | name: 'prompt', 98 | type: ApplicationCommandType.ChatInput, 99 | default_member_permissions: ManageGuild, 100 | description: 'Sends a message to the channel with a picker for pronouns', 101 | options: [ 102 | { 103 | name: 'title', 104 | description: 105 | 'A title for the embed sent with the picker; if not specified, a default title will be used', 106 | type: ApplicationCommandOptionType.String, 107 | required: false, 108 | }, 109 | { 110 | name: 'subtitle', 111 | description: 112 | 'A subtitle for the embed sent with the picker; if not specified, a default title will be used', 113 | type: ApplicationCommandOptionType.String, 114 | required: false, 115 | }, 116 | ], 117 | }; 118 | -------------------------------------------------------------------------------- /src/command/sendPronounPicker.ts: -------------------------------------------------------------------------------- 1 | import { CommandResponse } from '../response'; 2 | import { discordApiCall } from '../discordAPI'; 3 | import { assertGuild } from '../sanitization'; 4 | import { Strings } from '../strings'; 5 | import { OptionedCommandInteraction, OptionsList } from '../types'; 6 | import { CommandFailed, getErrorString } from '../errors'; 7 | import { getGuildPronouns } from '../roles'; 8 | 9 | const createMessage = async ( 10 | channel_id: string, 11 | guild_id: string, 12 | title?: string, 13 | subtitle?: string 14 | ) => { 15 | console.log('Creating message...'); 16 | let thingy = { 17 | embeds: [ 18 | { 19 | type: 'rich', 20 | title: title || Strings.PROMPT_DEFAULT_TITLE, 21 | description: subtitle || Strings.PROMPT_DEFAULT_SUBTITLE, 22 | }, 23 | ], 24 | components: await buildButtonLayout(guild_id), 25 | }; 26 | console.log(thingy); 27 | let response = await discordApiCall(`/channels/${channel_id}/messages`, 'POST', thingy); 28 | 29 | // console.log("Button layout: ", await buildButtonLayout(guild_id)); 30 | 31 | console.log('Request to create message finished, response: ', response); 32 | return response; 33 | }; 34 | 35 | const buildButtonLayout = async (guild_id: string) => { 36 | const pronouns = await getGuildPronouns(guild_id); 37 | 38 | console.log(pronouns); 39 | 40 | if (pronouns.length === 0) { 41 | throw new CommandFailed(Strings.PROMPT_NO_PRONOUNS); 42 | } 43 | 44 | let mainBucket = []; 45 | let specialBucket = []; 46 | 47 | for (const pronoun of pronouns) { 48 | if (pronoun.special) { 49 | specialBucket.push(pronoun); 50 | } else { 51 | mainBucket.push(pronoun); 52 | } 53 | } 54 | 55 | let row: any[] = []; 56 | let mainComponents: any[] = []; 57 | let specialComponents: any[] = []; 58 | 59 | for (const pronoun of mainBucket) { 60 | if (row.length >= 5) { 61 | mainComponents.push({ 62 | type: 1, 63 | components: row, 64 | }); 65 | row = []; 66 | } 67 | row.push({ 68 | type: 2, 69 | label: pronoun.name, 70 | style: 1, 71 | custom_id: pronoun.keyName, 72 | }); 73 | console.log(row); 74 | } 75 | 76 | if (row.length > 0) { 77 | mainComponents.push({ 78 | type: 1, 79 | components: row, 80 | }); 81 | } 82 | 83 | row = []; 84 | 85 | for (const pronoun of specialBucket) { 86 | if (row.length >= 5) { 87 | specialComponents.push({ 88 | type: 1, 89 | components: row, 90 | }); 91 | row = []; 92 | } 93 | row.push({ 94 | type: 2, 95 | label: pronoun.name, 96 | style: 2, 97 | custom_id: pronoun.keyName, 98 | }); 99 | } 100 | 101 | if (row.length > 0) { 102 | specialComponents.push({ 103 | type: 1, 104 | components: row, 105 | }); 106 | } 107 | 108 | return mainComponents.concat(specialComponents); 109 | }; 110 | 111 | export const SendPronounPickerCommand = async ( 112 | interaction: OptionedCommandInteraction, 113 | options: OptionsList 114 | ) => { 115 | assertGuild(interaction); 116 | 117 | const channel_id: string = interaction.channel_id; 118 | 119 | console.log('Channel ID: ', interaction.channel_id); 120 | console.log('Options: ', interaction.data.options); 121 | 122 | const title = (options.title?.value as string) || Strings.PROMPT_DEFAULT_TITLE; 123 | const subtitle = (options.subtitle?.value as string) || Strings.PROMPT_DEFAULT_SUBTITLE; 124 | 125 | let response = await createMessage( 126 | channel_id, 127 | interaction.guild_id as string, 128 | title, 129 | subtitle 130 | ); 131 | 132 | if (response.ok) { 133 | return new CommandResponse(Strings.PROMPT_SUCCESS); 134 | } else { 135 | let message = getErrorString(response); 136 | if (message === 'Missing Permissions') { 137 | throw new CommandFailed(Strings.PROMPT_MISSING_PERMISSION); 138 | } else if (message === 'Missing Access') { 139 | throw new CommandFailed(Strings.PROMPT_MISSING_ACCESS); 140 | } 141 | throw new CommandFailed(Strings.PROMPT_UNKNOWN_ERROR.format({ error: message })); 142 | } 143 | }; 144 | -------------------------------------------------------------------------------- /src/registerGuild.ts: -------------------------------------------------------------------------------- 1 | import { REGISTER_GLOBAL, REGISTER_GUILD } from './commandDefinitions'; 2 | 3 | const developerCommands: any[] = [REGISTER_GLOBAL, REGISTER_GUILD]; 4 | 5 | const testGuildId = PRONOUNS_BOT_TEST_GUILD_ID; 6 | import { registerCommands } from './register'; 7 | import { 8 | ApplicationCommandType, 9 | ApplicationCommandOptionType, 10 | APIApplicationCommand, 11 | } from '../node_modules/discord-api-types/payloads/v10/_interactions/applicationCommands'; 12 | import { PermissionFlagsBits } from '../node_modules/discord-api-types/payloads/common'; 13 | import { getGuildPronouns } from './roles'; 14 | 15 | const ManageGuild = String(PermissionFlagsBits.ManageGuild); 16 | 17 | // @ts-ignore Despite Discord API docs, some of the "required" properties are optional. Even Discord doesn't use all of them. 18 | // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-structure 19 | // https://github.com/discord/cloudflare-sample-app/blob/main/src/commands.js#L6 20 | export const DELETE_ROLE: APIApplicationCommand = { 21 | name: 'delete_role', 22 | type: ApplicationCommandType.ChatInput, 23 | default_member_permissions: ManageGuild, 24 | description: 'Delete a pronoun role', 25 | options: [ 26 | { 27 | name: 'pronoun', 28 | description: 'Pronoun to delete', 29 | type: ApplicationCommandOptionType.String, 30 | choices: [], 31 | }, 32 | ], 33 | }; 34 | 35 | // @ts-ignore Despite Discord API docs, some of the "required" properties are optional. Even Discord doesn't use all of them. 36 | // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-structure 37 | // https://github.com/discord/cloudflare-sample-app/blob/main/src/commands.js#L6 38 | export const UNASSIGN_ROLE: APIApplicationCommand = { 39 | name: 'unassign_role', 40 | type: ApplicationCommandType.ChatInput, 41 | default_member_permissions: ManageGuild, 42 | description: 'Unassigns a role from a pronoun', 43 | options: [ 44 | { 45 | name: 'pronoun', 46 | description: 'Pronoun to unassign', 47 | type: ApplicationCommandOptionType.String, 48 | choices: [], 49 | }, 50 | ], 51 | }; 52 | 53 | /** 54 | * Register all commands with a specific guild/server. Useful during initial 55 | * development and testing. 56 | */ 57 | export const registerGuildCommands = async (guild_id: string): Promise => { 58 | if (!testGuildId) { 59 | throw new Error('The PRONOUNS_BOT_TEST_GUILD_ID environment variable is required.'); 60 | } 61 | 62 | let commands: APIApplicationCommand[] = []; 63 | 64 | let guildDeleteRole = DELETE_ROLE; 65 | let guildUnassignRole = UNASSIGN_ROLE; 66 | let guildDeleteRoleOption = guildDeleteRole?.options?.[0] as any; 67 | let guildUnassignRoleOption = guildUnassignRole?.options?.[0] as any; 68 | let roleOptions: any[] = []; 69 | let roles = await getGuildPronouns(guild_id); 70 | 71 | console.log(roleOptions); 72 | 73 | guildDeleteRoleOption.choices = []; 74 | guildUnassignRoleOption.choices = []; 75 | 76 | // Create an option for each role 77 | roles.forEach(role => { 78 | guildDeleteRoleOption.choices.push({ 79 | name: role.name, 80 | description: `${role.name} Pronoun`, 81 | value: role.name, 82 | }); 83 | 84 | guildUnassignRoleOption.choices.push({ 85 | name: role.name, 86 | description: `${role.name} Pronoun`, 87 | value: role.name, 88 | }); 89 | }); 90 | 91 | commands.push(guildDeleteRole); 92 | commands.push(guildUnassignRole); 93 | 94 | const url = `/applications/${DISCORD_APPLICATION_ID}/guilds/${testGuildId}/commands`; 95 | if (guild_id === PRONOUNS_BOT_TEST_GUILD_ID) { 96 | commands = commands.concat(developerCommands); 97 | } 98 | const res = await registerCommands(url, commands); 99 | const json: any[] = (await res.json()) as any[]; 100 | console.log(json); 101 | 102 | json.forEach(async (cmd: any) => { 103 | const response = await fetch( 104 | `/applications/${DISCORD_APPLICATION_ID}/guilds/${testGuildId}/commands/${cmd.id}` 105 | ); 106 | if (!response.ok) { 107 | console.error(`Problem removing command ${cmd.id}`); 108 | } 109 | }); 110 | 111 | return res; 112 | }; 113 | -------------------------------------------------------------------------------- /src/strings.ts: -------------------------------------------------------------------------------- 1 | import { Constants } from './constants'; 2 | 3 | export const Strings = { 4 | PROMPT_MISSING_PERMISSION: `❌ I don't have permission to create a prompt in this channel. Please ensure that I have permission to View Channel, Send Message, and Embed Links`, 5 | PROMPT_MISSING_ACCESS: `❌ I don't have permission to create a prompt in this server. Please ensure that I have permission to View Channel, Send Message, and Embed Links`, 6 | PROMPT_UNKNOWN_ERROR: `❌ I couldn't create the pronoun picker message for you ({error}). Please try again later.`, 7 | PROMPT_SUCCESS: `✅ I successfully created the pronoun picker message for you.`, 8 | PROMPT_NO_PRONOUNS: `You haven't configured any pronouns yet. You can bind existing roles to pronouns using **/set_role**, or generate new roles using **/create_roles**.`, 9 | 10 | PROMPT_DEFAULT_TITLE: `👋 Hi, I can set pronouns for you`, 11 | PROMPT_DEFAULT_SUBTITLE: `Setting your pronouns lets everyone on the server know how to address you.`, 12 | 13 | GUILD_COMMAND_REGISTER_SUCCESS: `✅ Re-registered guild commands`, 14 | GUILD_COMMAND_REGISTER_FAIL: `❌ Failed to re-register guild commands: {error}`, 15 | GLOBAL_COMMAND_REGISTER_SUCCESS: `✅ Re-registered global commands`, 16 | GLOBAL_COMMAND_REGISTER_FAIL: `❌ Failed to re-register global commands: {error}`, 17 | 18 | LIST_ROLES_RESULT: `Here are the roles I know about:\n\n{roles}\n\nAny deleted or missing roles? Use **/set_role** to configure existing roles with this bot or **/create_role** to create new roles.`, 19 | LIST_ROLE_ENTRY: `{pronoun}: <@&{role_id}>`, 20 | 21 | CREATE_ROLES_SUCCESS: `✅ I created pronoun roles for {pronounList}`, 22 | CREATE_ROLES_FAIL: `❌ I couldn't create some roles ({error}). Please try again in a little bit. If this is your server, make sure the bot has the Manage Roles permission.`, 23 | CREATE_ROLES_ALREADY_DONE: `✅ All the necessary roles have been created already.`, 24 | 25 | SET_ROLE_SUCCESS: `✅ Role for pronoun {pronoun} set to <@&{role_id}>`, 26 | 27 | ROLE_ADD_SUCCESS: `✅ You've added the **{pronoun}** pronoun role. You can pick more pronouns if you'd like!`, 28 | ROLE_ADD_FAIL: `❌ An error occurred while adding the role ({error}). Please try again in a little bit.`, 29 | ROLE_REMOVE_SUCCESS: `✅ You no longer have the **{pronoun}** pronoun role.`, 30 | ROLE_REMOVE_FAIL: `❌ An error occurred while removing the role ({error}). Please try again in a little bit.`, 31 | ROLE_NOT_CONFIGURED: `❌ This pronoun hasn't been configured yet. If you manage this server, use the **/set_role** command to add existing pronoun roles to the bot or **/create_role** to create new roles.\n\nView full docs at ${Constants.DOCS_URL}`, 32 | ROLE_NO_PERMISSION: `❌ I don't have permission to manage roles. Please give me the Manage Roles permission so I can work.`, 33 | ROLE_TOO_HIGH: `❌ I can't assign this role because it's higher than my highest role. If you're an admin, please fix this by moving my role above the roles of your pronouns so I can assign them.`, 34 | 35 | GENERIC_GUILD_MISSING: `You can only use this command in a server.`, 36 | GENERIC_MISSING_FIELD: `❌ Missing required parameter`, 37 | MISSING_FIELD: `❌ Missing required parameter: {field}`, 38 | INVALID_FIELD: `❌ Invalid parameter for {field}`, 39 | 40 | CUSTOM_ROLE_SUCCESS: `✅ Role set for {pronoun}`, 41 | CUSTOM_ROLE_EXISTS: `Pronoun role already exists for {pronoun}`, 42 | CUSTOM_ROLE_CREATED: `✅ Created role for new pronoun {pronoun}`, 43 | CUSTOM_ROLE_FAIL: `❌ An error occurred while setting role ({error}). Please try again in a little bit.`, 44 | 45 | DELETE_ROLE_SUCCESS: `✅ Deleted role {name}`, 46 | DELETE_ROLE_MISSING: `ℹ It seems that role was already deleted, so we removed it from your list of roles.`, 47 | 48 | UNASSIGN_ROLE_SUCCESS: `✅ Unassigned role {name}`, 49 | 50 | VERSION_INFO: `Pronouns Bot build ${_COMMIT_HASH} (${_BUILD_DATE})`, 51 | VERSION_DESCRIPTION: `Made with love by dangered wolf#3621 (<@284144747860459532>)\n\n<:cf:988895299693080616> ➡ <:discord:988895279270985760> {discordLocation}`, 52 | VERSION_LOGO_URL: `https://cdn.discordapp.com/attachments/165560751363325952/988880853964832768/PronounsLogo.png`, 53 | GITHUB_URL: `https://github.com/dangeredwolf/pronouns-bot`, 54 | DOCS_URL: `https://wlf.is/pronouns`, 55 | COMMAND_NOT_FOUND: `❌ Unknown command: {command}`, 56 | UNKNOWN_COMMAND_ERROR: `❌ An unknown error occurred while processing your command. If you see this, please ping \`dangered wolf#3621\` <@284144747860459532>\n\n{error}`, 57 | }; 58 | --------------------------------------------------------------------------------