'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 }