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
d72003f
main
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 ( 8client :Client , 9messageID :string , 10panel :RolePanel , 11userID :string , 12reacted :boolean 13) :Promise < boolean > { 14const { guildID, roleID} = panel 15try { 16if ( reacted ) await client . rest . guilds . addMemberRole ( guildID , userID , roleID ) 17else await client . rest . guilds . removeMemberRole ( guildID , userID , roleID ) 18} catch ( error ) { 19const verb = reacted ?'grant' :'revoke' 20console . error ( `panel ${ messageID } : could not ${ verb } role ${ roleID } for ${ userID } :` , error ) 21return false 22} 23setReactor ( panel , userID , reacted ) 24return 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 > { 30for ( const [ messageID , panel ] of panels ) { 31try { 32// read the message's own reaction list so the emoji we query with 33// matches discord's stored form exactly (variation selectors vary) 34const message = await client . rest . channels . getMessage ( panel . channelID , messageID ) 35const match = message . reactions . find (( r ) => reactionKey ( r . emoji ) === panel . emoji ) 36if ( match === undefined || match . emoji . name === null ) continue 37const emoji = match . emoji . id === null ?match . emoji . name :` ${ match . emoji . name } : ${ match . emoji . id } ` 38 39const current = new Set ( 40( 41await client . rest . channels . getReactions ( panel . channelID , messageID , emoji , { 42limit :Infinity 43}) 44) 45. filter (( user ) => ! user . bot ) 46. map (( user ) => user . id ) 47) 48 49let granted = 0 50let revoked = 0 51for ( const userID of current ) { 52if ( panel . reactors . has ( userID )) continue 53if ( await applyReaction ( client , messageID , panel , userID , true )) granted ++ 54} 55// snapshot: applyReaction removes from panel.reactors as we go 56for ( const userID of [ ...panel . reactors ]) { 57if ( current . has ( userID )) continue 58if ( await applyReaction ( client , messageID , panel , userID , false )) revoked ++ 59} 60console . log ( `panel ${ messageID } : sync complete, granted ${ granted } , revoked ${ revoked } ` ) 61} catch ( error ) { 62if ( 63! ( error instanceof DiscordHTTPError || error instanceof DiscordRESTError ) || 64error . status !== 404 65) { 66console . error ( `panel ${ messageID } : startup sync failed:` , error ) 67continue 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 71const channel = await client . rest . channels . get ( panel . channelID ). catch (() => undefined ) 72if ( channel !== undefined ) { 73deletePanel ( messageID ) 74console . log ( `panel ${ messageID } : message no longer exists, unregistered` ) 75} else { 76console . warn ( `panel ${ messageID } : message or channel inaccessible, keeping panel` ) 77} 78} 79} 80}