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

Charlotte Somadd tomato to starboardf13db32

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