char-slop/city-role-bot

discord auto role applicator bot for charlotte polycule city

git clone https://git.t4t.associates/char-slop/city-role-bot

Charlotte Somsimplifyd72003f

main
5.2 KiB158 linesraw
1import { MessageFlags, type EditInteractionContent } from 'oceanic.js'
2import { deletePanel, panels, setPanel } from '../store.ts'
3import { normalizeEmoji } from '../emoji.ts'
4import { guard, slash, slashSub } from '../rosepack.ts'
5
6const ephemeral = (content: string): EditInteractionContent => ({
7  content,
8  flags: MessageFlags.EPHEMERAL
9})
10
11const ROLE_INPUT = /^(?:<@&)?(\d+)>?$/
12const MESSAGE_INPUT = /^(?:https:\/\/discord(?:app)?\.com\/channels\/\d+\/\d+\/)?(\d+)$/
13
14// declared on the leaves rather than the root so the leaf contexts get
15// guild-narrowed interaction types
16const adminOnly = [
17  guard.guild({ message: 'this command only works in a server.' }),
18  guard.userPermissions('MANAGE_ROLES', { message: 'you need manage roles to do that.' })
19]
20
21export default slash({
22  name: 'reactrole',
23  description: 'manage reaction-role panels',
24  subcommands: {
25    create: slashSub({
26      description: 'post a new reaction-role message in this channel',
27      guards: adminOnly,
28      options: {
29        message: {
30          description: 'what the panel message should say',
31          kind: 'string',
32          maxLength: 2_000,
33          required: true
34        },
35        emoji: {
36          description: 'the emoji users react with',
37          kind: 'string',
38          required: true
39        },
40        role: {
41          description: 'the role granted by reacting (mention or id)',
42          kind: 'string',
43          required: true
44        }
45      },
46
47      async execute(context) {
48        const { client } = context
49        const guildID = context.interaction.guildID
50        const channelID = context.interaction.channelID
51
52        const emoji = normalizeEmoji(context.options.emoji)
53        if (emoji === undefined) {
54          await context.reply(ephemeral('that is not a valid emoji. use a single unicode or custom emoji.'))
55          return
56        }
57
58        const roleID = ROLE_INPUT.exec(context.options.role.trim())?.[1]
59        if (roleID === undefined) {
60          await context.reply(ephemeral('pass the role as a mention or an id.'))
61          return
62        }
63
64        const guild = client.guilds.get(guildID)
65        const role =
66          guild?.roles.get(roleID) ??
67          (await client.rest.guilds.getRole(guildID, roleID).catch(() => undefined))
68        if (role === undefined) {
69          await context.reply(ephemeral('that role does not exist in this server.'))
70          return
71        }
72
73        // startup reconciliation revokes the role from anyone who has not
74        // reacted, so two panels sharing one role would fight each other
75        for (const [existingID, existing] of panels) {
76          if (existing.guildID === guildID && existing.roleID === roleID) {
77            await context.reply(
78              ephemeral(
79                `that role already has a panel: https://discord.com/channels/${guildID}/${existing.channelID}/${existingID}`
80              )
81            )
82            return
83          }
84        }
85
86        if (guild !== undefined) {
87          const botMember = guild.members.get(client.user.id)
88          if (botMember !== undefined) {
89            const top = Math.max(0, ...botMember.roles.map((id) => guild.roles.get(id)?.position ?? 0))
90            if (role.position >= top) {
91              await context.reply(
92                ephemeral('the target role must sit below my highest role before i can grant it.')
93              )
94              return
95            }
96          }
97        }
98
99        let message
100        try {
101          message = await client.rest.channels.createMessage(channelID, {
102            content: context.options.message
103          })
104        } catch {
105          await context.reply(
106            ephemeral(
107              'i could not post the panel here. check that i have view channel and send messages in this channel.'
108            )
109          )
110          return
111        }
112        try {
113          await client.rest.channels.createReaction(channelID, message.id, emoji)
114        } catch {
115          await client.rest.channels.deleteMessage(channelID, message.id).catch(() => {})
116          await context.reply(
117            ephemeral(
118              'i could not react to the new message. check that i have add reactions and read message history here.'
119            )
120          )
121          return
122        }
123
124        setPanel(message.id, { channelID, emoji, guildID, reactors: new Set(), roleID })
125        await context.reply(
126          ephemeral(`panel created: https://discord.com/channels/${guildID}/${channelID}/${message.id}`)
127        )
128      }
129    }),
130
131    remove: slashSub({
132      description: 'stop a reaction-role message from granting roles',
133      guards: adminOnly,
134      options: {
135        message: {
136          description: 'the panel message, as an id or link',
137          kind: 'string',
138          required: true
139        }
140      },
141
142      async execute(context) {
143        const messageID = MESSAGE_INPUT.exec(context.options.message.trim())?.[1]
144        if (messageID === undefined) {
145          await context.reply(ephemeral('pass the panel message as an id or a message link.'))
146          return
147        }
148        await context.reply(
149          ephemeral(
150            deletePanel(messageID)
151              ? 'that message no longer grants roles.'
152              : 'there is no panel registered for that message.'
153          )
154        )
155      }
156    })
157  }
158})