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
f13db32
main
1import { createHash } from 'node:crypto' 2import { setTimeout as delay } from 'node:timers/promises' 3import { 4ChannelTypes , 5DiscordHTTPError , 6DiscordRESTError , 7MessageTypes , 8ReactionType , 9TextableGuildChannelsWithoutThreadsTypes , 10type Client , 11type CreateMessageOptions , 12type EmbedField , 13type Message , 14type PossiblyUncachedMessage 15} from 'oceanic.js' 16import { reactionKey } from './emoji.ts' 17import { retryDiscord } from './retry.ts' 18import { 19loadStarboard , 20saveStarboard , 21type Backfill , 22type ScanJob , 23type StarboardState 24} from './starboard-store.ts' 25 26export const STARBOARD_GUILD = '1244870846556803074' 27export const STARBOARD_CHANNEL = '1559594091497660507' 28const LEGACY_CHANNEL = '1559288341717844088' 29const EMOJI = new Set ([ '⭐' , '🔥' , '😭' , '🍅' ]) 30const THRESHOLD = 5 31 32function missingMessage ( error :unknown ) :boolean { 33return error instanceof DiscordRESTError && error . code === 10008 34} 35 36function inaccessible ( error :unknown ) :boolean { 37return ( 38( error instanceof DiscordRESTError || error instanceof DiscordHTTPError ) && 39( error . status === 403 || error . status === 404 ) 40) 41} 42 43function truncate ( text :string , limit :number ) :string { 44return text . length <= limit ?text :` ${ text . slice ( 0 , limit - 1 )} …` 45} 46 47function newBackfill () :Backfill { 48return { 49phase :'collecting' , candidates :{}, pending :{}, processed :0 , 50startedAt :new Date (). toISOString (), initialized :false , 51jobs :[], channels :[], scanned :0 , skipped :[] 52} 53} 54 55export class Starboard { 56private locks = new Map < string , Promise < void >>() 57private timers = new Map < string , ReturnType < typeof setTimeout >>() 58private worker ?:Promise < void > 59private client :Client 60readonly state :StarboardState 61private save :( state :StarboardState ) => void 62private pageDelay :number 63private excludedChannels :Set < string > 64 65constructor ( 66client :Client , 67state :StarboardState = loadStarboard (), 68save :( state :StarboardState ) => void = saveStarboard , 69pageDelay = 250 , 70excludedChannelIDs :readonly string [] = ( process . env . STARBOARD_EXCLUDED_CHANNEL_IDS ?? '1308583779157151804' ) 71. split ( / [,\s]+ / ). filter ( Boolean ) 72) { 73this . client = client 74this . state = state 75this . save = save 76this . pageDelay = pageDelay 77if ( excludedChannelIDs . some (( id ) => ! /^\d{17,20}$ / . test ( id ))) { 78throw new Error ( 'STARBOARD_EXCLUDED_CHANNEL_IDS must contain channel IDs separated by commas or whitespace' ) 79} 80this . excludedChannels = new Set ( excludedChannelIDs ) 81 82const destinationID = state . destinationID ?? LEGACY_CHANNEL 83if ( destinationID !== STARBOARD_CHANNEL ) { 84if ( Object . keys ( state . posts ). length > 0 || state . backfill !== undefined ) { 85const archived = ( state . archivedBoards ??= {}) 86archived [ destinationID ] = { posts :state . posts , backfill :state . backfill } 87} 88const previous = state . archivedBoards ?.[ STARBOARD_CHANNEL ] 89const scan = state . backfill 90if ( scan ?. phase !== undefined ) { 91state . backfill = { 92 ...structuredClone ( scan ), 93candidates :{ 94 ...structuredClone ( scan . candidates ), 95 ...structuredClone ( scan . pending ), 96 ...Object . fromEntries ( Object . entries ( state . posts ) 97. filter (([, post ]) => ! post . deleted ) 98. map (([ id , post ]) => [ id , { channelID :post . channelID }])) 99}, 100pending :{}, 101processed :0 , 102startedAt :new Date (). toISOString () 103} 104delete state . backfill . completedAt 105delete state . backfill . error 106} else { 107delete state . backfill 108} 109state . posts = previous ?. posts ?? {} 110delete state . archivedBoards ?.[ STARBOARD_CHANNEL ] 111} 112state . destinationID = STARBOARD_CHANNEL 113if ( state . backfill ?. phase === undefined ) state . backfill = newBackfill () 114this . save ( state ) 115} 116 117private isExcluded ( channelID :string ) :boolean { 118if ( this . excludedChannels . has ( channelID ) || channelID === STARBOARD_CHANNEL || this . state . archivedBoards ?.[ channelID ] !== undefined ) return true 119const channel = this . client . getChannel ( channelID ) 120return channel !== undefined && 'parentID' in channel && channel . parentID !== null && this . isExcluded ( channel . parentID ) 121} 122 123private defer ( channelID :string , id :string ) :boolean { 124const scan = this . state . backfill 125if ( scan === undefined || scan . completedAt !== undefined ) return false 126const queue = scan . phase === 'collecting' ?scan . candidates :scan . pending 127queue [ id ] = { channelID} 128this . save ( this . state ) 129return true 130} 131 132async start () :Promise < void > { 133const destination = await this . client . rest . channels . get ( STARBOARD_CHANNEL ) 134if ( ! ( 'guildID' in destination ) || destination . guildID !== STARBOARD_GUILD ) { 135throw new Error ( 'starboard destination is not in the configured guild' ) 136} 137for ( const [ id , post ] of Object . entries ( this . state . posts )) { 138try { 139if ( post . deleted ) await this . remove ( id ) 140else await this . sync ( post . channelID , id , { force :true }) 141} catch ( error ) { 142console . error ( `starboard ${ id } : startup reconciliation failed:` , error ) 143} 144} 145if ( ! this . state . backfill ?. completedAt ) this . startBackfill () 146} 147 148schedule ( 149message :{ id :string ; channelID :string ; guildID ?:string | null }, 150emoji ?:{ id :string | null ; name :string | null } 151) :void { 152const channel = this . client . getChannel ( message . channelID ) 153const guildID = message . guildID ?? ( channel !== undefined && 'guildID' in channel ?channel . guildID :undefined ) 154if ( this . isExcluded ( message . channelID ) || guildID !== STARBOARD_GUILD ) return 155if ( emoji !== undefined && ! EMOJI . has ( reactionKey ( emoji ) ?? '' )) return 156const previous = this . timers . get ( message . id ) 157if ( previous !== undefined ) clearTimeout ( previous ) 158this . timers . set ( message . id , setTimeout (() => { 159this . timers . delete ( message . id ) 160void retryDiscord (() => this . sync ( message . channelID , message . id ), `starboard ${ message . id } ` ). catch (( error ) => { 161console . error ( `starboard ${ message . id } : update failed:` , error ) 162}) 163}, 750 )) 164} 165 166async deleted ( message :PossiblyUncachedMessage ) :Promise < void > { 167const scan = this . state . backfill 168if ( scan ?. candidates [ message . id ] !== undefined || scan ?. pending [ message . id ] !== undefined ) { 169delete scan . candidates [ message . id ] 170delete scan . pending [ message . id ] 171this . save ( this . state ) 172} 173if ( this . state . posts [ message . id ] === undefined && ! this . locks . has ( message . id )) return 174await retryDiscord (() => this . remove ( message . id ), `starboard ${ message . id } deletion` ) 175} 176 177private async exclusive ( id :string , action :() => Promise < void >) :Promise < void > { 178const work = ( this . locks . get ( id ) ?? Promise . resolve ()). catch (() => {}). then ( action ) 179this . locks . set ( id , work ) 180try { 181await work 182} finally { 183if ( this . locks . get ( id ) === work ) this . locks . delete ( id ) 184} 185} 186 187private async remove ( id :string ) :Promise < void > { 188await this . exclusive ( id , async () => { 189const post = this . state . posts [ id ] 190if ( post === undefined ) return 191post . deleted = true 192this . save ( this . state ) 193try { 194await this . client . rest . channels . deleteMessage ( STARBOARD_CHANNEL , post . messageID ) 195} catch ( error ) { 196if ( ! missingMessage ( error )) throw error 197} 198delete this . state . posts [ id ] 199this . save ( this . state ) 200}) 201} 202 203async sync ( 204channelID :string , 205id :string , 206options :{ snapshot ?:Message ; force ?:boolean ; publish ?:boolean } = {} 207) :Promise < void > { 208if ( this . isExcluded ( channelID )) return 209const { snapshot} = options 210// A queued history snapshot may predate a live edit or deletion. 211const fetch = snapshot === undefined || this . locks . has ( id ) || this . state . posts [ id ] !== undefined 212let gone = false 213await this . exclusive ( id , async () => { 214const post = this . state . posts [ id ] 215if ( post ?. deleted ) return 216let message :Message 217try { 218const source = this . client . getChannel ( channelID ) ?? await this . client . rest . channels . get ( channelID ) 219if ( ! ( 'guildID' in source ) || source . guildID !== STARBOARD_GUILD ) return 220if ( source . parentID !== null && this . isExcluded ( source . parentID )) return 221message = fetch ?await this . client . rest . channels . getMessage ( channelID , id ) :snapshot ! 222const reactions = message . reactions . filter (( r ) => EMOJI . has ( reactionKey ( r . emoji ) ?? '' )) 223if ( post === undefined && reactions . reduce (( sum , r ) => sum + r . count , 0 ) < THRESHOLD ) return 224if ( post === undefined && ! options . publish && this . defer ( channelID , id )) return 225 226const voters = new Set < string >() 227const breakdown = new Map ([ ...EMOJI ]. map (( emoji ) => [ emoji , new Set < string >()])) 228for ( const reaction of reactions ) { 229const emojiVoters = breakdown . get ( reactionKey ( reaction . emoji ) ! ) ! 230for ( const [ type , count ] of [ 231[ ReactionType . NORMAL , reaction . countDetails . normal ], 232[ ReactionType . SUPER , reaction . countDetails . burst ] 233] as const ) { 234if ( count === 0 || reaction . emoji . name === null ) continue 235const users = await this . client . rest . channels . getReactions ( channelID , id , reaction . emoji . name , { 236limit :Infinity , 237 type 238}) 239for ( const user of users ) { 240if ( ! user . bot && user . id !== message . author . id ) { 241voters . add ( user . id ) 242emojiVoters . add ( user . id ) 243} 244} 245} 246} 247if ( post === undefined && voters . size < THRESHOLD ) return 248 249const attachments = [ ...message . attachments . values ()] 250const image = attachments . find (( a ) => 251a . contentType ?. startsWith ( 'image/' ) && ! a . filename . startsWith ( 'SPOILER_' ) 252) 253const links :string [] = [] 254for ( const attachment of attachments ) { 255const label = truncate ( attachment . filename . replace ( / [\\[\]] / g , '\\$&' ), 100 ) 256const attachmentLink = `[ ${ label } ]( ${ attachment . url } )` 257const link = attachment . filename . startsWith ( 'SPOILER_' ) ?`|| ${ attachmentLink } ||` :attachmentLink 258if ([ ...links , link ]. join ( '\n' ). length > 980 ) { 259links . push ( 'More attachments in the original message.' ) 260break 261} 262links . push ( link ) 263} 264const fields :EmbedField [] = [] 265const reference = message . messageReference 266if ( message . type === MessageTypes . REPLY && reference ?. messageID !== undefined ) { 267const replyChannelID = reference . channelID ?? channelID 268const replyGuildID = reference . guildID ?? STARBOARD_GUILD 269let repliedTo = message . referencedMessage 270if ( repliedTo === undefined ) { 271try { 272repliedTo = await this . client . rest . channels . getMessage ( replyChannelID , reference . messageID ) 273} catch ( error ) { 274if ( ! inaccessible ( error )) throw error 275repliedTo = null 276} 277} 278const replyURL = `https://discord.com/channels/ ${ replyGuildID } / ${ replyChannelID } / ${ reference . messageID } ` 279const preview = repliedTo === null 280 ?'[Message deleted or unavailable.]' 281 :repliedTo . content || ( repliedTo . attachments . size > 0 282 ?`[ ${ repliedTo . attachments . size } attachment(s); view the message for details.]` 283 :'[No text; view the message for details.]' ) 284fields . push ({ 285name :repliedTo === null ?'Replying to' :truncate ( `Replying to ${ repliedTo . author . globalName ?? repliedTo . author . username } ` , 100 ), 286// Leave room for attachments and the original text within the 6,000-character embed budget. 287value :truncate ( ` ${ truncate ( preview , 300 )} \n[View replied-to message]( ${ replyURL } )` , 450 ) 288}) 289} 290if ( links . length > 0 ) fields . push ({ name :'Attachments' , value :links . join ( '\n' ) }) 291const url = `https://discord.com/channels/ ${ STARBOARD_GUILD } / ${ channelID } / ${ id } ` 292const reactionsLine = [ ...breakdown ] 293. filter (([, users ]) => users . size > 0 ) 294. map (([ emoji , users ]) => ` ${ emoji } ** ${ users . size } **` ) 295. join ( ' ' ) 296const payload :CreateMessageOptions = { 297content :[ reactionsLine , url ]. filter ( Boolean ). join ( '\n' ), 298allowedMentions :{ everyone :false , users :false , roles :false , repliedUser :false }, 299embeds :[{ 300color :0xffcc55 , 301author :{ 302name :truncate ( message . author . globalName ?? message . author . username , 256 ), 303iconURL :message . author . avatarURL (), 304url :`https://discord.com/users/ ${ message . author . id } ` 305}, 306description :truncate ( message . content || '[No text; see the original message.]' , 4096 ), 307timestamp :message . timestamp . toISOString (), 308 fields, 309image :image === undefined ?undefined :{ url :image . url } 310}] 311} 312const signature = createHash ( 'sha256' ). update ( JSON . stringify ( payload )). digest ( 'hex' ) 313if ( post !== undefined ) { 314if ( ! options . force && post . signature === signature ) return 315try { 316await this . client . rest . channels . editMessage ( STARBOARD_CHANNEL , post . messageID , payload ) 317post . signature = signature 318this . save ( this . state ) 319return 320} catch ( error ) { 321if ( ! missingMessage ( error )) throw error 322} 323} 324if ( ! options . publish && this . defer ( channelID , id )) return 325const copy = await this . client . rest . channels . createMessage ( STARBOARD_CHANNEL , { 326 ...payload , 327nonce :createHash ( 'sha256' ). update ( ` ${ STARBOARD_CHANNEL } : ${ id } ` ). digest ( 'hex' ). slice ( 0 , 25 ), 328enforceNonce :true 329}) 330this . state . posts [ id ] = { channelID, messageID :copy . id , signature} 331this . save ( this . state ) 332} catch ( error ) { 333if ( ! missingMessage ( error )) throw error 334gone = true 335} 336}) 337if ( gone ) await this . remove ( id ) 338} 339 340status () :string { 341const scan = this . state . backfill 342const phase = scan ?. completedAt !== undefined ?'complete' 343 :this . worker !== undefined ?scan ?. phase :`paused ( ${ scan ?. phase ?? 'not started' } )` 344return [ 345`destination: <# ${ STARBOARD_CHANNEL } >` , 346`excluded channels: ${[ ... this . excludedChannels ]. map (( id ) => `<# ${ id } >` ). join ( ', ' ) || 'none' } (also excludes starboard destinations and child channels)` , 347` ${[ ... EMOJI ]. join ( ' ' )} · ${ THRESHOLD } unique people · bot/self votes excluded` , 348` ${ Object . keys ( this . state . posts ). length } reposts tracked` , 349`backfill: ${ scan === undefined ? 'not started' : phase } ` , 350 ...( scan === undefined ?[] :[ 351` ${ scan . scanned } messages scanned · ${ scan . channels . length } source channels/threads discovered · ${ scan . jobs . length } jobs remaining` , 352` ${ Object . keys ( scan . candidates ). length } history candidates queued · ${ Object . keys ( scan . pending ). length } live candidates waiting · ${ scan . processed } candidates processed` , 353` ${ scan . skipped . length } inaccessible history/thread-list jobs skipped (details in bot logs)` , 354 ...( scan . error === undefined ?[] :[ `last error: ${ truncate ( scan . error , 500 )} ` ]) 355]) 356]. join ( '\n' ) 357} 358 359startBackfill () :boolean { 360if ( this . worker !== undefined ) return false 361if ( this . state . backfill === undefined || this . state . backfill . completedAt !== undefined ) { 362this . state . backfill = newBackfill () 363} 364delete this . state . backfill . error 365this . save ( this . state ) 366this . worker = retryDiscord (() => this . runBackfill (), 'starboard backfill' ). catch (( error :unknown ) => { 367this . state . backfill ! . error = String ( error ) 368this . save ( this . state ) 369console . error ( 'starboard backfill paused; use /starboard backfill to resume:' , error ) 370}). finally (() => { this . worker = undefined }) 371return true 372} 373 374async waitForBackfill () :Promise < void > { 375await this . worker 376} 377 378private enqueue ( scan :Backfill , channelID :string ) :void { 379if ( this . isExcluded ( channelID ) || scan . channels . includes ( channelID )) return 380scan . channels . push ( channelID ) 381scan . jobs . push ({ kind :'messages' , channelID}) 382} 383 384private async runBackfill () :Promise < void > { 385const scan = this . state . backfill ! 386if ( ! scan . initialized ) { 387const channels = await this . client . rest . guilds . getChannels ( STARBOARD_GUILD ) 388const active = await this . client . rest . guilds . getActiveThreads ( STARBOARD_GUILD ) 389for ( const channel of channels ) { 390if ( this . isExcluded ( channel . id ) || ( channel . parentID !== null && this . isExcluded ( channel . parentID ))) continue 391if ( TextableGuildChannelsWithoutThreadsTypes . some (( type ) => type === channel . type )) this . enqueue ( scan , channel . id ) 392if ([ ChannelTypes . GUILD_TEXT , ChannelTypes . GUILD_ANNOUNCEMENT , ChannelTypes . GUILD_FORUM , ChannelTypes . GUILD_MEDIA ]. includes ( channel . type )) { 393scan . jobs . push ({ kind :'publicThreads' , channelID :channel . id }) 394if ( channel . type === ChannelTypes . GUILD_TEXT ) { 395scan . jobs . push ({ kind :'privateThreads' , channelID :channel . id }) 396scan . jobs . push ({ kind :'joinedThreads' , channelID :channel . id }) 397} 398} 399} 400for ( const thread of active . threads ) { 401if ( ! this . isExcluded ( thread . parentID )) this . enqueue ( scan , thread . id ) 402} 403scan . initialized = true 404this . save ( this . state ) 405} 406console . log ( `starboard backfill: ${ scan . phase } , ${ scan . jobs . length } history jobs remaining` ) 407if ( scan . phase === 'collecting' ) { 408while ( scan . jobs . length > 0 ) { 409const job = scan . jobs [ 0 ] ! 410await this . scanPage ( scan , job ) 411this . save ( this . state ) 412await delay ( this . pageDelay ) 413} 414scan . phase = 'posting' 415this . save ( this . state ) 416console . log ( `starboard backfill: posting ${ Object . keys ( scan . candidates ). length } candidates oldest-first` ) 417} 418for ( const queue of [ scan . candidates , scan . pending ]) { 419while ( Object . keys ( queue ). length > 0 ) { 420const ids = Object . keys ( queue ). sort (( a , b ) => BigInt ( a ) < BigInt ( b ) ?- 1 :BigInt ( a ) > BigInt ( b ) ?1 :0 ) 421for ( const id of ids ) { 422const candidate = queue [ id ] 423if ( candidate === undefined ) continue 424await this . sync ( candidate . channelID , id , { publish :true }) 425if ( queue [ id ] === candidate ) delete queue [ id ] 426scan . processed ++ 427this . save ( this . state ) 428await delay ( this . pageDelay ) 429} 430} 431} 432scan . completedAt = new Date (). toISOString () 433this . save ( this . state ) 434console . log ( `starboard backfill complete: ${ scan . scanned } messages, ${ scan . skipped . length } jobs skipped` ) 435} 436 437private async scanPage ( scan :Backfill , job :ScanJob ) :Promise < void > { 438if ( this . isExcluded ( job . channelID )) { 439scan . jobs . shift () 440return 441} 442if ( job . kind === 'messages' ) { 443let messages :Message [] 444try { 445const source = this . client . getChannel ( job . channelID ) ?? await this . client . rest . channels . get ( job . channelID ) 446if ( 'parentID' in source && source . parentID !== null && this . isExcluded ( source . parentID )) { 447scan . jobs . shift () 448return 449} 450messages = await this . client . rest . channels . getMessages ( job . channelID , { before :job . before , limit :100 }) 451} catch ( error ) { 452if ( ! inaccessible ( error )) throw error 453scan . skipped . push ( ` ${ job . kind } : ${ job . channelID } ` ) 454console . warn ( `starboard backfill: cannot read history in ${ job . channelID } :` , error ) 455scan . jobs . shift () 456return 457} 458for ( const message of messages ) await this . sync ( job . channelID , message . id , { snapshot :message }) 459scan . scanned += messages . length 460if ( messages . length < 100 ) scan . jobs . shift () 461else job . before = messages . at ( - 1 ) ! . id 462} else { 463let archived 464try { 465const options = { before :job . before , limit :100 } 466archived = job . kind === 'publicThreads' 467 ?await this . client . rest . channels . getPublicArchivedThreads ( job . channelID , options ) 468 :job . kind === 'privateThreads' 469 ?await this . client . rest . channels . getPrivateArchivedThreads ( job . channelID , options ) 470 :await this . client . rest . channels . getPrivateJoinedArchivedThreads ( job . channelID , options ) 471} catch ( error ) { 472if ( ! inaccessible ( error )) throw error 473scan . skipped . push ( ` ${ job . kind } : ${ job . channelID } ` ) 474console . warn ( `starboard backfill: cannot list ${ job . kind } in ${ job . channelID } :` , error ) 475scan . jobs . shift () 476return 477} 478for ( const thread of archived . threads ) this . enqueue ( scan , thread . id ) 479if ( ! archived . hasMore ) scan . jobs . shift () 480else { 481const last = archived . threads . at ( - 1 ) 482if ( last === undefined ) throw new Error ( `empty archived thread page with hasMore in ${ job . channelID } ` ) 483// Joined-private pagination uses snowflakes, unlike the other archive endpoints. 484const before = job . kind === 'joinedThreads' ?last . id :last . threadMetadata . archiveTimestamp . toISOString () 485if ( before === job . before ) throw new Error ( `archived thread cursor did not advance in ${ job . channelID } ` ) 486job . before = before 487} 488} 489} 490}