import { createHash } from 'node:crypto' import { setTimeout as delay } from 'node:timers/promises' import { ChannelTypes, DiscordHTTPError, DiscordRESTError, MessageTypes, ReactionType, TextableGuildChannelsWithoutThreadsTypes, type Client, type CreateMessageOptions, type EmbedField, type Message, type PossiblyUncachedMessage } from 'oceanic.js' import { reactionKey } from './emoji.ts' import { retryDiscord } from './retry.ts' import { loadStarboard, saveStarboard, type Backfill, type ScanJob, type StarboardState } from './starboard-store.ts' export const STARBOARD_GUILD = '1244870846556803074' export const STARBOARD_CHANNEL = '1559594091497660507' const LEGACY_CHANNEL = '1559288341717844088' const EMOJI = new Set(['⭐', '🔥', '😭', '🍅']) const THRESHOLD = 5 function missingMessage(error: unknown): boolean { return error instanceof DiscordRESTError && error.code === 10008 } function inaccessible(error: unknown): boolean { return ( (error instanceof DiscordRESTError || error instanceof DiscordHTTPError) && (error.status === 403 || error.status === 404) ) } function truncate(text: string, limit: number): string { return text.length <= limit ? text : `${text.slice(0, limit - 1)}…` } function newBackfill(): Backfill { return { phase: 'collecting', candidates: {}, pending: {}, processed: 0, startedAt: new Date().toISOString(), initialized: false, jobs: [], channels: [], scanned: 0, skipped: [] } } export class Starboard { private locks = new Map>() private timers = new Map>() private worker?: Promise private client: Client readonly state: StarboardState private save: (state: StarboardState) => void private pageDelay: number private excludedChannels: Set constructor( client: Client, state: StarboardState = loadStarboard(), save: (state: StarboardState) => void = saveStarboard, pageDelay = 250, excludedChannelIDs: readonly string[] = (process.env.STARBOARD_EXCLUDED_CHANNEL_IDS ?? '1308583779157151804') .split(/[,\s]+/).filter(Boolean) ) { this.client = client this.state = state this.save = save this.pageDelay = pageDelay if (excludedChannelIDs.some((id) => !/^\d{17,20}$/.test(id))) { throw new Error('STARBOARD_EXCLUDED_CHANNEL_IDS must contain channel IDs separated by commas or whitespace') } this.excludedChannels = new Set(excludedChannelIDs) const destinationID = state.destinationID ?? LEGACY_CHANNEL if (destinationID !== STARBOARD_CHANNEL) { if (Object.keys(state.posts).length > 0 || state.backfill !== undefined) { const archived = (state.archivedBoards ??= {}) archived[destinationID] = { posts: state.posts, backfill: state.backfill } } const previous = state.archivedBoards?.[STARBOARD_CHANNEL] const scan = state.backfill if (scan?.phase !== undefined) { state.backfill = { ...structuredClone(scan), candidates: { ...structuredClone(scan.candidates), ...structuredClone(scan.pending), ...Object.fromEntries(Object.entries(state.posts) .filter(([, post]) => !post.deleted) .map(([id, post]) => [id, { channelID: post.channelID }])) }, pending: {}, processed: 0, startedAt: new Date().toISOString() } delete state.backfill.completedAt delete state.backfill.error } else { delete state.backfill } state.posts = previous?.posts ?? {} delete state.archivedBoards?.[STARBOARD_CHANNEL] } state.destinationID = STARBOARD_CHANNEL if (state.backfill?.phase === undefined) state.backfill = newBackfill() this.save(state) } private isExcluded(channelID: string): boolean { if (this.excludedChannels.has(channelID) || channelID === STARBOARD_CHANNEL || this.state.archivedBoards?.[channelID] !== undefined) return true const channel = this.client.getChannel(channelID) return channel !== undefined && 'parentID' in channel && channel.parentID !== null && this.isExcluded(channel.parentID) } private defer(channelID: string, id: string): boolean { const scan = this.state.backfill if (scan === undefined || scan.completedAt !== undefined) return false const queue = scan.phase === 'collecting' ? scan.candidates : scan.pending queue[id] = { channelID } this.save(this.state) return true } async start(): Promise { const destination = await this.client.rest.channels.get(STARBOARD_CHANNEL) if (!('guildID' in destination) || destination.guildID !== STARBOARD_GUILD) { throw new Error('starboard destination is not in the configured guild') } for (const [id, post] of Object.entries(this.state.posts)) { try { if (post.deleted) await this.remove(id) else await this.sync(post.channelID, id, { force: true }) } catch (error) { console.error(`starboard ${id}: startup reconciliation failed:`, error) } } if (!this.state.backfill?.completedAt) this.startBackfill() } schedule( message: { id: string; channelID: string; guildID?: string | null }, emoji?: { id: string | null; name: string | null } ): void { const channel = this.client.getChannel(message.channelID) const guildID = message.guildID ?? (channel !== undefined && 'guildID' in channel ? channel.guildID : undefined) if (this.isExcluded(message.channelID) || guildID !== STARBOARD_GUILD) return if (emoji !== undefined && !EMOJI.has(reactionKey(emoji) ?? '')) return const previous = this.timers.get(message.id) if (previous !== undefined) clearTimeout(previous) this.timers.set(message.id, setTimeout(() => { this.timers.delete(message.id) void retryDiscord(() => this.sync(message.channelID, message.id), `starboard ${message.id}`).catch((error) => { console.error(`starboard ${message.id}: update failed:`, error) }) }, 750)) } async deleted(message: PossiblyUncachedMessage): Promise { const scan = this.state.backfill if (scan?.candidates[message.id] !== undefined || scan?.pending[message.id] !== undefined) { delete scan.candidates[message.id] delete scan.pending[message.id] this.save(this.state) } if (this.state.posts[message.id] === undefined && !this.locks.has(message.id)) return await retryDiscord(() => this.remove(message.id), `starboard ${message.id} deletion`) } private async exclusive(id: string, action: () => Promise): Promise { const work = (this.locks.get(id) ?? Promise.resolve()).catch(() => {}).then(action) this.locks.set(id, work) try { await work } finally { if (this.locks.get(id) === work) this.locks.delete(id) } } private async remove(id: string): Promise { await this.exclusive(id, async () => { const post = this.state.posts[id] if (post === undefined) return post.deleted = true this.save(this.state) try { await this.client.rest.channels.deleteMessage(STARBOARD_CHANNEL, post.messageID) } catch (error) { if (!missingMessage(error)) throw error } delete this.state.posts[id] this.save(this.state) }) } async sync( channelID: string, id: string, options: { snapshot?: Message; force?: boolean; publish?: boolean } = {} ): Promise { if (this.isExcluded(channelID)) return const { snapshot } = options // A queued history snapshot may predate a live edit or deletion. const fetch = snapshot === undefined || this.locks.has(id) || this.state.posts[id] !== undefined let gone = false await this.exclusive(id, async () => { const post = this.state.posts[id] if (post?.deleted) return let message: Message try { const source = this.client.getChannel(channelID) ?? await this.client.rest.channels.get(channelID) if (!('guildID' in source) || source.guildID !== STARBOARD_GUILD) return if (source.parentID !== null && this.isExcluded(source.parentID)) return message = fetch ? await this.client.rest.channels.getMessage(channelID, id) : snapshot! const reactions = message.reactions.filter((r) => EMOJI.has(reactionKey(r.emoji) ?? '')) if (post === undefined && reactions.reduce((sum, r) => sum + r.count, 0) < THRESHOLD) return if (post === undefined && !options.publish && this.defer(channelID, id)) return const voters = new Set() const breakdown = new Map([...EMOJI].map((emoji) => [emoji, new Set()])) for (const reaction of reactions) { const emojiVoters = breakdown.get(reactionKey(reaction.emoji)!)! for (const [type, count] of [ [ReactionType.NORMAL, reaction.countDetails.normal], [ReactionType.SUPER, reaction.countDetails.burst] ] as const) { if (count === 0 || reaction.emoji.name === null) continue const users = await this.client.rest.channels.getReactions(channelID, id, reaction.emoji.name, { limit: Infinity, type }) for (const user of users) { if (!user.bot && user.id !== message.author.id) { voters.add(user.id) emojiVoters.add(user.id) } } } } if (post === undefined && voters.size < THRESHOLD) return const attachments = [...message.attachments.values()] const image = attachments.find((a) => a.contentType?.startsWith('image/') && !a.filename.startsWith('SPOILER_') ) const links: string[] = [] for (const attachment of attachments) { const label = truncate(attachment.filename.replace(/[\\[\]]/g, '\\$&'), 100) const attachmentLink = `[${label}](${attachment.url})` const link = attachment.filename.startsWith('SPOILER_') ? `||${attachmentLink}||` : attachmentLink if ([...links, link].join('\n').length > 980) { links.push('More attachments in the original message.') break } links.push(link) } const fields: EmbedField[] = [] const reference = message.messageReference if (message.type === MessageTypes.REPLY && reference?.messageID !== undefined) { const replyChannelID = reference.channelID ?? channelID const replyGuildID = reference.guildID ?? STARBOARD_GUILD let repliedTo = message.referencedMessage if (repliedTo === undefined) { try { repliedTo = await this.client.rest.channels.getMessage(replyChannelID, reference.messageID) } catch (error) { if (!inaccessible(error)) throw error repliedTo = null } } const replyURL = `https://discord.com/channels/${replyGuildID}/${replyChannelID}/${reference.messageID}` const preview = repliedTo === null ? '[Message deleted or unavailable.]' : repliedTo.content || (repliedTo.attachments.size > 0 ? `[${repliedTo.attachments.size} attachment(s); view the message for details.]` : '[No text; view the message for details.]') fields.push({ name: repliedTo === null ? 'Replying to' : truncate(`Replying to ${repliedTo.author.globalName ?? repliedTo.author.username}`, 100), // Leave room for attachments and the original text within the 6,000-character embed budget. value: truncate(`${truncate(preview, 300)}\n[View replied-to message](${replyURL})`, 450) }) } if (links.length > 0) fields.push({ name: 'Attachments', value: links.join('\n') }) const url = `https://discord.com/channels/${STARBOARD_GUILD}/${channelID}/${id}` const reactionsLine = [...breakdown] .filter(([, users]) => users.size > 0) .map(([emoji, users]) => `${emoji} **${users.size}**`) .join(' ') const payload: CreateMessageOptions = { content: [reactionsLine, url].filter(Boolean).join('\n'), allowedMentions: { everyone: false, users: false, roles: false, repliedUser: false }, embeds: [{ color: 0xffcc55, author: { name: truncate(message.author.globalName ?? message.author.username, 256), iconURL: message.author.avatarURL(), url: `https://discord.com/users/${message.author.id}` }, description: truncate(message.content || '[No text; see the original message.]', 4096), timestamp: message.timestamp.toISOString(), fields, image: image === undefined ? undefined : { url: image.url } }] } const signature = createHash('sha256').update(JSON.stringify(payload)).digest('hex') if (post !== undefined) { if (!options.force && post.signature === signature) return try { await this.client.rest.channels.editMessage(STARBOARD_CHANNEL, post.messageID, payload) post.signature = signature this.save(this.state) return } catch (error) { if (!missingMessage(error)) throw error } } if (!options.publish && this.defer(channelID, id)) return const copy = await this.client.rest.channels.createMessage(STARBOARD_CHANNEL, { ...payload, nonce: createHash('sha256').update(`${STARBOARD_CHANNEL}:${id}`).digest('hex').slice(0, 25), enforceNonce: true }) this.state.posts[id] = { channelID, messageID: copy.id, signature } this.save(this.state) } catch (error) { if (!missingMessage(error)) throw error gone = true } }) if (gone) await this.remove(id) } status(): string { const scan = this.state.backfill const phase = scan?.completedAt !== undefined ? 'complete' : this.worker !== undefined ? scan?.phase : `paused (${scan?.phase ?? 'not started'})` return [ `destination: <#${STARBOARD_CHANNEL}>`, `excluded channels: ${[...this.excludedChannels].map((id) => `<#${id}>`).join(', ') || 'none'} (also excludes starboard destinations and child channels)`, `${[...EMOJI].join(' ')} · ${THRESHOLD} unique people · bot/self votes excluded`, `${Object.keys(this.state.posts).length} reposts tracked`, `backfill: ${scan === undefined ? 'not started' : phase}`, ...(scan === undefined ? [] : [ `${scan.scanned} messages scanned · ${scan.channels.length} source channels/threads discovered · ${scan.jobs.length} jobs remaining`, `${Object.keys(scan.candidates).length} history candidates queued · ${Object.keys(scan.pending).length} live candidates waiting · ${scan.processed} candidates processed`, `${scan.skipped.length} inaccessible history/thread-list jobs skipped (details in bot logs)`, ...(scan.error === undefined ? [] : [`last error: ${truncate(scan.error, 500)}`]) ]) ].join('\n') } startBackfill(): boolean { if (this.worker !== undefined) return false if (this.state.backfill === undefined || this.state.backfill.completedAt !== undefined) { this.state.backfill = newBackfill() } delete this.state.backfill.error this.save(this.state) this.worker = retryDiscord(() => this.runBackfill(), 'starboard backfill').catch((error: unknown) => { this.state.backfill!.error = String(error) this.save(this.state) console.error('starboard backfill paused; use /starboard backfill to resume:', error) }).finally(() => { this.worker = undefined }) return true } async waitForBackfill(): Promise { await this.worker } private enqueue(scan: Backfill, channelID: string): void { if (this.isExcluded(channelID) || scan.channels.includes(channelID)) return scan.channels.push(channelID) scan.jobs.push({ kind: 'messages', channelID }) } private async runBackfill(): Promise { const scan = this.state.backfill! if (!scan.initialized) { const channels = await this.client.rest.guilds.getChannels(STARBOARD_GUILD) const active = await this.client.rest.guilds.getActiveThreads(STARBOARD_GUILD) for (const channel of channels) { if (this.isExcluded(channel.id) || (channel.parentID !== null && this.isExcluded(channel.parentID))) continue if (TextableGuildChannelsWithoutThreadsTypes.some((type) => type === channel.type)) this.enqueue(scan, channel.id) if ([ChannelTypes.GUILD_TEXT, ChannelTypes.GUILD_ANNOUNCEMENT, ChannelTypes.GUILD_FORUM, ChannelTypes.GUILD_MEDIA].includes(channel.type)) { scan.jobs.push({ kind: 'publicThreads', channelID: channel.id }) if (channel.type === ChannelTypes.GUILD_TEXT) { scan.jobs.push({ kind: 'privateThreads', channelID: channel.id }) scan.jobs.push({ kind: 'joinedThreads', channelID: channel.id }) } } } for (const thread of active.threads) { if (!this.isExcluded(thread.parentID)) this.enqueue(scan, thread.id) } scan.initialized = true this.save(this.state) } console.log(`starboard backfill: ${scan.phase}, ${scan.jobs.length} history jobs remaining`) if (scan.phase === 'collecting') { while (scan.jobs.length > 0) { const job = scan.jobs[0]! await this.scanPage(scan, job) this.save(this.state) await delay(this.pageDelay) } scan.phase = 'posting' this.save(this.state) console.log(`starboard backfill: posting ${Object.keys(scan.candidates).length} candidates oldest-first`) } for (const queue of [scan.candidates, scan.pending]) { while (Object.keys(queue).length > 0) { const ids = Object.keys(queue).sort((a, b) => BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0) for (const id of ids) { const candidate = queue[id] if (candidate === undefined) continue await this.sync(candidate.channelID, id, { publish: true }) if (queue[id] === candidate) delete queue[id] scan.processed++ this.save(this.state) await delay(this.pageDelay) } } } scan.completedAt = new Date().toISOString() this.save(this.state) console.log(`starboard backfill complete: ${scan.scanned} messages, ${scan.skipped.length} jobs skipped`) } private async scanPage(scan: Backfill, job: ScanJob): Promise { if (this.isExcluded(job.channelID)) { scan.jobs.shift() return } if (job.kind === 'messages') { let messages: Message[] try { const source = this.client.getChannel(job.channelID) ?? await this.client.rest.channels.get(job.channelID) if ('parentID' in source && source.parentID !== null && this.isExcluded(source.parentID)) { scan.jobs.shift() return } messages = await this.client.rest.channels.getMessages(job.channelID, { before: job.before, limit: 100 }) } catch (error) { if (!inaccessible(error)) throw error scan.skipped.push(`${job.kind}:${job.channelID}`) console.warn(`starboard backfill: cannot read history in ${job.channelID}:`, error) scan.jobs.shift() return } for (const message of messages) await this.sync(job.channelID, message.id, { snapshot: message }) scan.scanned += messages.length if (messages.length < 100) scan.jobs.shift() else job.before = messages.at(-1)!.id } else { let archived try { const options = { before: job.before, limit: 100 } archived = job.kind === 'publicThreads' ? await this.client.rest.channels.getPublicArchivedThreads(job.channelID, options) : job.kind === 'privateThreads' ? await this.client.rest.channels.getPrivateArchivedThreads(job.channelID, options) : await this.client.rest.channels.getPrivateJoinedArchivedThreads(job.channelID, options) } catch (error) { if (!inaccessible(error)) throw error scan.skipped.push(`${job.kind}:${job.channelID}`) console.warn(`starboard backfill: cannot list ${job.kind} in ${job.channelID}:`, error) scan.jobs.shift() return } for (const thread of archived.threads) this.enqueue(scan, thread.id) if (!archived.hasMore) scan.jobs.shift() else { const last = archived.threads.at(-1) if (last === undefined) throw new Error(`empty archived thread page with hasMore in ${job.channelID}`) // Joined-private pagination uses snowflakes, unlike the other archive endpoints. const before = job.kind === 'joinedThreads' ? last.id : last.threadMetadata.archiveTimestamp.toISOString() if (before === job.before) throw new Error(`archived thread cursor did not advance in ${job.channelID}`) job.before = before } } } }