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 Somadd color selection354c8fb

main
4.5 KiB106 linesraw
1import type { Client } from 'oceanic.js'
2import { colorRoles, setColorRole } from './store.ts'
3
4const HEX_INPUT = /^#?([0-9a-f]{6})$/i
5
6/** Normalizes a hex color to the `#rrggbb` form used as the role name.
7 *  Returns undefined for anything else. */
8export function parseHex(input: string): string | undefined {
9  const digits = HEX_INPUT.exec(input.trim())?.[1]?.toLowerCase()
10  if (digits === undefined) return undefined
11  // discord treats a role color of 0 as uncolored
12  return digits === '000000' ? '#010101' : `#${digits}`
13}
14
15/** The color a member has from a bot-created color role, if any. */
16export function currentColor(guildID: string, memberRoles: readonly string[]): string | undefined {
17  return Object.entries(colorRoles[guildID] ?? {}).find(([, roleID]) => memberRoles.includes(roleID))?.[0]
18}
19
20export function forgetColorRole(guildID: string, roleID: string): void {
21  const hex = currentColor(guildID, [roleID])
22  if (hex !== undefined) setColorRole(guildID, hex, undefined)
23}
24
25// every change reads the role store, talks to discord, then writes the store,
26// so two members picking the same new color at once would create it twice
27let queue: Promise<unknown> = Promise.resolve()
28function serialized<T>(task: () => Promise<T>): Promise<T> {
29  const result = queue.then(task)
30  queue = result.catch(() => {})
31  return result
32}
33
34/** Deletes the given color roles if nobody has them anymore. Returns how many were deleted. */
35async function prune(client: Client, guildID: string, roleIDs: readonly string[]): Promise<number> {
36  if (roleIDs.length === 0) return 0
37  const counts = await client.rest.guilds.getRoleMemberCounts(guildID)
38  let deleted = 0
39  for (const [hex, roleID] of Object.entries(colorRoles[guildID] ?? {})) {
40    if (!roleIDs.includes(roleID) || (counts[roleID] ?? 0) > 0) continue
41    await client.rest.guilds.deleteRole(guildID, roleID, 'nobody has this color anymore')
42    setColorRole(guildID, hex, undefined)
43    deleted++
44  }
45  return deleted
46}
47
48/** Gives a member the role for `hex`, creating it if needed, and takes away
49 *  any other color role they had. Pass undefined to only take colors away. */
50export function setMemberColor(
51  client: Client,
52  guildID: string,
53  member: { id: string; roles: readonly string[] },
54  hex: string | undefined
55): Promise<void> {
56  return serialized(async () => {
57    let roleID = hex === undefined ? undefined : colorRoles[guildID]?.[hex]
58    if (hex !== undefined && roleID === undefined) {
59      const guild = client.guilds.get(guildID)
60      if (guild === undefined) throw new Error(`guild ${guildID} is not cached`)
61      const top = Math.max(0, ...guild.clientMember.roles.map((id) => guild.roles.get(id)?.position ?? 0))
62      const role = await client.rest.guilds.createRole(guildID, {
63        colors: { primaryColor: Number.parseInt(hex.slice(1), 16) },
64        name: hex,
65        permissions: '0'
66      })
67      setColorRole(guildID, hex, role.id)
68      roleID = role.id
69      // names take the color of the highest colored role, and new roles start
70      // at the bottom, so lift it as high as the bot is allowed to
71      await client.rest.guilds.editRolePositions(guildID, [{ id: roleID, position: top - 1 }])
72    }
73
74    if (roleID !== undefined && !member.roles.includes(roleID)) {
75      await client.rest.guilds.addMemberRole(guildID, member.id, roleID)
76    }
77    const previous = Object.values(colorRoles[guildID] ?? {}).filter(
78      (id) => id !== roleID && member.roles.includes(id)
79    )
80    for (const id of previous) await client.rest.guilds.removeMemberRole(guildID, member.id, id)
81    await prune(client, guildID, previous)
82  })
83}
84
85/** Forgets color roles deleted while the bot was offline, and deletes the ones
86 *  left without members by people changing color or leaving the server. */
87export function pruneColorRoles(client: Client): Promise<void> {
88  return serialized(async () => {
89    for (const [guildID, roles] of Object.entries(colorRoles)) {
90      const guild = client.guilds.get(guildID)
91      if (guild === undefined) {
92        console.warn(`color roles: guild ${guildID} is unavailable, skipping cleanup`)
93        continue
94      }
95      for (const [hex, roleID] of Object.entries(roles)) {
96        if (!guild.roles.has(roleID)) setColorRole(guildID, hex, undefined)
97      }
98      try {
99        const deleted = await prune(client, guildID, Object.values(roles))
100        console.log(`color roles in ${guildID}: cleanup complete, deleted ${deleted}`)
101      } catch (error) {
102        console.error(`color roles in ${guildID}: cleanup failed:`, error)
103      }
104    }
105  })
106}