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