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 assert from 'node:assert/strict' 2import { mock , test } from 'node:test' 3import { 4Client , 5ChannelTypes , 6DiscordRESTError , 7Message , 8MessageTypes , 9PublicThreadChannel , 10ReactionType , 11TextChannel , 12User , 13type AnyGuildChannel , 14type CreateMessageOptions , 15type RawMessage , 16type RawMessageReaction , 17type RawUser 18} from 'oceanic.js' 19import { STARBOARD_CHANNEL , STARBOARD_GUILD , Starboard } from '../src/starboard.ts' 20import type { Backfill , StarboardState } from '../src/starboard-store.ts' 21 22const SOURCE = '1244870846556803100' 23const AUTHOR = '1244870846556803200' 24const ID = '1244870846556803300' 25 26function rawUser ( id :string , bot = false ) :RawUser { 27return { id, bot, username :`user- ${ id } ` , global_name :null , discriminator :'0' , avatar :null , public_flags :0 } 28} 29 30function reaction ( name :string , normal :number , burst = 0 , customID :string | null = null ) :RawMessageReaction { 31return { 32emoji :{ name, id :customID }, count :normal + burst , 33count_details :{ normal, burst}, burst_colors :[], me :false , me_burst :false 34} 35} 36 37function completedBackfill () :Backfill { 38return { 39phase :'posting' , candidates :{}, pending :{}, processed :0 , 40startedAt :'2024-01-01' , completedAt :'2024-01-02' , initialized :true , 41channels :[], jobs :[], scanned :0 , skipped :[] 42} 43} 44 45function fixture ( state :StarboardState = { 46destinationID :STARBOARD_CHANNEL , posts :{}, backfill :completedBackfill () 47}) { 48const client = new Client () 49const channels = new Map < string , AnyGuildChannel >() 50const history = new Map < string , Message []>() 51const votes = new Map < string , User []>() 52const created :Array <{ id :string ; channelID :string ; payload :CreateMessageOptions }> = [] 53const edited :Array <{ id :string ; payload :CreateMessageOptions }> = [] 54const deleted :string [] = [] 55let persisted = structuredClone ( state ) 56const starboard = new Starboard ( client , state , ( value ) => { persisted = structuredClone ( value ) }, 0 ) 57 58function channel ( id :string , guildID = STARBOARD_GUILD ) :TextChannel { 59const value = new TextChannel ({ 60 id, guild_id :guildID , type :ChannelTypes . GUILD_TEXT , name :id , parent_id :null , 61default_auto_archive_duration :1440 , last_message_id :null , last_pin_timestamp :null , 62rate_limit_per_user :0 , topic :null , nsfw :false , permission_overwrites :[], position :0 63}, client ) 64channels . set ( id , value ) 65return value 66} 67 68function message ( overrides :Partial < RawMessage > = {}) :Message { 69return new Message ({ 70id :ID , channel_id :SOURCE , content :'hello' , author :rawUser ( AUTHOR ), 71attachments :[], embeds :[], edited_timestamp :null , mention_everyone :false , 72mention_roles :[], mentions :[], pinned :false , tts :false , 73timestamp :'2024-01-01T00:00:00.000Z' , type :MessageTypes . DEFAULT , 74reactions :[ reaction ( '⭐' , 5 )], ...overrides 75}, client ) 76} 77 78function thread ( id :string , parentID = SOURCE , archivedAt = '2024-01-01T00:00:00.000Z' ) :PublicThreadChannel { 79const value = new PublicThreadChannel ({ 80 id, guild_id :STARBOARD_GUILD , parent_id :parentID , type :ChannelTypes . PUBLIC_THREAD , 81name :id , owner_id :AUTHOR , last_message_id :null , message_count :0 , member_count :0 , 82rate_limit_per_user :0 , flags :0 , total_message_sent :0 , newly_created :false , applied_tags :[], 83member :{ flags :0 , join_timestamp :archivedAt }, 84thread_metadata :{ archive_timestamp :archivedAt , archived :true , auto_archive_duration :1440 , locked :false } 85}, client ) 86channels . set ( id , value ) 87return value 88} 89 90channel ( SOURCE ) 91channel ( STARBOARD_CHANNEL ) 92mock . getter ( client , 'user' , () => new User ( rawUser ( '1244870846556803900' , true ), client )) 93mock . method ( client , 'getChannel' , ( id :string ) => channels . get ( id )) 94mock . method ( client . rest . channels , 'get' , async ( id :string ) => { 95const value = channels . get ( id ) 96if ( value === undefined ) throw restError ( 404 , 10003 ) 97return value 98}) 99mock . method ( client . rest . channels , 'getMessage' , async ( channelID :string , id :string ) => { 100const value = history . get ( channelID )?. find (( m ) => m . id === id ) 101if ( value === undefined ) throw restError ( 404 , 10008 ) 102return value 103}) 104mock . method ( client . rest . channels , 'getMessages' , async ( channelID :string , options :{ before ?:string ; limit :number }) => 105( history . get ( channelID ) ?? []). filter (( m ) => options . before === undefined || BigInt ( m . id ) < BigInt ( options . before )) 106. sort (( a , b ) => BigInt ( a . id ) > BigInt ( b . id ) ?- 1 :1 ). slice ( 0 , options . limit ) 107) 108mock . method ( client . rest . channels , 'getReactions' , async ( _channelID :string , _id :string , emoji :string , options :{ type :ReactionType }) => 109votes . get ( ` ${ emoji } : ${ options . type } ` ) ?? [] 110) 111mock . method ( client . rest . channels , 'createMessage' , async ( channelID :string , payload :CreateMessageOptions ) => { 112const id = String ( 1559288341717845000n + BigInt ( created . length )) 113created . push ({ channelID, payload, id}) 114return message ({ id, channel_id :channelID , reactions :[] }) 115}) 116mock . method ( client . rest . channels , 'editMessage' , async ( _channelID :string , id :string , payload :CreateMessageOptions ) => { 117edited . push ({ id, payload}) 118return message ({ id, channel_id :STARBOARD_CHANNEL }) 119}) 120mock . method ( client . rest . channels , 'deleteMessage' , async ( _channelID :string , id :string ) => { deleted . push ( id ) }) 121mock . method ( client . rest . guilds , 'getChannels' , async () => [ ...channels . values ()]. filter (( c ) => c . type === ChannelTypes . GUILD_TEXT )) 122mock . method ( client . rest . guilds , 'getActiveThreads' , async () => ({ threads :[], members :[] })) 123mock . method ( client . rest . channels , 'getPublicArchivedThreads' , async () => ({ threads :[], members :[], hasMore :false })) 124mock . method ( client . rest . channels , 'getPrivateArchivedThreads' , async () => ({ threads :[], members :[], hasMore :false })) 125mock . method ( client . rest . channels , 'getPrivateJoinedArchivedThreads' , async () => ({ threads :[], members :[], hasMore :false })) 126votes . set ( '⭐:0' , Array . from ({ length :5 }, ( _ , i ) => new User ( rawUser ( String ( 1244870846556803400n + BigInt ( i ))), client ))) 127history . set ( SOURCE , [ message ()]) 128 129return { client, starboard, state, channels, history, votes, created, edited, deleted, channel, message, thread, checkpoint :() => persisted } 130} 131 132function restError ( status :number , code :number ) :DiscordRESTError { 133const response = new Response ( '' , { status}) 134Object . defineProperty ( response , 'url' , { value :'https://discord.com/api/v10/channels/test/messages/test' }) 135return new DiscordRESTError ( response , { code, message :'test error' }, 'GET' ) 136} 137 138test ( 'counts unique humans across emoji and normal/super reactions, excluding author and bots' , async () => { 139const f = fixture () 140const humans = f . votes . get ( '⭐:0' ) ! 141const bot = new User ( rawUser ( '1244870846556803999' , true ), f . client ) 142const author = new User ( rawUser ( AUTHOR ), f . client ) 143f . votes . set ( '⭐:0' , [ ...humans . slice ( 0 , 3 ), bot , author ]) 144f . votes . set ( '🔥:0' , humans . slice ( 1 , 4 )) 145f . votes . set ( '😭:1' , humans . slice ( 0 , 4 )) 146f . votes . set ( '⭐:1' , [ humans [ 0 ] ! ]) 147f . history . set ( SOURCE , [ f . message ({ reactions :[ reaction ( '⭐️' , 5 , 1 ), reaction ( '🔥' , 3 ), reaction ( '😭' , 0 , 4 ), reaction ( '⭐' , 100 , 0 , '123' )] })]) 148// Variation selectors are preserved in API requests, not just comparison keys. 149f . votes . set ( '⭐️:0' , f . votes . get ( '⭐:0' ) ! ) 150f . votes . set ( '⭐️:1' , f . votes . get ( '⭐:1' ) ! ) 151await f . starboard . sync ( SOURCE , ID ) 152assert . equal ( f . created . length , 0 ) 153f . votes . set ( '🔥:0' , humans ) 154await f . starboard . sync ( SOURCE , ID ) 155assert . equal ( f . created . length , 1 ) 156assert . equal ( f . created [ 0 ] ! . payload . content , `⭐ **3** 🔥 **5** 😭 **4**\nhttps://discord.com/channels/ ${ STARBOARD_GUILD } / ${ SOURCE } / ${ ID } ` ) 157}) 158 159test ( 'debounces live reactions with missing REST guild IDs and handles bulk reaction removal' , async ( t ) => { 160const f = fixture () 161t . mock . timers . enable ({ apis :[ 'setTimeout' ] }) 162const original = f . history . get ( SOURCE ) ! [ 0 ] ! 163assert . equal ( original . guildID , null ) 164f . starboard . schedule ( original , { name :'💀' , id :null }) 165t . mock . timers . tick ( 750 ) 166await new Promise < void >(( resolve ) => setImmediate ( resolve )) 167assert . equal ( f . created . length , 0 ) 168for ( let i = 0 ; i < 10 ; i ++ ) f . starboard . schedule ( original , { name :'⭐' , id :null }) 169t . mock . timers . tick ( 750 ) 170await new Promise < void >(( resolve ) => setImmediate ( resolve )) 171assert . equal ( f . created . length , 1 ) 172f . history . set ( SOURCE , [ f . message ({ reactions :[] })]) 173f . starboard . schedule ( original ) 174t . mock . timers . tick ( 750 ) 175await new Promise < void >(( resolve ) => setImmediate ( resolve )) 176assert . equal ( f . edited . at ( - 1 ) ! . payload . content , `https://discord.com/channels/ ${ STARBOARD_GUILD } / ${ SOURCE } / ${ ID } ` ) 177}) 178 179test ( 'tomato reactions trigger live reposts on their own or combined with other emoji' , async ( t ) => { 180t . mock . timers . enable ({ apis :[ 'setTimeout' ] }) 181for ( const mixed of [ false , true ]) { 182const f = fixture () 183const humans = f . votes . get ( '⭐:0' ) ! 184f . votes . set ( '⭐:0' , humans . slice ( 0 , 3 )) 185f . votes . set ( '🍅:0' , mixed ?humans . slice ( 2 ) :humans ) 186const original = f . message ({ 187reactions :mixed ?[ reaction ( '⭐' , 3 ), reaction ( '🍅' , 3 )] :[ reaction ( '🍅' , 5 )] 188}) 189f . history . set ( SOURCE , [ original ]) 190f . starboard . schedule ( original , { name :'🍅' , id :null }) 191t . mock . timers . tick ( 750 ) 192await new Promise < void >(( resolve ) => setImmediate ( resolve )) 193assert . equal ( f . created . length , 1 ) 194const summary = mixed ?'⭐ **3** 🍅 **3**' :'🍅 **5**' 195assert . equal ( f . created [ 0 ] ! . payload . content , ` ${ summary } \nhttps://discord.com/channels/ ${ STARBOARD_GUILD } / ${ SOURCE } / ${ ID } ` ) 196assert . match ( f . starboard . status (), / 🍅 / ) 197} 198}) 199 200test ( 'hides zero-count emoji and puts the unmasked message URL below the reactions' , async () => { 201const f = fixture () 202f . history . set ( SOURCE , [ f . message ({ reactions :[ reaction ( '😭' , 5 )] })]) 203f . votes . set ( '😭:0' , f . votes . get ( '⭐:0' ) ! ) 204await f . starboard . sync ( SOURCE , ID ) 205assert . equal ( f . created [ 0 ] ! . payload . content , `😭 **5**\nhttps://discord.com/channels/ ${ STARBOARD_GUILD } / ${ SOURCE } / ${ ID } ` ) 206}) 207 208test ( 'includes reply author, preview and link from the embedded referenced message' , async () => { 209const f = fixture () 210const original = f . message ({ id :String ( BigInt ( ID ) - 1n ), content :'the setup' , author :rawUser ( '1244870846556803201' ) }) 211const highlight = f . message ({ type :MessageTypes . REPLY , content :'the punchline' }) 212highlight . messageReference = { messageID :original . id , channelID :SOURCE , guildID :STARBOARD_GUILD } 213highlight . referencedMessage = original 214f . history . set ( SOURCE , [ highlight ]) 215await f . starboard . sync ( SOURCE , ID ) 216const embed = f . created [ 0 ] ! . payload . embeds ! [ 0 ] ! 217assert . equal ( embed . description , 'the punchline' ) 218const reply = embed . fields ! . find (( field ) => field . name . startsWith ( 'Replying to' )) ! 219assert . ok ( reply . name . includes ( original . author . username )) 220assert . ok ( reply . value . includes ( 'the setup' )) 221assert . ok ( reply . value . includes ( `https://discord.com/channels/ ${ STARBOARD_GUILD } / ${ SOURCE } / ${ original . id } ` )) 222}) 223 224test ( 'fetches an omitted reply reference and stays within the total embed text limit' , async () => { 225const f = fixture () 226const original = f . message ({ 227id :String ( BigInt ( ID ) - 1n ), content :'context' . repeat ( 1000 ), 228author :{ ...rawUser ( '1244870846556803201' ), global_name :'name' . repeat ( 100 ) } 229}) 230const highlight = f . message ({ 231type :MessageTypes . REPLY , content :'body' . repeat ( 2000 ), 232author :{ ...rawUser ( AUTHOR ), global_name :'name' . repeat ( 100 ) }, 233attachments :Array . from ({ length :10 }, ( _ , i ) => ({ 234id :String ( 1244870846556803500n + BigInt ( i )), filename :` ${ 'file' . repeat ( 50 )} ${ i } .txt` , 235size :100 , url :`https://cdn.discordapp.com/ ${ 'x' . repeat ( 200 )} / ${ i } ` , 236proxy_url :`https://media.discordapp.net/ ${ i } ` 237})) 238}) 239highlight . messageReference = { messageID :original . id , channelID :SOURCE , guildID :STARBOARD_GUILD } 240f . history . set ( SOURCE , [ highlight , original ]) 241await f . starboard . sync ( SOURCE , ID ) 242const embed = f . created [ 0 ] ! . payload . embeds ! [ 0 ] ! 243assert . ok ( embed . fields ! . some (( field ) => field . value . includes ( 'context' ))) 244const length = ( embed . author ?. name ?. length ?? 0 ) + ( embed . description ?. length ?? 0 ) + 245embed . fields ! . reduce (( sum , field ) => sum + field . name . length + field . value . length , 0 ) 246assert . ok ( length <= 6000 ) 247assert . ok ( embed . fields ! . every (( field ) => field . name . length <= 256 && field . value . length <= 1024 )) 248}) 249 250test ( 'a deleted or inaccessible reply target does not prevent reposting the reply' , async () => { 251for ( const status of [ 403 , 404 ]) { 252const f = fixture () 253const highlight = f . message ({ type :MessageTypes . REPLY }) 254highlight . messageReference = { messageID :String ( BigInt ( ID ) - 1n ), channelID :SOURCE , guildID :STARBOARD_GUILD } 255f . history . set ( SOURCE , [ highlight ]) 256const getMessage = f . client . rest . channels . getMessage . bind ( f . client . rest . channels ) 257mock . method ( f . client . rest . channels , 'getMessage' , async ( channelID :string , id :string ) => { 258if ( id !== ID ) throw restError ( status , status === 404 ?10008 :50001 ) 259return getMessage ( channelID , id ) 260}) 261await f . starboard . sync ( SOURCE , ID ) 262assert . equal ( f . created . length , 1 ) 263assert . ok ( f . created [ 0 ] ! . payload . embeds ! [ 0 ] ! . fields ! . some (( field ) => field . value . includes ( 'unavailable' ))) 264assert . equal ( f . deleted . length , 0 ) 265} 266}) 267 268test ( 'scores messages with more than a page of voters' , async () => { 269const f = fixture () 270f . history . set ( SOURCE , [ f . message ({ reactions :[ reaction ( '⭐' , 150 )] })]) 271f . votes . set ( '⭐:0' , Array . from ({ length :150 }, ( _ , i ) => 272new User ( rawUser ( String ( 1244870846556804000n + BigInt ( i ))), f . client ) 273)) 274await f . starboard . sync ( SOURCE , ID ) 275assert . match ( f . created [ 0 ] ! . payload . content ! , / \*\*150\*\* / ) 276}) 277 278test ( 'concurrent updates and a restored registry create only one repost' , async () => { 279const f = fixture () 280await Promise . all ( Array . from ({ length :10 }, () => f . starboard . sync ( SOURCE , ID ))) 281assert . equal ( f . created . length , 1 ) 282const restored = new Starboard ( f . client , f . checkpoint (), () => {}, 0 ) 283await restored . sync ( SOURCE , ID ) 284assert . equal ( f . created . length , 1 ) 285}) 286 287test ( 'retains a repost below threshold, updates edits and removes deleted originals' , async () => { 288const f = fixture () 289await f . starboard . sync ( SOURCE , ID ) 290f . history . set ( SOURCE , [ f . message ({ content :'edited' , reactions :[] })]) 291await f . starboard . sync ( SOURCE , ID ) 292assert . equal ( f . created . length , 1 ) 293assert . equal ( f . deleted . length , 0 ) 294assert . doesNotMatch ( f . edited . at ( - 1 ) ! . payload . content ! , / ⭐|🔥|😭|🍅 / ) 295assert . equal ( f . edited . at ( - 1 ) ! . payload . embeds ! [ 0 ] ! . description , 'edited' ) 296await f . starboard . deleted ( f . history . get ( SOURCE ) ! [ 0 ] ! ) 297assert . deepEqual ( f . deleted , [ f . created [ 0 ] ! . id ]) 298assert . deepEqual ( f . checkpoint (). posts , {}) 299}) 300 301test ( 'deletion during creation is serialized and removes the newly created copy' , async () => { 302const f = fixture () 303const original = f . history . get ( SOURCE ) ! [ 0 ] ! 304let release ! :() => void 305let entered ! :() => void 306const blocked = new Promise < void >(( resolve ) => { release = resolve }) 307const started = new Promise < void >(( resolve ) => { entered = resolve }) 308const create = f . client . rest . channels . createMessage . bind ( f . client . rest . channels ) 309mock . method ( f . client . rest . channels , 'createMessage' , async ( channelID :string , payload :CreateMessageOptions ) => { 310entered () 311await blocked 312return create ( channelID , payload ) 313}) 314const posting = f . starboard . sync ( SOURCE , ID ) 315await started 316f . history . set ( SOURCE , []) 317const removal = f . starboard . deleted ( original ) 318release () 319await Promise . all ([ posting , removal ]) 320assert . equal ( f . created . length , 1 ) 321assert . deepEqual ( f . deleted , [ f . created [ 0 ] ! . id ]) 322assert . deepEqual ( f . state . posts , {}) 323}) 324 325test ( 'does not mistake lost source permissions for deletion' , async () => { 326const f = fixture () 327await f . starboard . sync ( SOURCE , ID ) 328mock . method ( f . client . rest . channels , 'getMessage' , async () => { throw restError ( 403 , 50001 ) }) 329await assert . rejects ( f . starboard . sync ( SOURCE , ID )) 330assert . equal ( f . deleted . length , 0 ) 331assert . ok ( f . state . posts [ ID ]) 332mock . method ( f . client . rest . channels , 'getMessage' , async () => { throw restError ( 404 , 10008 ) }) 333await f . starboard . sync ( SOURCE , ID ) 334assert . equal ( f . deleted . length , 1 ) 335}) 336 337test ( 'failed copy deletion is persisted and retried on startup' , async () => { 338const f = fixture () 339await f . starboard . sync ( SOURCE , ID ) 340mock . method ( f . client . rest . channels , 'deleteMessage' , async () => { throw new Error ( 'temporary failure' ) }) 341await assert . rejects ( f . starboard . deleted ( f . history . get ( SOURCE ) ! [ 0 ] ! )) 342const checkpoint = f . checkpoint () 343assert . equal ( checkpoint . posts [ ID ] ! . deleted , true ) 344checkpoint . backfill = completedBackfill () 345mock . method ( f . client . rest . channels , 'deleteMessage' , async ( _channelID :string , id :string ) => { f . deleted . push ( id ) }) 346const restored = new Starboard ( f . client , checkpoint , () => {}, 0 ) 347await restored . start () 348assert . equal ( f . deleted . length , 1 ) 349assert . deepEqual ( checkpoint . posts , {}) 350}) 351 352test ( 'bounds embeds, preserves attachment links and does not expose spoiler images' , async () => { 353const f = fixture () 354f . history . set ( SOURCE , [ f . message ({ 355content :` ${ 'x' . repeat ( 5000 )} @everyone` , 356attachments :Array . from ({ length :10 }, ( _ , i ) => ({ 357id :String ( 1244870846556803500n + BigInt ( i )), filename :`SPOILER_ ${ '[' . repeat ( 120 )} ${ i } .png` , 358size :100 , content_type :'image/png' , url :`https://cdn.discordapp.com/ ${ 'x' . repeat ( 200 )} / ${ i } ` , 359proxy_url :`https://media.discordapp.net/ ${ i } ` 360})) 361})]) 362await f . starboard . sync ( SOURCE , ID ) 363const payload = f . created [ 0 ] ! . payload 364const embed = payload . embeds ! [ 0 ] ! 365assert . ok ( embed . description ! . length <= 4096 ) 366assert . ok ( embed . fields ! [ 0 ] ! . value . length <= 1024 ) 367assert . equal ( embed . image , undefined ) 368assert . match ( embed . fields ! [ 0 ] ! . value , / \|\|\[ / ) 369assert . deepEqual ( payload . allowedMentions , { everyone :false , users :false , roles :false , repliedUser :false }) 370assert . match ( payload . content ! , new RegExp ( `/channels/ ${ STARBOARD_GUILD } / ${ SOURCE } / ${ ID } ` )) 371}) 372 373test ( 'ignores the destination, its threads and sources in another guild' , async () => { 374const f = fixture () 375const other = '1244870846556803600' 376f . channel ( other , '1244870846556803998' ) 377const child = f . thread ( '1244870846556803601' , STARBOARD_CHANNEL ) 378f . history . set ( other , [ f . message ({ channel_id :other })]) 379f . history . set ( child . id , [ f . message ({ channel_id :child . id })]) 380await f . starboard . sync ( STARBOARD_CHANNEL , ID , { snapshot :f . message ({ channel_id :STARBOARD_CHANNEL }) }) 381await f . starboard . sync ( other , ID ) 382await f . starboard . sync ( child . id , ID ) 383assert . equal ( f . created . length , 0 ) 384}) 385 386test ( 'excludes configured channels and their threads from live reactions and saved posting queues' , async ( t ) => { 387const excluded = '1308583779157151804' 388const extra = '1244870846556803800' 389t . mock . property ( process , 'env' , { ...process . env , STARBOARD_EXCLUDED_CHANNEL_IDS :` ${ excluded } , ${ extra } ` }) 390const childID = '1244870846556803801' 391const f = fixture ({ 392destinationID :STARBOARD_CHANNEL , posts :{}, 393backfill :{ 394 ...completedBackfill (), completedAt :undefined , 395candidates :{ 396[ ID ] :{ channelID :SOURCE }, 397[ excluded ] :{ channelID :excluded }, 398[ extra ] :{ channelID :extra }, 399[ childID ] :{ channelID :childID } 400} 401} 402}) 403f . channel ( excluded ) 404f . channel ( extra ) 405f . thread ( childID , excluded ) 406const getMessage = f . client . rest . channels . getMessage . bind ( f . client . rest . channels ) 407t . mock . method ( f . client . rest . channels , 'getMessage' , async ( channelID :string , id :string ) => { 408assert . equal ( channelID , SOURCE ) 409return getMessage ( channelID , id ) 410}) 411for ( const channelID of [ excluded , extra , childID ]) { 412f . starboard . schedule ({ id :channelID , channelID, guildID :STARBOARD_GUILD }) 413await f . starboard . sync ( channelID , channelID ) 414} 415await f . starboard . start () 416await f . starboard . waitForBackfill () 417assert . equal ( f . created . length , 1 ) 418assert . ok ( f . created [ 0 ] ! . payload . content ! . endsWith ( `/ ${ SOURCE } / ${ ID } ` )) 419assert . ok ( f . state . backfill ! . completedAt ) 420assert . match ( f . starboard . status (), new RegExp ( extra )) 421}) 422 423test ( 'the default exclusion applies to discovery and resumed history jobs, including uncached threads' , async ( t ) => { 424const env = { ...process . env } 425delete env . STARBOARD_EXCLUDED_CHANNEL_IDS 426t . mock . property ( process , 'env' , env ) 427const excluded = '1308583779157151804' 428const f = fixture () 429f . channel ( excluded ) 430const child = f . thread ( '1244870846556803801' , excluded ) 431t . mock . method ( f . client , 'getChannel' , ( id :string ) => id === child . id ?undefined :f . channels . get ( id )) 432t . mock . method ( f . client . rest . guilds , 'getActiveThreads' , async () => ({ threads :[ child ], members :[] })) 433const getMessages = f . client . rest . channels . getMessages . bind ( f . client . rest . channels ) 434t . mock . method ( f . client . rest . channels , 'getMessages' , async ( channelID :string , options :{ before ?:string ; limit :number }) => { 435assert . equal ( channelID , SOURCE ) 436return getMessages ( channelID , options ) 437}) 438t . mock . method ( f . client . rest . channels , 'getPublicArchivedThreads' , async ( channelID :string ) => { 439assert . equal ( channelID , SOURCE ) 440return { threads :[], members :[], hasMore :false } 441}) 442f . starboard . startBackfill () 443await f . starboard . waitForBackfill () 444assert . deepEqual ( f . state . backfill ! . channels , [ SOURCE ]) 445f . state . backfill = { 446 ...completedBackfill (), completedAt :undefined , phase :'collecting' , 447jobs :[{ kind :'messages' , channelID :excluded }, { kind :'messages' , channelID :child . id }] 448} 449await f . starboard . sync ( child . id , ID ) 450f . starboard . startBackfill () 451await f . starboard . waitForBackfill () 452assert . equal ( f . state . backfill . scanned , 0 ) 453assert . equal ( f . created . length , 1 ) 454}) 455 456test ( 'an empty exclusion setting clears the default, while invalid configuration fails closed' , async ( t ) => { 457t . mock . property ( process , 'env' , { ...process . env , STARBOARD_EXCLUDED_CHANNEL_IDS :'' }) 458const f = fixture () 459const channelID = '1308583779157151804' 460f . channel ( channelID ) 461f . history . set ( channelID , [ f . message ({ channel_id :channelID })]) 462await f . starboard . sync ( channelID , ID ) 463assert . equal ( f . created . length , 1 ) 464process . env . STARBOARD_EXCLUDED_CHANNEL_IDS = 'not-a-channel' 465assert . throws (() => fixture (), / STARBOARD_EXCLUDED_CHANNEL_IDS / ) 466}) 467 468test ( 'full backfill reaches the oldest page, resumes after failure and deduplicates rescans' , async () => { 469const f = fixture () 470const history = Array . from ({ length :205 }, ( _ , i ) => f . message ({ 471id :String ( BigInt ( ID ) + BigInt ( i )), reactions :i === 0 || i === 204 ?[ reaction ( '⭐' , 5 )] :[] 472})) 473f . history . set ( SOURCE , history ) 474const getMessages = f . client . rest . channels . getMessages . bind ( f . client . rest . channels ) 475let failed = false 476const visited :Array <{ channelID :string ; before ?:string }> = [] 477mock . method ( f . client . rest . channels , 'getMessages' , async ( channelID :string , options :{ before ?:string ; limit :number }) => { 478visited . push ({ channelID, before :options . before }) 479if ( options . before !== undefined && ! failed ) { 480failed = true 481throw new Error ( 'connection lost' ) 482} 483return getMessages ( channelID , options ) 484}) 485const errors = mock . method ( console , 'error' , () => {}) 486assert . equal ( f . starboard . startBackfill (), true ) 487assert . equal ( f . starboard . startBackfill (), false ) 488await f . starboard . waitForBackfill () 489errors . mock . restore () 490assert . equal ( f . state . backfill ! . scanned , 100 ) 491assert . equal ( f . created . length , 0 ) 492assert . ok ( f . state . backfill ! . error ) 493const checkpoint = f . checkpoint () 494const restored = new Starboard ( f . client , checkpoint , () => {}, 0 ) 495await restored . start () 496await restored . waitForBackfill () 497assert . equal ( checkpoint . backfill ! . scanned , 205 ) 498assert . ok ( checkpoint . backfill ! . completedAt ) 499assert . equal ( f . created . length , 2 ) 500assert . ok ( f . created [ 0 ] ! . payload . content ! . endsWith ( `/ ${ ID } ` )) 501assert . ok ( f . created [ 1 ] ! . payload . content ! . endsWith ( `/ ${ BigInt ( ID ) + 204n } ` )) 502assert . equal ( visited . filter (( v ) => v . before === undefined ). length , 1 ) 503assert . ok ( visited . every (( v ) => v . channelID !== STARBOARD_CHANNEL )) 504restored . startBackfill () 505await restored . waitForBackfill () 506assert . equal ( f . created . length , 2 ) 507}) 508 509test ( 'discovers active and paginated archived threads, including joined-private cursor semantics' , async () => { 510const f = fixture ({ destinationID :STARBOARD_CHANNEL , posts :{} }) 511const active = f . thread ( '1244870846556803700' ) 512const archived = f . thread ( '1244870846556803701' , SOURCE , '2023-01-01T00:00:00.000Z' ) 513const older = f . thread ( '1244870846556803702' , SOURCE , '2022-01-01T00:00:00.000Z' ) 514for ( const thread of [ active , archived , older ]) { 515f . history . set ( thread . id , [ f . message ({ channel_id :thread . id , id :thread . id })]) 516} 517mock . method ( f . client . rest . guilds , 'getActiveThreads' , async () => ({ threads :[ active ], members :[] })) 518const publicCursors :Array < string | undefined > = [] 519const joinedCursors :Array < string | undefined > = [] 520mock . method ( f . client . rest . channels , 'getPublicArchivedThreads' , async ( _id :string , options :{ before ?:string }) => { 521publicCursors . push ( options . before ) 522return { threads :options . before === undefined ?[ archived ] :[ older ], members :[], hasMore :options . before === undefined } 523}) 524mock . method ( f . client . rest . channels , 'getPrivateJoinedArchivedThreads' , async ( _id :string , options :{ before ?:string }) => { 525joinedCursors . push ( options . before ) 526return { threads :options . before === undefined ?[ archived ] :[], members :[], hasMore :options . before === undefined } 527}) 528await f . starboard . start () 529await f . starboard . waitForBackfill () 530assert . deepEqual ( publicCursors , [ undefined , '2023-01-01T00:00:00.000Z' ]) 531assert . deepEqual ( joinedCursors , [ undefined , archived . id ]) 532assert . equal ( f . created . length , 4 ) 533assert . equal ( f . state . backfill ! . channels . length , 4 ) 534}) 535 536test ( 'a destination failure pauses backfill without losing the qualifying source page' , async () => { 537const f = fixture () 538const create = f . client . rest . channels . createMessage . bind ( f . client . rest . channels ) 539mock . method ( f . client . rest . channels , 'createMessage' , async () => { throw restError ( 403 , 50013 ) }) 540const errors = mock . method ( console , 'error' , () => {}) 541f . starboard . startBackfill () 542await f . starboard . waitForBackfill () 543errors . mock . restore () 544assert . equal ( f . state . backfill ! . scanned , 1 ) 545assert . equal ( f . state . backfill ! . phase , 'posting' ) 546assert . equal ( f . state . backfill ! . candidates [ ID ] ! . channelID , SOURCE ) 547assert . ok ( f . state . backfill ! . error ) 548assert . equal ( f . state . backfill ! . skipped . length , 0 ) 549mock . method ( f . client . rest . channels , 'createMessage' , create ) 550f . starboard . startBackfill () 551await f . starboard . waitForBackfill () 552assert . equal ( f . created . length , 1 ) 553assert . ok ( f . state . backfill ! . completedAt ) 554}) 555 556test ( 'collects every source before posting in global chronological order, including live candidates' , async () => { 557const f = fixture ({ destinationID :STARBOARD_CHANNEL , posts :{} }) 558const other = '1244870846556803801' 559f . channel ( other ) 560f . history . set ( other , [ f . message ({ channel_id :other , id :String ( BigInt ( ID ) - 1n ), content :'oldest' })]) 561f . history . set ( SOURCE , [ 562f . message ({ content :'middle' }), 563f . message ({ id :String ( BigInt ( ID ) + 1n ), content :'newest' }) 564]) 565let release ! :() => void 566let entered ! :() => void 567const blocked = new Promise < void >(( resolve ) => { release = resolve }) 568const started = new Promise < void >(( resolve ) => { entered = resolve }) 569mock . method ( f . client . rest . channels , 'getPublicArchivedThreads' , async () => { 570assert . equal ( f . created . length , 0 ) 571entered () 572await blocked 573return { threads :[], members :[], hasMore :false } 574}) 575await f . starboard . start () 576await started 577const live = f . message ({ id :String ( BigInt ( ID ) + 2n ), content :'live' }) 578f . history . get ( SOURCE ) ! . push ( live ) 579await f . starboard . sync ( SOURCE , live . id ) 580assert . equal ( f . created . length , 0 ) 581release () 582await f . starboard . waitForBackfill () 583assert . deepEqual ( f . created . map (( c ) => c . payload . embeds ! [ 0 ] ! . description ), [ 'oldest' , 'middle' , 'newest' , 'live' ]) 584assert . ok ( f . created . every (( c ) => c . channelID === STARBOARD_CHANNEL )) 585}) 586 587test ( 'resumes posting in order without rescanning history, and holds new live posts until afterward' , async () => { 588const f = fixture () 589const middle = String ( BigInt ( ID ) + 1n ) 590const newest = String ( BigInt ( ID ) + 2n ) 591const liveID = String ( BigInt ( ID ) + 3n ) 592f . history . set ( SOURCE , [ 593f . message ({ content :'oldest' }), 594f . message ({ id :middle , content :'middle' }), 595f . message ({ id :newest , content :'newest' }) 596]) 597const create = f . client . rest . channels . createMessage . bind ( f . client . rest . channels ) 598let failed = false 599mock . method ( f . client . rest . channels , 'createMessage' , async ( channelID :string , payload :CreateMessageOptions ) => { 600if ( f . created . length === 1 && ! failed ) { 601failed = true 602throw new Error ( 'connection lost' ) 603} 604return create ( channelID , payload ) 605}) 606const errors = mock . method ( console , 'error' , () => {}) 607f . starboard . startBackfill () 608await f . starboard . waitForBackfill () 609errors . mock . restore () 610assert . equal ( f . created . length , 1 ) 611f . history . get ( SOURCE ) ! . push ( f . message ({ id :liveID , content :'live' })) 612await f . starboard . sync ( SOURCE , liveID ) 613assert . equal ( f . created . length , 1 ) 614const restored = new Starboard ( f . client , f . checkpoint (), () => {}, 0 ) 615mock . method ( f . client . rest . channels , 'getMessages' , async () => { throw new Error ( 'history must not be rescanned' ) }) 616await restored . start () 617await restored . waitForBackfill () 618assert . deepEqual ( f . created . map (( c ) => c . payload . embeds ! [ 0 ] ! . description ), [ 'oldest' , 'middle' , 'newest' , 'live' ]) 619assert . ok ( restored . state . backfill ! . completedAt ) 620}) 621 622test ( 'revalidates candidate votes before posting' , async () => { 623const f = fixture ({ destinationID :STARBOARD_CHANNEL , posts :{} }) 624await f . starboard . sync ( SOURCE , ID ) 625assert . equal ( f . created . length , 0 ) 626f . votes . get ( '⭐:0' ) ! . pop () 627await f . starboard . start () 628await f . starboard . waitForBackfill () 629assert . equal ( f . created . length , 0 ) 630assert . ok ( f . state . backfill ! . completedAt ) 631}) 632 633test ( 'changing destinations archives old mappings and rescans from the beginning without modifying old posts' , async () => { 634const oldChannel = '1559288341717844088' 635const oldCopy = '1559288341717845000' 636const state = JSON . parse ( JSON . stringify ({ 637posts :{ [ ID ] :{ channelID :SOURCE , messageID :oldCopy , signature :'old' } }, 638backfill :{ startedAt :'2024-01-01' , initialized :true , jobs :[], channels :[ SOURCE ], scanned :4600 , skipped :[] } 639})) as StarboardState 640const f = fixture ( state ) 641f . channel ( oldChannel ) 642f . history . set ( oldChannel , [ f . message ({ id :oldCopy , channel_id :oldChannel })]) 643const create = f . client . rest . channels . createMessage . bind ( f . client . rest . channels ) 644mock . method ( f . client . rest . channels , 'createMessage' , async ( channelID :string , payload :CreateMessageOptions ) => { 645if ( payload . nonce === ID ) return f . message ({ id :oldCopy , channel_id :oldChannel }) 646return create ( channelID , payload ) 647}) 648await f . starboard . start () 649await f . starboard . waitForBackfill () 650assert . equal ( f . created . length , 1 ) 651assert . equal ( f . created [ 0 ] ! . channelID , STARBOARD_CHANNEL ) 652assert . equal ( f . state . destinationID , STARBOARD_CHANNEL ) 653assert . equal ( f . state . backfill ! . scanned , 1 ) 654assert . equal ( f . state . archivedBoards ! [ oldChannel ] ! . posts [ ID ] ! . messageID , oldCopy ) 655assert . equal ( f . deleted . length , 0 ) 656assert . equal ( f . edited . length , 0 ) 657}) 658 659test ( 'a new destination replays already posted and queued originals without rescanning history' , async () => { 660const oldChannel = '1559380388269391892' 661const middleID = String ( BigInt ( ID ) + 1n ) 662const newestID = String ( BigInt ( ID ) + 2n ) 663const deletedID = String ( BigInt ( ID ) - 1n ) 664const oldScan :Backfill = { 665 ...completedBackfill (), completedAt :undefined , scanned :415615 , processed :73 , 666candidates :{ [ middleID ] :{ channelID :SOURCE } }, 667pending :{ [ newestID ] :{ channelID :SOURCE } } 668} 669const oldPosts = { 670[ ID ] :{ channelID :SOURCE , messageID :'1559380388269391900' , signature :'old' }, 671[ deletedID ] :{ channelID :SOURCE , messageID :'1559380388269391901' , signature :'old' , deleted :true } 672} 673const state :StarboardState = { destinationID :oldChannel , posts :oldPosts , backfill :oldScan } 674const originalState = structuredClone ( state ) 675const f = fixture ( state ) 676f . history . set ( SOURCE , [ 677f . message ({ content :'already posted' }), 678f . message ({ id :middleID , content :'queued history' }), 679f . message ({ id :newestID , content :'queued live' }) 680]) 681mock . method ( f . client . rest . channels , 'getMessages' , async () => { throw new Error ( 'must reuse collected history' ) }) 682mock . method ( f . client . rest . guilds , 'getChannels' , async () => { throw new Error ( 'must not rediscover channels' ) }) 683assert . equal ( Object . keys ( f . state . posts ). length , 0 ) 684assert . equal ( f . state . backfill ! . processed , 0 ) 685assert . equal ( f . state . backfill ! . scanned , 415615 ) 686const restored = new Starboard ( f . client , f . checkpoint (), () => {}, 0 ) 687await restored . start () 688await restored . waitForBackfill () 689assert . deepEqual ( f . created . map (( c ) => c . payload . embeds ! [ 0 ] ! . description ), [ 'already posted' , 'queued history' , 'queued live' ]) 690assert . ok ( f . created . every (( c ) => c . channelID === STARBOARD_CHANNEL )) 691assert . equal ( f . edited . length , 0 ) 692assert . equal ( f . deleted . length , 0 ) 693assert . deepEqual ( restored . state . archivedBoards ! [ oldChannel ], { posts :originalState . posts , backfill :originalState . backfill }) 694assert . ok ( restored . state . backfill ! . completedAt ) 695}) 696 697test ( 'switching destination during collection retains the history cursor' , async () => { 698const scan :Backfill = { 699 ...completedBackfill (), completedAt :undefined , phase :'collecting' , scanned :100 , 700candidates :{ [ String ( BigInt ( ID ) + 1n )] :{ channelID :SOURCE } }, 701jobs :[{ kind :'messages' , channelID :SOURCE , before :String ( BigInt ( ID ) + 1n ) }] 702} 703const f = fixture ({ destinationID :'1559380388269391892' , posts :{}, backfill :scan }) 704f . history . set ( SOURCE , [ f . message (), f . message ({ id :String ( BigInt ( ID ) + 1n ) })]) 705const getMessages = f . client . rest . channels . getMessages . bind ( f . client . rest . channels ) 706mock . method ( f . client . rest . channels , 'getMessages' , async ( channelID :string , options :{ before ?:string ; limit :number }) => { 707assert . equal ( options . before , String ( BigInt ( ID ) + 1n )) 708return getMessages ( channelID , options ) 709}) 710await f . starboard . start () 711await f . starboard . waitForBackfill () 712assert . equal ( f . state . backfill ! . scanned , 101 ) 713assert . equal ( f . created . length , 2 ) 714}) 715 716test ( 'backfill automatically retries a connection reset from its saved page' , async ( t ) => { 717const f = fixture () 718f . history . set ( SOURCE , Array . from ({ length :101 }, ( _ , i ) => f . message ({ 719id :String ( BigInt ( ID ) + BigInt ( i )), reactions :i === 0 ?[ reaction ( '⭐' , 5 )] :[] 720}))) 721const getMessages = f . client . rest . channels . getMessages . bind ( f . client . rest . channels ) 722let firstPages = 0 723let failed = false 724t . mock . method ( console , 'warn' , () => {}) 725t . mock . method ( f . client . rest . channels , 'getMessages' , async ( channelID :string , options :{ before ?:string ; limit :number }) => { 726if ( options . before === undefined ) firstPages ++ 727else if ( ! failed ) { 728failed = true 729throw new TypeError ( 'fetch failed' , { cause :new Error ( 'read ECONNRESET' ) }) 730} 731return getMessages ( channelID , options ) 732}) 733f . starboard . startBackfill () 734await f . starboard . waitForBackfill () 735assert . equal ( firstPages , 1 ) 736assert . equal ( f . state . backfill ! . scanned , 101 ) 737assert . equal ( f . created . length , 1 ) 738assert . ok ( f . state . backfill ! . completedAt ) 739assert . equal ( f . state . backfill ! . error , undefined ) 740}) 741 742test ( 'reports inaccessible history while continuing other channels' , async () => { 743const f = fixture () 744const hidden = '1244870846556803800' 745f . channel ( hidden ) 746const getMessages = f . client . rest . channels . getMessages . bind ( f . client . rest . channels ) 747mock . method ( f . client . rest . channels , 'getMessages' , async ( channelID :string , options :{ before ?:string ; limit :number }) => { 748if ( channelID === hidden ) throw restError ( 403 , 50001 ) 749return getMessages ( channelID , options ) 750}) 751const warnings = mock . method ( console , 'warn' , () => {}) 752f . starboard . startBackfill () 753await f . starboard . waitForBackfill () 754warnings . mock . restore () 755assert . ok ( f . state . backfill ! . completedAt ) 756assert . equal ( f . created . length , 1 ) 757assert . deepEqual ( f . state . backfill ! . skipped , [ `messages: ${ hidden } ` ]) 758})