feat(announcements): Phase 680 mixin, heal, cross-post hardening (v0.8.655)

Add announcements-mixin for gossip dedupe and partition heal, announcement.follow
spans, cross-post rate limit and audit, retryAnnouncementCrosspost, text-only
target normalization, announcement search scope, and crosspostPublishResult view.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-06-02 23:39:50 -04:00
co-authored by Cursor
parent 2a1cd96b11
commit 6b983ad722
3 changed files with 336 additions and 34 deletions
+2
View File
@@ -2,6 +2,8 @@
Application facade: one `PearcordPlatform` class that wires identity, database, guild mesh, DMs, invites, voice, discovery, bots, and dozens of feature modules for the Pearcord desktop sidecar and companion. Application facade: one `PearcordPlatform` class that wires identity, database, guild mesh, DMs, invites, voice, discovery, bots, and dozens of feature modules for the Pearcord desktop sidecar and companion.
**Phase 680 (v0.8.655):** Announcement follow & cross-post — `announcements-mixin.js`, cross-post dedupe/heal/rate limit, `announcement.follow` spans, `retryAnnouncementCrosspost`, announcement search scope. Bundle: `npm run test:ci-phase680`.
**Phase 679 (v0.8.654):** Thread panel & forum parity — `threads-forum.js` mixin, `createThread` auto-archive, `_healThreadMetadataOnPartition` watermark + auto-archive heal, guild-scoped `buildThreadsPanel`, deep link `create-thread` query. Bundle: `npm run test:ci-phase679`. **Phase 679 (v0.8.654):** Thread panel & forum parity — `threads-forum.js` mixin, `createThread` auto-archive, `_healThreadMetadataOnPartition` watermark + auto-archive heal, guild-scoped `buildThreadsPanel`, deep link `create-thread` query. Bundle: `npm run test:ci-phase679`.
**Phase 678 (v0.8.653):** Invites & discovery join — `invites-discovery.js` mixin (`listGuildInvites`, `_healInvitesOnPartition`, `_healDiscoveryListingsOnPartition`, `_shouldGossipInvite`); view `guildInvites`, `inviteCount`, `discoveryListingVersion`; `createInvite` ttl/maxUses validation. Bundle: `npm run test:ci-phase678`. **Phase 678 (v0.8.653):** Invites & discovery join — `invites-discovery.js` mixin (`listGuildInvites`, `_healInvitesOnPartition`, `_healDiscoveryListingsOnPartition`, `_shouldGossipInvite`); view `guildInvites`, `inviteCount`, `discoveryListingVersion`; `createInvite` ttl/maxUses validation. Bundle: `npm run test:ci-phase678`.
+154
View File
@@ -0,0 +1,154 @@
'use strict'
const { isAnnouncementChannel } = require('pearcord-threads')
const announcementsMixin = {
_announcementFollowHealWatermark: null,
_announcementCrosspostHealWatermark: null,
_announcementCrosspostGossipKeys: null,
_announcementFollowGossipKeys: null,
_crosspostRateByGuild: null,
_lastCrosspostPublish: null,
_initAnnouncementMixinState () {
if (!this._announcementCrosspostGossipKeys) {
this._announcementCrosspostGossipKeys = new Set()
}
if (!this._announcementFollowGossipKeys) {
this._announcementFollowGossipKeys = new Set()
}
if (!this._crosspostRateByGuild) {
this._crosspostRateByGuild = new Map()
}
},
_shouldGossipAnnouncementCrosspost (row) {
this._initAnnouncementMixinState()
if (!row?.guildId || !row?.sourceChannelId) return true
const ids = (row.targetChannelIds || []).join(',')
const hash = `${row.guildId}:${row.sourceChannelId}:${ids}:${row.updatedAt || 0}`
if (this._announcementCrosspostGossipKeys.has(hash)) return false
this._announcementCrosspostGossipKeys.add(hash)
if (this._announcementCrosspostGossipKeys.size > 4096) {
const first = this._announcementCrosspostGossipKeys.values().next().value
if (first) this._announcementCrosspostGossipKeys.delete(first)
}
return true
},
_shouldGossipAnnouncementFollow (row) {
this._initAnnouncementMixinState()
if (!row?.guildId || !row?.channelId || !row?.userId) return true
const hash = `${row.guildId}:${row.channelId}:${row.userId}:${row.active ? '1' : '0'}:${row.updatedAt || 0}`
if (this._announcementFollowGossipKeys.has(hash)) return false
this._announcementFollowGossipKeys.add(hash)
if (this._announcementFollowGossipKeys.size > 8192) {
const first = this._announcementFollowGossipKeys.values().next().value
if (first) this._announcementFollowGossipKeys.delete(first)
}
return true
},
_checkCrosspostPublishRateLimit (guildId) {
this._initAnnouncementMixinState()
const gid = guildId || this.guild?.guild?.id
if (!gid) return true
const cap = 12
const windowMs = 60_000
const now = Date.now()
let bucket = this._crosspostRateByGuild.get(gid)
if (!bucket || now >= bucket.resetAt) {
bucket = { count: 0, resetAt: now + windowMs }
}
if (bucket.count >= cap) return false
bucket.count++
this._crosspostRateByGuild.set(gid, bucket)
return true
},
async _healAnnouncementCrosspostOnPartition (guildId) {
const gid = guildId || this.guild?.guild?.id || null
const span = this.log.time('announcement.heal', {
spanKind: 'announcement.heal',
guildId: gid,
slice: 'crosspost'
})
try {
if (!gid || !this.guild?.listChannels || !this.announcements) {
span.end({ relisted: 0, skipped: true })
return { relisted: 0, skipped: true }
}
const channels = await this.guild.listChannels().catch(() => [])
let relisted = 0
for (const ch of channels) {
if (!isAnnouncementChannel(ch) || ch.guildId && ch.guildId !== gid) continue
const targets = await this.announcements.getCrosspostTargets(gid, ch.id)
const row = {
guildId: gid,
sourceChannelId: ch.id,
targetChannelIds: targets,
updatedAt: Date.now()
}
if (this._shouldGossipAnnouncementCrosspost(row) && this.guild?.gossipAnnouncementCrosspost) {
this.guild.gossipAnnouncementCrosspost(row)
relisted++
}
}
const watermark = Date.now()
this._announcementCrosspostHealWatermark = watermark
span.end({ relisted, watermark })
return { relisted, watermark }
} catch (err) {
this.log.error('announcement.heal error', {
guildId: gid,
slice: 'crosspost',
error: err?.message || String(err)
})
span.fail(err)
return { relisted: 0, error: err?.message || String(err) }
}
},
async _healAnnouncementFollowsOnPartition (guildId) {
const gid = guildId || this.guild?.guild?.id || null
const span = this.log.time('announcement.heal', {
spanKind: 'announcement.heal',
guildId: gid,
slice: 'follow'
})
try {
if (!gid || !this.announcements?.store) {
span.end({ relisted: 0, skipped: true })
return { relisted: 0, skipped: true }
}
const userId = this.identity?.user?.id
if (!userId) {
span.end({ relisted: 0, skipped: true })
return { relisted: 0, skipped: true }
}
const follows = await this.announcements.listMyFollows(userId, gid)
let relisted = 0
for (const row of follows) {
if (row.guildId !== gid) continue
if (this._shouldGossipAnnouncementFollow(row) && this.guild?.gossipAnnouncementFollow) {
this.guild.gossipAnnouncementFollow(row)
relisted++
}
}
const watermark = Date.now()
this._announcementFollowHealWatermark = watermark
span.end({ relisted, watermark, followCount: follows.length })
return { relisted, watermark, followCount: follows.length }
} catch (err) {
this.log.error('announcement.heal error', {
guildId: gid,
slice: 'follow',
error: err?.message || String(err)
})
span.fail(err)
return { relisted: 0, error: err?.message || String(err) }
}
}
}
module.exports = { announcementsMixin }
+180 -34
View File
@@ -1565,6 +1565,12 @@ class PearcordPlatform extends EventEmitter {
const invitesHeal = await this._healInvitesOnPartition(gid).catch(() => ({ relisted: 0 })) const invitesHeal = await this._healInvitesOnPartition(gid).catch(() => ({ relisted: 0 }))
const discoveryHeal = await this._healDiscoveryListingsOnPartition(gid).catch(() => ({ relisted: 0 })) const discoveryHeal = await this._healDiscoveryListingsOnPartition(gid).catch(() => ({ relisted: 0 }))
const forumHeal = await this._healForumTagPaletteOnPartition(gid).catch(() => ({ relisted: 0 })) const forumHeal = await this._healForumTagPaletteOnPartition(gid).catch(() => ({ relisted: 0 }))
const announcementCrosspostHeal = await this._healAnnouncementCrosspostOnPartition(gid).catch(() => ({
relisted: 0
}))
const announcementFollowHeal = await this._healAnnouncementFollowsOnPartition(gid).catch(() => ({
relisted: 0
}))
return { return {
voiceApplied, voiceApplied,
emojiSlots, emojiSlots,
@@ -1589,7 +1595,9 @@ class PearcordPlatform extends EventEmitter {
moderationHeal, moderationHeal,
invitesHeal, invitesHeal,
discoveryHeal, discoveryHeal,
forumHeal forumHeal,
announcementCrosspostHeal,
announcementFollowHeal
} }
} }
@@ -4658,7 +4666,9 @@ class PearcordPlatform extends EventEmitter {
setSearch ({ query, scope, includeMesh }) { setSearch ({ query, scope, includeMesh }) {
const nextQuery = String(query || '').trim() const nextQuery = String(query || '').trim()
const nextScope = const nextScope =
scope === 'guild' || scope === 'forum' ? scope : 'channel' scope === 'guild' || scope === 'forum' || scope === 'announcement'
? scope
: 'channel'
const nextMesh = const nextMesh =
includeMesh !== undefined ? includeMesh !== false : this._searchIncludeMesh includeMesh !== undefined ? includeMesh !== false : this._searchIncludeMesh
if ( if (
@@ -4840,9 +4850,18 @@ class PearcordPlatform extends EventEmitter {
return { refreshed: false, reason: 'no-viewable-channels', meta: this._lastSearchMeshMeta } return { refreshed: false, reason: 'no-viewable-channels', meta: this._lastSearchMeshMeta }
} }
const scope = this._searchScope const scope = this._searchScope
if (scope !== 'guild' && scope !== 'forum' && scope !== 'channel') { if (scope !== 'guild' && scope !== 'forum' && scope !== 'channel' && scope !== 'announcement') {
return { refreshed: false, reason: 'scope', meta: this._lastSearchMeshMeta } return { refreshed: false, reason: 'scope', meta: this._lastSearchMeshMeta }
} }
if (scope === 'announcement') {
const ch = await this.db.get(COLLECTIONS.CHANNELS, {
guildId: this.guild.guild.id,
id: this.activeChannelId
})
if (!ch || !isAnnouncementChannel(ch)) {
return { refreshed: false, reason: 'announcement-inactive', meta: this._lastSearchMeshMeta }
}
}
if (scope === 'channel' && !this.activeChannelId) { if (scope === 'channel' && !this.activeChannelId) {
return { refreshed: false, reason: 'no-channel', meta: this._lastSearchMeshMeta } return { refreshed: false, reason: 'no-channel', meta: this._lastSearchMeshMeta }
} }
@@ -5238,6 +5257,28 @@ class PearcordPlatform extends EventEmitter {
this._commitSearchCache(cacheKey, hits) this._commitSearchCache(cacheKey, hits)
return hits return hits
} }
if (this._searchScope === 'announcement') {
const channels = await this.guild.listChannels()
const annIds = new Set(channels.filter((c) => isAnnouncementChannel(c)).map((c) => c.id))
const out = await this.searchGuildMessagesWithMesh(this.guild.guild.id, q, {
limit: 50,
mesh: this._searchIncludeMesh
})
let filtered = await this._applySearchQueryFilters(out.hits, q, this.guild.guild.id)
filtered = filtered.filter((h) => annIds.has(h.channelId))
this._lastSearchMeshMeta = {
mesh: out.mesh,
meshHitCount: out.meshHitCount || 0,
localHitCount: filtered.length,
scope: 'announcement',
fetchedAt: Date.now(),
refreshing: false
}
this._searchFetchedAt = this._lastSearchMeshMeta.fetchedAt
endSearchSpan({ count: filtered.length, scope: 'announcement' })
this._commitSearchCache(cacheKey, filtered)
return filtered
}
if (this._searchScope === 'guild') { if (this._searchScope === 'guild') {
const out = await this.searchGuildMessagesWithMesh(this.guild.guild.id, q, { const out = await this.searchGuildMessagesWithMesh(this.guild.guild.id, q, {
limit: 50, limit: 50,
@@ -16970,7 +17011,8 @@ class PearcordPlatform extends EventEmitter {
guildId: parsed.guildId, guildId: parsed.guildId,
channelId: parsed.channelId, channelId: parsed.channelId,
openCreateThread: !!parsed.createThreadMessageId, openCreateThread: !!parsed.createThreadMessageId,
messageId: parsed.createThreadMessageId || null messageId: parsed.createThreadMessageId || null,
openFollowAnnouncement: !!parsed.followAnnouncement
} }
} }
@@ -21492,15 +21534,38 @@ class PearcordPlatform extends EventEmitter {
sourceMsg.guildId, sourceMsg.guildId,
sourceMsg.channelId sourceMsg.channelId
) )
if (!targets.length) return [] if (!this._checkCrosspostPublishRateLimit(sourceMsg.guildId)) {
this._lastCrosspostPublish = {
messageId: sourceMsg.id,
mirrored: 0,
failed: targets.length ? [{ error: 'rate limited' }] : [],
rateLimited: true
}
return []
}
if (!targets.length) {
this._lastCrosspostPublish = {
messageId: sourceMsg.id,
mirrored: 0,
failed: [],
rateLimited: false
}
return []
}
const channels = await this.guild.listChannels() const channels = await this.guild.listChannels()
const body = formatCrosspostBody(sourceCh.name, sourceMsg.content) const body = formatCrosspostBody(sourceCh.name, sourceMsg.content)
const stickerNames = (sourceMsg.stickerNames || []).slice(0, 3) const stickerNames = (sourceMsg.stickerNames || []).slice(0, 3)
const out = [] const out = []
const failed = []
for (const targetId of targets) { for (const targetId of targets) {
if (targetId === sourceMsg.channelId) continue if (targetId === sourceMsg.channelId) continue
const tch = channels.find((c) => c.id === targetId) const tch = channels.find((c) => c.id === targetId)
if (!tch || tch.type !== 'text') continue if (!tch || tch.type !== 'text') {
if (tch?.type === 'forum' || tch?.type === 'voice' || tch?.type === 'stage') {
failed.push({ targetId, error: 'invalid target type' })
}
continue
}
try { try {
const row = await this._insertGuildMessage({ const row = await this._insertGuildMessage({
guildId: sourceMsg.guildId, guildId: sourceMsg.guildId,
@@ -21510,11 +21575,24 @@ class PearcordPlatform extends EventEmitter {
stickerNames: stickerNames.length ? stickerNames : undefined stickerNames: stickerNames.length ? stickerNames : undefined
}) })
out.push(row) out.push(row)
} catch { } catch (err) {
// skip invalid target failed.push({ targetId, error: err?.message || String(err) })
} }
} }
if (out.length) this.emit('message') this._lastCrosspostPublish = {
messageId: sourceMsg.id,
mirrored: out.length,
failed,
rateLimited: false
}
if (out.length) {
await this._audit('announcement.crosspost', {
guildId: sourceMsg.guildId,
targetId: sourceMsg.channelId,
meta: { mirrored: out.length, failed: failed.length }
}).catch(() => {})
this.emit('message')
}
return out return out
} }
@@ -21555,16 +21633,38 @@ class PearcordPlatform extends EventEmitter {
id: sourceChannelId id: sourceChannelId
}) })
if (!ch || !isAnnouncementChannel(ch)) throw new Error('not an announcement channel') if (!ch || !isAnnouncementChannel(ch)) throw new Error('not an announcement channel')
const channels = await this.guild.listChannels()
const normalized = (targetChannelIds || []).filter((id) => {
const tch = channels.find((c) => c.id === id)
return tch?.type === 'text'
})
const row = await this.announcements.setCrosspostTargets( const row = await this.announcements.setCrosspostTargets(
this.guild.guild.id, this.guild.guild.id,
sourceChannelId, sourceChannelId,
targetChannelIds normalized
) )
this.guild.gossipAnnouncementCrosspost(row) if (this._shouldGossipAnnouncementCrosspost(row)) {
this.guild.gossipAnnouncementCrosspost(row)
}
this.emit('announcements', row) this.emit('announcements', row)
return row return row
} }
async retryAnnouncementCrosspost (messageId) {
if (!this.guild?.guild) throw new Error('no guild')
const msg = await this.db.get(COLLECTIONS.MESSAGES, {
guildId: this.guild.guild.id,
id: messageId
})
if (!msg) throw new Error('message not found')
const srcCh = await this.db.get(COLLECTIONS.CHANNELS, {
guildId: this.guild.guild.id,
id: msg.channelId
})
if (!srcCh || !isAnnouncementChannel(srcCh)) throw new Error('not an announcement message')
return this._crosspostAnnouncement(msg, srcCh)
}
async getAnnouncementCrosspostTargets (sourceChannelId) { async getAnnouncementCrosspostTargets (sourceChannelId) {
await this._ensureGuildModules() await this._ensureGuildModules()
if (!this.guild?.guild) return [] if (!this.guild?.guild) return []
@@ -21575,21 +21675,40 @@ class PearcordPlatform extends EventEmitter {
} }
async followAnnouncementChannel (channelId) { async followAnnouncementChannel (channelId) {
if (!this.guild?.guild || !this.identity?.user) throw new Error('no guild') const guildId = this.guild?.guild?.id || null
const ch = await this.db.get(COLLECTIONS.CHANNELS, { const span = this.log.time('announcement.follow', {
guildId: this.guild.guild.id, spanKind: 'announcement.follow',
id: channelId guildId,
channelId
}) })
if (!ch || !isAnnouncementChannel(ch)) throw new Error('not an announcement channel') try {
await this._ensureGuildModules() if (!this.guild?.guild || !this.identity?.user) throw new Error('no guild')
const row = await this.announcements.follow( const ch = await this.db.get(COLLECTIONS.CHANNELS, {
this.guild.guild.id, guildId: this.guild.guild.id,
channelId, id: channelId
this.identity.user.id })
) if (!ch || !isAnnouncementChannel(ch)) throw new Error('not an announcement channel')
this.guild.gossipAnnouncementFollow(row) await this._ensureGuildModules()
this.emit('announcements', row) const row = await this.announcements.follow(
return row this.guild.guild.id,
channelId,
this.identity.user.id
)
if (this._shouldGossipAnnouncementFollow(row)) {
this.guild.gossipAnnouncementFollow(row)
}
this.emit('announcements', row)
span.end({ guildId, channelId, active: true, nsfw: !!ch.nsfw })
return row
} catch (err) {
this.log.error('announcement.follow error', {
guildId,
channelId,
error: err?.message || String(err)
})
span.fail(err)
throw err
}
} }
async _gossipGuildBoostState (row, attribution) { async _gossipGuildBoostState (row, attribution) {
@@ -21764,16 +21883,36 @@ class PearcordPlatform extends EventEmitter {
} }
async unfollowAnnouncementChannel (channelId) { async unfollowAnnouncementChannel (channelId) {
if (!this.guild?.guild || !this.identity?.user) throw new Error('no guild') const guildId = this.guild?.guild?.id || null
await this._ensureGuildModules() const span = this.log.time('announcement.follow', {
const row = await this.announcements.unfollow( spanKind: 'announcement.follow',
this.guild.guild.id, guildId,
channelId, channelId,
this.identity.user.id active: false
) })
this.guild.gossipAnnouncementFollow(row) try {
this.emit('announcements', row) if (!this.guild?.guild || !this.identity?.user) throw new Error('no guild')
return row await this._ensureGuildModules()
const row = await this.announcements.unfollow(
this.guild.guild.id,
channelId,
this.identity.user.id
)
if (row && this._shouldGossipAnnouncementFollow(row)) {
this.guild.gossipAnnouncementFollow(row)
}
this.emit('announcements', row)
span.end({ guildId, channelId, active: false })
return row
} catch (err) {
this.log.error('announcement.follow error', {
guildId,
channelId,
error: err?.message || String(err)
})
span.fail(err)
throw err
}
} }
async sendMessage (content, opts = {}) { async sendMessage (content, opts = {}) {
@@ -25539,6 +25678,11 @@ class PearcordPlatform extends EventEmitter {
announcementCrosspostByChannel, announcementCrosspostByChannel,
announcementFollowing, announcementFollowing,
announcementFollows, announcementFollows,
announcementVersion:
announcementFollows.length +
Object.keys(announcementCrosspostByChannel || {}).length +
Number(this._announcementCrosspostHealWatermark ? 1 : 0),
crosspostPublishResult: this._lastCrosspostPublish || null,
announcementHubPublishers, announcementHubPublishers,
remoteAnnouncementHubSubs, remoteAnnouncementHubSubs,
guildBoost, guildBoost,
@@ -26098,9 +26242,11 @@ const { userSettingsMixin } = require('./user-settings')
const { moderationMixin } = require('./moderation') const { moderationMixin } = require('./moderation')
const { invitesDiscoveryMixin } = require('./invites-discovery') const { invitesDiscoveryMixin } = require('./invites-discovery')
const { threadsForumMixin } = require('./threads-forum') const { threadsForumMixin } = require('./threads-forum')
const { announcementsMixin } = require('./announcements-mixin')
Object.assign(PearcordPlatform.prototype, pollSchedulingMixin) Object.assign(PearcordPlatform.prototype, pollSchedulingMixin)
Object.assign(PearcordPlatform.prototype, notificationsActivityMixin) Object.assign(PearcordPlatform.prototype, notificationsActivityMixin)
Object.assign(PearcordPlatform.prototype, userSettingsMixin) Object.assign(PearcordPlatform.prototype, userSettingsMixin)
Object.assign(PearcordPlatform.prototype, moderationMixin) Object.assign(PearcordPlatform.prototype, moderationMixin)
Object.assign(PearcordPlatform.prototype, invitesDiscoveryMixin) Object.assign(PearcordPlatform.prototype, invitesDiscoveryMixin)
Object.assign(PearcordPlatform.prototype, threadsForumMixin) Object.assign(PearcordPlatform.prototype, threadsForumMixin)
Object.assign(PearcordPlatform.prototype, announcementsMixin)