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
3.3 KiB80 linesraw
1import { DiscordHTTPError, DiscordRESTError, type Client } from 'oceanic.js'
2import { reactionKey } from './emoji.ts'
3import { deletePanel, panels, setReactor, type RolePanel } from './store.ts'
4
5/** Grants or revokes a panel's role to match one user's reaction, recording
6 *  the outcome so a later sync knows it was applied. Resolves false on failure. */
7export async function applyReaction(
8  client: Client,
9  messageID: string,
10  panel: RolePanel,
11  userID: string,
12  reacted: boolean
13): Promise<boolean> {
14  const { guildID, roleID } = panel
15  try {
16    if (reacted) await client.rest.guilds.addMemberRole(guildID, userID, roleID)
17    else await client.rest.guilds.removeMemberRole(guildID, userID, roleID)
18  } catch (error) {
19    const verb = reacted ? 'grant' : 'revoke'
20    console.error(`panel ${messageID}: could not ${verb} role ${roleID} for ${userID}:`, error)
21    return false
22  }
23  setReactor(panel, userID, reacted)
24  return true
25}
26
27/** Diffs every panel's current reactions against its tracked reactors and
28 *  applies the grants and revocations that happened while the bot was down. */
29export async function syncPanels(client: Client): Promise<void> {
30  for (const [messageID, panel] of panels) {
31    try {
32      // read the message's own reaction list so the emoji we query with
33      // matches discord's stored form exactly (variation selectors vary)
34      const message = await client.rest.channels.getMessage(panel.channelID, messageID)
35      const match = message.reactions.find((r) => reactionKey(r.emoji) === panel.emoji)
36      if (match === undefined || match.emoji.name === null) continue
37      const emoji = match.emoji.id === null ? match.emoji.name : `${match.emoji.name}:${match.emoji.id}`
38
39      const current = new Set(
40        (
41          await client.rest.channels.getReactions(panel.channelID, messageID, emoji, {
42            limit: Infinity
43          })
44        )
45          .filter((user) => !user.bot)
46          .map((user) => user.id)
47      )
48
49      let granted = 0
50      let revoked = 0
51      for (const userID of current) {
52        if (panel.reactors.has(userID)) continue
53        if (await applyReaction(client, messageID, panel, userID, true)) granted++
54      }
55      // snapshot: applyReaction removes from panel.reactors as we go
56      for (const userID of [...panel.reactors]) {
57        if (current.has(userID)) continue
58        if (await applyReaction(client, messageID, panel, userID, false)) revoked++
59      }
60      console.log(`panel ${messageID}: sync complete, granted ${granted}, revoked ${revoked}`)
61    } catch (error) {
62      if (
63        !(error instanceof DiscordHTTPError || error instanceof DiscordRESTError) ||
64        error.status !== 404
65      ) {
66        console.error(`panel ${messageID}: startup sync failed:`, error)
67        continue
68      }
69      // discord returns 404 for deleted messages and for channels the bot can
70      // no longer see; only unregister once we know the channel still exists
71      const channel = await client.rest.channels.get(panel.channelID).catch(() => undefined)
72      if (channel !== undefined) {
73        deletePanel(messageID)
74        console.log(`panel ${messageID}: message no longer exists, unregistered`)
75      } else {
76        console.warn(`panel ${messageID}: message or channel inaccessible, keeping panel`)
77      }
78    }
79  }
80}