import type { Client } from 'oceanic.js' import { colorRoles, setColorRole } from './store.ts' const HEX_INPUT = /^#?([0-9a-f]{6})$/i /** Normalizes a hex color to the `#rrggbb` form used as the role name. * Returns undefined for anything else. */ export function parseHex(input: string): string | undefined { const digits = HEX_INPUT.exec(input.trim())?.[1]?.toLowerCase() if (digits === undefined) return undefined // discord treats a role color of 0 as uncolored return digits === '000000' ? '#010101' : `#${digits}` } /** The color a member has from a bot-created color role, if any. */ export function currentColor(guildID: string, memberRoles: readonly string[]): string | undefined { return Object.entries(colorRoles[guildID] ?? {}).find(([, roleID]) => memberRoles.includes(roleID))?.[0] } export function forgetColorRole(guildID: string, roleID: string): void { const hex = currentColor(guildID, [roleID]) if (hex !== undefined) setColorRole(guildID, hex, undefined) } // every change reads the role store, talks to discord, then writes the store, // so two members picking the same new color at once would create it twice let queue: Promise = Promise.resolve() function serialized(task: () => Promise): Promise { const result = queue.then(task) queue = result.catch(() => {}) return result } /** Deletes the given color roles if nobody has them anymore. Returns how many were deleted. */ async function prune(client: Client, guildID: string, roleIDs: readonly string[]): Promise { if (roleIDs.length === 0) return 0 const counts = await client.rest.guilds.getRoleMemberCounts(guildID) let deleted = 0 for (const [hex, roleID] of Object.entries(colorRoles[guildID] ?? {})) { if (!roleIDs.includes(roleID) || (counts[roleID] ?? 0) > 0) continue await client.rest.guilds.deleteRole(guildID, roleID, 'nobody has this color anymore') setColorRole(guildID, hex, undefined) deleted++ } return deleted } /** Gives a member the role for `hex`, creating it if needed, and takes away * any other color role they had. Pass undefined to only take colors away. */ export function setMemberColor( client: Client, guildID: string, member: { id: string; roles: readonly string[] }, hex: string | undefined ): Promise { return serialized(async () => { let roleID = hex === undefined ? undefined : colorRoles[guildID]?.[hex] if (hex !== undefined && roleID === undefined) { const guild = client.guilds.get(guildID) if (guild === undefined) throw new Error(`guild ${guildID} is not cached`) const top = Math.max(0, ...guild.clientMember.roles.map((id) => guild.roles.get(id)?.position ?? 0)) const role = await client.rest.guilds.createRole(guildID, { colors: { primaryColor: Number.parseInt(hex.slice(1), 16) }, name: hex, permissions: '0' }) setColorRole(guildID, hex, role.id) roleID = role.id // names take the color of the highest colored role, and new roles start // at the bottom, so lift it as high as the bot is allowed to await client.rest.guilds.editRolePositions(guildID, [{ id: roleID, position: top - 1 }]) } if (roleID !== undefined && !member.roles.includes(roleID)) { await client.rest.guilds.addMemberRole(guildID, member.id, roleID) } const previous = Object.values(colorRoles[guildID] ?? {}).filter( (id) => id !== roleID && member.roles.includes(id) ) for (const id of previous) await client.rest.guilds.removeMemberRole(guildID, member.id, id) await prune(client, guildID, previous) }) } /** Forgets color roles deleted while the bot was offline, and deletes the ones * left without members by people changing color or leaving the server. */ export function pruneColorRoles(client: Client): Promise { return serialized(async () => { for (const [guildID, roles] of Object.entries(colorRoles)) { const guild = client.guilds.get(guildID) if (guild === undefined) { console.warn(`color roles: guild ${guildID} is unavailable, skipping cleanup`) continue } for (const [hex, roleID] of Object.entries(roles)) { if (!guild.roles.has(roleID)) setColorRole(guildID, hex, undefined) } try { const deleted = await prune(client, guildID, Object.values(roles)) console.log(`color roles in ${guildID}: cleanup complete, deleted ${deleted}`) } catch (error) { console.error(`color roles in ${guildID}: cleanup failed:`, error) } } }) }