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
354c8fb
main
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 { 9const digits = HEX_INPUT . exec ( input . trim ())?.[ 1 ]?. toLowerCase () 10if ( digits === undefined ) return undefined 11// discord treats a role color of 0 as uncolored 12return 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 { 17return Object . entries ( colorRoles [ guildID ] ?? {}). find (([, roleID ]) => memberRoles . includes ( roleID ))?.[ 0 ] 18} 19 20export function forgetColorRole ( guildID :string , roleID :string ) :void { 21const hex = currentColor ( guildID , [ roleID ]) 22if ( 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 > { 29const result = queue . then ( task ) 30queue = result . catch (() => {}) 31return 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 > { 36if ( roleIDs . length === 0 ) return 0 37const counts = await client . rest . guilds . getRoleMemberCounts ( guildID ) 38let deleted = 0 39for ( const [ hex , roleID ] of Object . entries ( colorRoles [ guildID ] ?? {})) { 40if ( ! roleIDs . includes ( roleID ) || ( counts [ roleID ] ?? 0 ) > 0 ) continue 41await client . rest . guilds . deleteRole ( guildID , roleID , 'nobody has this color anymore' ) 42setColorRole ( guildID , hex , undefined ) 43deleted ++ 44} 45return 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 ( 51client :Client , 52guildID :string , 53member :{ id :string ; roles :readonly string [] }, 54hex :string | undefined 55) :Promise < void > { 56return serialized ( async () => { 57let roleID = hex === undefined ?undefined :colorRoles [ guildID ]?.[ hex ] 58if ( hex !== undefined && roleID === undefined ) { 59const guild = client . guilds . get ( guildID ) 60if ( guild === undefined ) throw new Error ( `guild ${ guildID } is not cached` ) 61const top = Math . max ( 0 , ...guild . clientMember . roles . map (( id ) => guild . roles . get ( id )?. position ?? 0 )) 62const role = await client . rest . guilds . createRole ( guildID , { 63colors :{ primaryColor :Number . parseInt ( hex . slice ( 1 ), 16 ) }, 64name :hex , 65permissions :'0' 66}) 67setColorRole ( guildID , hex , role . id ) 68roleID = 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 71await client . rest . guilds . editRolePositions ( guildID , [{ id :roleID , position :top - 1 }]) 72} 73 74if ( roleID !== undefined && ! member . roles . includes ( roleID )) { 75await client . rest . guilds . addMemberRole ( guildID , member . id , roleID ) 76} 77const previous = Object . values ( colorRoles [ guildID ] ?? {}). filter ( 78( id ) => id !== roleID && member . roles . includes ( id ) 79) 80for ( const id of previous ) await client . rest . guilds . removeMemberRole ( guildID , member . id , id ) 81await 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 > { 88return serialized ( async () => { 89for ( const [ guildID , roles ] of Object . entries ( colorRoles )) { 90const guild = client . guilds . get ( guildID ) 91if ( guild === undefined ) { 92console . warn ( `color roles: guild ${ guildID } is unavailable, skipping cleanup` ) 93continue 94} 95for ( const [ hex , roleID ] of Object . entries ( roles )) { 96if ( ! guild . roles . has ( roleID )) setColorRole ( guildID , hex , undefined ) 97} 98try { 99const deleted = await prune ( client , guildID , Object . values ( roles )) 100console . log ( `color roles in ${ guildID } : cleanup complete, deleted ${ deleted } ` ) 101} catch ( error ) { 102console . error ( `color roles in ${ guildID } : cleanup failed:` , error ) 103} 104} 105}) 106}