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]>
540 lines
19 KiB
JavaScript
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 }
|