Files
pearcord-platform/mixins/domain/polls-scheduling.js
T
Raven ScottandCursor a5dba86570 refactor(platform): organize mixins into category directories
Move 136 prototype mixins under mixins/domain, json-export, and
runtime/* so the package root stays navigable. Manifest assign order
is unchanged; apply-platform-mixins and runtime registry paths updated.

Co-authored-by: Cursor <[email protected]>
2026-06-03 17:42:26 -04:00

540 lines
19 KiB
JavaScript

'use strict'
const { GuildPollStore, isPollClosed } = require('pearcord-polls')
const { ScheduledMessageStore } = require('pearcord-scheduled-messages')
const {
COLLECTIONS,
DM_GUILD_ID,
DM_POLL_DEFAULT_EXPIRES_MS,
buildDmPollMessageContent,
parseDmPollMessageContent,
pollVoteEmoji,
normalizeDmScheduledMessagePayload,
id,
PERMISSION,
roleHasPermission
} = require('pearcord-shared')
const { computeNextRecurrenceSendAt } = require('pearcord-shared/dm-scheduled-policy')
const pollSchedulingMixin = {
async _ensureGuildPollStore () {
if (this._guildPollStore) return this._guildPollStore
this._guildPollStore = new GuildPollStore({
db: this.db,
storagePath: this.storagePath
})
await this._guildPollStore.ready()
return this._guildPollStore
},
async _ensureScheduledMessageStore () {
if (this._scheduledMessageStore) return this._scheduledMessageStore
this._scheduledMessageStore = new ScheduledMessageStore({
db: this.db,
storagePath: this.storagePath
})
await this._scheduledMessageStore.ready()
return this._scheduledMessageStore
},
_pollGossipKey (payload) {
if (!payload) return ''
if (payload.messageId && payload.updatedAt) {
return `poll:${payload.messageId}:${payload.updatedAt}`
}
if (payload.messageId && payload.userId != null && payload.optionIndex != null) {
return `vote:${payload.messageId}:${payload.userId}:${payload.optionIndex}:${payload.votedAt || 0}`
}
return ''
},
_shouldGossipPoll (payload) {
const hash = this._pollGossipKey(payload)
if (!hash) return false
if (!this._pollGossipKeys) this._pollGossipKeys = new Set()
if (this._pollGossipKeys.has(hash)) return false
this._pollGossipKeys.add(hash)
if (this._pollGossipKeys.size > 8192) {
const first = this._pollGossipKeys.values().next().value
if (first) this._pollGossipKeys.delete(first)
}
return true
},
_gossipPollUpsertIfNew (payload) {
if (!this._shouldGossipPoll(payload) || !this.guild?.gossipPollUpsert) return
this.guild.gossipPollUpsert(payload)
},
_gossipPollVoteIfNew (payload) {
if (!this._shouldGossipPoll(payload) || !this.guild?.gossipPollVote) return
this.guild.gossipPollVote(payload)
},
_scheduledGossipKey (payload) {
if (!payload?.id) return ''
return `sched:${payload.id}:${payload.updatedAt || payload.sendAt || 0}:${payload.status || ''}`
},
_shouldGossipScheduled (payload) {
const hash = this._scheduledGossipKey(payload)
if (!hash) return false
if (!this._scheduledGossipKeys) this._scheduledGossipKeys = new Set()
if (this._scheduledGossipKeys.has(hash)) return false
this._scheduledGossipKeys.add(hash)
if (this._scheduledGossipKeys.size > 4096) {
const first = this._scheduledGossipKeys.values().next().value
if (first) this._scheduledGossipKeys.delete(first)
}
return true
},
_gossipScheduledIfNew (payload) {
if (!this._shouldGossipScheduled(payload) || !this.guild?.gossipScheduledMessage) return
this.guild.gossipScheduledMessage(payload)
},
async _assertPollPostPermission () {
if (this.mode !== 'guild' || !this.guild?.guild) throw new Error('polls require guild mode')
await this._assertCanParticipate('send')
const channels = await this.guild.listChannels()
const channel = channels.find((c) => c.id === this.activeChannelId)
await this._assertAnnouncementPost(channel)
return { channel }
},
async _syncPollStoreFromMessage (msg, { store = null } = {}) {
if (!msg?.id || !msg.channelId) return null
const poll = parseDmPollMessageContent(this._messagePlaintext(msg) || msg.content)
if (!poll) return null
const pollStore =
store ||
(msg.guildId === DM_GUILD_ID
? await this._ensureDmPollStore()
: await this._ensureGuildPollStore())
const row = {
messageId: msg.id,
channelId: msg.channelId,
guildId: msg.guildId || this.guild?.guild?.id || DM_GUILD_ID,
question: poll.question,
options: poll.options.map((o) => o.text),
multiSelect: !!poll.multiSelect,
anonymous: !!poll.anonymous,
expiresAt: poll.expiresAt || null,
closedAt: poll.closedAt || null,
createdAt: msg.createdAt || Date.now(),
updatedAt: Date.now()
}
await pollStore.upsert(row)
if (row.guildId !== DM_GUILD_ID) this._gossipPollUpsertIfNew(row)
return row
},
async createPoll ({
question,
options,
multiSelect = false,
anonymous = false,
expiresInMs = null
} = {}) {
if (this.mode === 'dm') {
return this.createDmPoll({ question, options, multiSelect, anonymous, expiresInMs })
}
const span = this.log.time('poll.create', {
spanKind: 'poll.create',
guildId: this.guild?.guild?.id,
channelId: this.activeChannelId
})
try {
await this._assertPollPostPermission()
const store = await this._ensureGuildPollStore()
const rate = store.checkCreateRateLimit({
guildId: this.guild.guild.id,
channelId: this.activeChannelId
})
if (!rate.allowed) throw new Error(rate.reason)
const ttl = Number.isFinite(Number(expiresInMs))
? Math.max(60000, Math.min(30 * 24 * 60 * 60 * 1000, Number(expiresInMs)))
: DM_POLL_DEFAULT_EXPIRES_MS
const body = buildDmPollMessageContent({
question,
options,
multiSelect: !!multiSelect,
anonymous: !!anonymous,
expiresAt: Date.now() + ttl
})
const msg = await this._sendPlainMessage(body, {})
await this._syncPollStoreFromMessage(msg, { store })
span.end({
messageId: msg?.id,
optionCount: (options || []).length,
multiSelect: !!multiSelect,
anonymous: !!anonymous,
guildCount: (this.guilds || []).length
})
return msg
} catch (err) {
this.log.error('poll.create error', { error: err?.message || String(err) })
span.fail(err)
throw err
}
},
async votePoll (messageId, optionIndex, voted = null) {
if (this.mode === 'dm') return this.voteDmPoll(messageId, optionIndex, voted)
const channelId = this.activeChannelId
const userId = this.identity?.user?.id
const guildId = this.guild?.guild?.id
if (!channelId || !userId || !guildId) throw new Error('no active guild channel')
const span = this.log.time('poll.vote', {
spanKind: 'poll.vote',
messageId,
guildId,
channelId
})
try {
await this._assertPollPostPermission()
const msg = await this.db.get(COLLECTIONS.MESSAGES, { channelId, id: messageId })
if (!msg) throw new Error('poll message not found')
const poll = parseDmPollMessageContent(this._messagePlaintext(msg) || msg.content)
if (!poll) throw new Error('message is not a poll')
if (isPollClosed(poll)) throw new Error('poll is closed')
const voteEmoji = pollVoteEmoji(optionIndex)
if (!voteEmoji) throw new Error('invalid poll option')
const allowed = new Set(poll.options.map((o) => o.emoji))
if (!allowed.has(voteEmoji)) throw new Error('poll option out of bounds')
const rows = await this.messages.listReactions()
const hasTarget = rows.some(
(r) => r.messageId === messageId && r.emoji === voteEmoji && r.userId === userId
)
const desired = voted == null ? !hasTarget : !!voted
if (!poll.multiSelect && desired) {
for (const o of poll.options) {
if (o.emoji === voteEmoji) continue
const hasOther = rows.some(
(r) => r.messageId === messageId && r.emoji === o.emoji && r.userId === userId
)
if (hasOther) await this.toggleReaction(messageId, o.emoji)
}
}
if (hasTarget === desired) {
span.end({ messageId, idempotent: true })
return { messageId, emoji: voteEmoji, removed: !desired, idempotent: true }
}
const out = await this.toggleReaction(messageId, voteEmoji)
const resolvedOptionIndex = poll.options.findIndex((o) => o.emoji === voteEmoji)
const store = await this._ensureGuildPollStore().catch(() => null)
if (store && resolvedOptionIndex >= 0) {
if (!poll.multiSelect && desired) await store.removeUserVotes(messageId, userId)
if (desired) {
const voteRow = await store.upsertVote({
channelId,
messageId,
userId,
optionIndex: resolvedOptionIndex
})
this._gossipPollVoteIfNew({ ...voteRow, guildId, removed: false })
} else {
await store.removeVote({ channelId, messageId, userId, optionIndex: resolvedOptionIndex })
this._gossipPollVoteIfNew({
channelId,
messageId,
userId,
optionIndex: resolvedOptionIndex,
guildId,
removed: true,
votedAt: Date.now()
})
}
}
span.end({
messageId,
optionIndex: resolvedOptionIndex,
removed: !desired,
guildCount: (this.guilds || []).length
})
return { ...out, unreadImpact: 'none', mentionImpact: 'none' }
} catch (err) {
this.log.error('poll.vote error', { messageId, error: err?.message || String(err) })
span.fail(err)
throw err
}
},
async closePoll (messageId) {
if (this.mode === 'dm') return this.closeDmPoll(messageId)
const channelId = this.activeChannelId
if (!channelId) throw new Error('no active channel')
const span = this.log.time('poll.close', { spanKind: 'poll.close', messageId, channelId })
const roles = await this._memberRoles()
const canManage =
roleHasPermission(roles, PERMISSION.MANAGE_MESSAGES) ||
(await this._assertCanModifyMessage(messageId, 'edit').then(() => true).catch(() => false))
if (!canManage) throw new Error('no permission to close poll')
const msg = await this.db.get(COLLECTIONS.MESSAGES, { channelId, id: messageId })
if (!msg) throw new Error('poll message not found')
const poll = parseDmPollMessageContent(this._messagePlaintext(msg) || msg.content)
if (!poll) throw new Error('message is not a poll')
if (poll.closedAt) {
span.end({ messageId, alreadyClosed: true })
return msg
}
const body = buildDmPollMessageContent({
question: poll.question,
options: poll.options.map((o) => o.text),
multiSelect: !!poll.multiSelect,
anonymous: !!poll.anonymous,
expiresAt: poll.expiresAt,
closedAt: Date.now()
})
const closed = await this.editMessage(messageId, body)
await this._syncPollStoreFromMessage(closed)
span.end({ messageId, guildId: this.guild?.guild?.id })
return closed
},
async scheduleMessage (payload = {}) {
if (this.mode === 'dm') return this.createDmScheduledMessage(payload)
const channelId = this.activeChannelId
const userId = this.identity?.user?.id
const guildId = this.guild?.guild?.id
if (!channelId || !userId || !guildId) throw new Error('no active guild channel')
const span = this.log.time('scheduled.create', {
spanKind: 'scheduled.create',
guildId,
channelId
})
try {
await this._assertPollPostPermission()
const store = await this._ensureScheduledMessageStore()
await store.assertQueueCapacity({ guildId, channelId })
const norm = normalizeDmScheduledMessagePayload(payload)
const row = {
id: id(),
channelId,
guildId,
userId,
content: norm.content,
sendAt: norm.sendAt,
timezone: norm.timezone,
recurrence: norm.recurrence,
status: 'queued',
sendAttempts: 0,
retryAt: null,
lastError: null,
createdAt: Date.now(),
updatedAt: Date.now(),
sentMessageId: null,
sentAt: null
}
await store.upsert(row)
this._gossipScheduledIfNew(row)
this.emit('scheduled-message', row)
span.end({ scheduleId: row.id, channelId, recurrence: row.recurrence })
return row
} catch (err) {
this.log.error('scheduled.create error', { error: err?.message || String(err) })
span.fail(err)
throw err
}
},
async listScheduledMessages ({ channelId = null, includeSent = false } = {}) {
const userId = this.identity?.user?.id
const dmRows =
this.mode === 'dm' || !includeSent
? await this.listDmScheduledMessages({ channelId, includeSent })
: await this.listDmScheduledMessages({ channelId, includeSent: true })
if (this.mode !== 'guild' || !this.guild?.guild?.id) {
return dmRows.filter((r) => r.userId === userId)
}
const store = await this._ensureScheduledMessageStore().catch(() => null)
const guildRows = store
? await store.listByGuild(this.guild.guild.id, {
channelId: channelId || null,
status: includeSent ? null : 'queued',
userId
})
: []
return [...guildRows, ...dmRows.filter((r) => r.userId === userId)].sort(
(a, b) => (a.sendAt || 0) - (b.sendAt || 0)
)
},
async updateScheduledMessage (scheduleId, patch = {}) {
const userId = this.identity?.user?.id
if (!scheduleId || !userId) throw new Error('invalid scheduled message id')
const guildId = this.guild?.guild?.id
if (guildId && this.mode === 'guild') {
const store = await this._ensureScheduledMessageStore()
const row = await store.get(guildId, scheduleId)
if (row && row.userId === userId) {
if (row.status !== 'queued') throw new Error('only queued scheduled messages can be edited')
const norm = normalizeDmScheduledMessagePayload({
content: patch.content != null ? patch.content : row.content,
sendAt: patch.sendAt != null ? patch.sendAt : row.sendAt,
timezone: patch.timezone != null ? patch.timezone : row.timezone,
recurrence: patch.recurrence != null ? patch.recurrence : row.recurrence
})
const next = {
...row,
content: norm.content,
sendAt: norm.sendAt,
timezone: norm.timezone,
recurrence: norm.recurrence,
retryAt: null,
lastError: null,
updatedAt: Date.now()
}
await store.upsert(next)
this._gossipScheduledIfNew(next)
this.emit('scheduled-message', next)
return next
}
}
return this.updateDmScheduledMessage(scheduleId, patch)
},
async cancelScheduledMessage (scheduleId) {
const userId = this.identity?.user?.id
const guildId = this.guild?.guild?.id
if (guildId && this.mode === 'guild') {
const store = await this._ensureScheduledMessageStore()
const row = await store.get(guildId, scheduleId)
if (row && row.userId === userId) {
const span = this.log.time('scheduled.cancel', { spanKind: 'scheduled.cancel', scheduleId })
const next = { ...row, status: 'cancelled', updatedAt: Date.now() }
await store.upsert(next)
this._gossipScheduledIfNew(next)
this.emit('scheduled-message', next)
span.end({ scheduleId })
return next
}
}
return this.cancelDmScheduledMessage(scheduleId)
},
async sendNowScheduledMessage (scheduleId) {
const userId = this.identity?.user?.id
const guildId = this.guild?.guild?.id
if (guildId && this.mode === 'guild') {
const store = await this._ensureScheduledMessageStore()
const row = await store.get(guildId, scheduleId)
if (row && row.userId === userId && row.status === 'queued') {
const span = this.log.time('scheduled.fire', { spanKind: 'scheduled.fire', scheduleId })
const msg = await this._sendPlainMessage(row.content, {})
const next = {
...row,
status: 'sent',
sentMessageId: msg?.id || null,
sentAt: Date.now(),
updatedAt: Date.now()
}
await store.upsert(next)
this._gossipScheduledIfNew(next)
const nextSendAt = computeNextRecurrenceSendAt(row.recurrence, Date.now())
if (nextSendAt) {
const recurring = {
...row,
id: id(),
status: 'queued',
sendAt: nextSendAt,
sendAttempts: 0,
retryAt: null,
lastError: null,
sentMessageId: null,
sentAt: null,
createdAt: Date.now(),
updatedAt: Date.now()
}
await store.upsert(recurring)
this._gossipScheduledIfNew(recurring)
}
this.emit('scheduled-message', next)
span.end({ scheduleId, messageId: msg?.id })
void this.notifyScheduledMessageFired({
scheduleId,
channelId: row.channelId,
content: row.content,
sentMessageId: msg?.id
}).catch(() => null)
return next
}
}
return this.sendNowDmScheduledMessage(scheduleId)
},
async _healPollVoteVectorsOnPartition (guildId) {
const gid = guildId || this.guild?.guild?.id
if (!gid || gid === DM_GUILD_ID) return { relisted: 0, skipped: true }
const span = this.log.time('poll.heal', { spanKind: 'poll.heal', guildId: gid })
try {
const store = await this._ensureGuildPollStore()
const polls = await store.listByGuild(gid)
let relisted = 0
for (const poll of polls.slice(0, 64)) {
this._gossipPollUpsertIfNew({ ...poll, updatedAt: poll.updatedAt || Date.now() })
relisted++
const votes = await store.listVotes(poll.messageId)
for (const v of votes.slice(0, 128)) {
this._gossipPollVoteIfNew({ ...v, guildId: gid })
relisted++
}
}
span.end({ relisted, pollCount: polls.length })
return { relisted, pollCount: polls.length }
} catch (err) {
this.log.error('poll.heal error', { guildId: gid, error: err?.message || String(err) })
span.fail(err)
return { relisted: 0, error: err?.message || String(err) }
}
},
async _healScheduledQueueOnPartition (guildId) {
const gid = guildId || this.guild?.guild?.id
if (!gid || gid === DM_GUILD_ID) return { relisted: 0, skipped: true }
const span = this.log.time('scheduled.heal', { spanKind: 'scheduled.heal', guildId: gid })
try {
const store = await this._ensureScheduledMessageStore()
const rows = await store.listByGuild(gid, { status: 'queued' })
let relisted = 0
for (const row of rows.slice(0, 64)) {
this._gossipScheduledIfNew(row)
relisted++
}
span.end({ relisted, rowCount: rows.length })
return { relisted, rowCount: rows.length }
} catch (err) {
this.log.error('scheduled.heal error', { guildId: gid, error: err?.message || String(err) })
span.fail(err)
return { relisted: 0, error: err?.message || String(err) }
}
},
async _scheduledQueueSnapshotForView () {
const userId = this.identity?.user?.id
if (!userId) return []
const channelId = this.activeChannelId
const rows = await this.listScheduledMessages({
channelId: this.mode === 'guild' ? channelId : channelId,
includeSent: false
})
return rows.filter((r) => r.status === 'queued')
},
async _pollEngineSnapshotForView () {
try {
if (this.mode === 'dm') {
const store = await this._ensureDmPollStore()
return store.engine || 'json'
}
const store = await this._ensureGuildPollStore()
return store.engine || 'json'
} catch {
return 'json'
}
}
}
module.exports = { pollSchedulingMixin }