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.
**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 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 discoveryHeal = await this._healDiscoveryListingsOnPartition(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 {
voiceApplied,
emojiSlots,
@@ -1589,7 +1595,9 @@ class PearcordPlatform extends EventEmitter {
moderationHeal,
invitesHeal,
discoveryHeal,
forumHeal
forumHeal,
announcementCrosspostHeal,
announcementFollowHeal
}
}
@@ -4658,7 +4666,9 @@ class PearcordPlatform extends EventEmitter {
setSearch ({ query, scope, includeMesh }) {
const nextQuery = String(query || '').trim()
const nextScope =
scope === 'guild' || scope === 'forum' ? scope : 'channel'
scope === 'guild' || scope === 'forum' || scope === 'announcement'
? scope
: 'channel'
const nextMesh =
includeMesh !== undefined ? includeMesh !== false : this._searchIncludeMesh
if (
@@ -4840,9 +4850,18 @@ class PearcordPlatform extends EventEmitter {
return { refreshed: false, reason: 'no-viewable-channels', meta: this._lastSearchMeshMeta }
}
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 }
}
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) {
return { refreshed: false, reason: 'no-channel', meta: this._lastSearchMeshMeta }
}
@@ -5238,6 +5257,28 @@ class PearcordPlatform extends EventEmitter {
this._commitSearchCache(cacheKey, 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') {
const out = await this.searchGuildMessagesWithMesh(this.guild.guild.id, q, {
limit: 50,
@@ -16970,7 +17011,8 @@ class PearcordPlatform extends EventEmitter {
guildId: parsed.guildId,
channelId: parsed.channelId,
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.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 body = formatCrosspostBody(sourceCh.name, sourceMsg.content)
const stickerNames = (sourceMsg.stickerNames || []).slice(0, 3)
const out = []
const failed = []
for (const targetId of targets) {
if (targetId === sourceMsg.channelId) continue
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 {
const row = await this._insertGuildMessage({
guildId: sourceMsg.guildId,
@@ -21510,11 +21575,24 @@ class PearcordPlatform extends EventEmitter {
stickerNames: stickerNames.length ? stickerNames : undefined
})
out.push(row)
} catch {
// skip invalid target
} catch (err) {
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
}
@@ -21555,16 +21633,38 @@ class PearcordPlatform extends EventEmitter {
id: sourceChannelId
})
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(
this.guild.guild.id,
sourceChannelId,
targetChannelIds
normalized
)
this.guild.gossipAnnouncementCrosspost(row)
if (this._shouldGossipAnnouncementCrosspost(row)) {
this.guild.gossipAnnouncementCrosspost(row)
}
this.emit('announcements', 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) {
await this._ensureGuildModules()
if (!this.guild?.guild) return []
@@ -21575,21 +21675,40 @@ class PearcordPlatform extends EventEmitter {
}
async followAnnouncementChannel (channelId) {
if (!this.guild?.guild || !this.identity?.user) throw new Error('no guild')
const ch = await this.db.get(COLLECTIONS.CHANNELS, {
guildId: this.guild.guild.id,
id: channelId
const guildId = this.guild?.guild?.id || null
const span = this.log.time('announcement.follow', {
spanKind: 'announcement.follow',
guildId,
channelId
})
if (!ch || !isAnnouncementChannel(ch)) throw new Error('not an announcement channel')
await this._ensureGuildModules()
const row = await this.announcements.follow(
this.guild.guild.id,
channelId,
this.identity.user.id
)
this.guild.gossipAnnouncementFollow(row)
this.emit('announcements', row)
return row
try {
if (!this.guild?.guild || !this.identity?.user) throw new Error('no guild')
const ch = await this.db.get(COLLECTIONS.CHANNELS, {
guildId: this.guild.guild.id,
id: channelId
})
if (!ch || !isAnnouncementChannel(ch)) throw new Error('not an announcement channel')
await this._ensureGuildModules()
const row = await this.announcements.follow(
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) {
@@ -21764,16 +21883,36 @@ class PearcordPlatform extends EventEmitter {
}
async unfollowAnnouncementChannel (channelId) {
if (!this.guild?.guild || !this.identity?.user) throw new Error('no guild')
await this._ensureGuildModules()
const row = await this.announcements.unfollow(
this.guild.guild.id,
const guildId = this.guild?.guild?.id || null
const span = this.log.time('announcement.follow', {
spanKind: 'announcement.follow',
guildId,
channelId,
this.identity.user.id
)
this.guild.gossipAnnouncementFollow(row)
this.emit('announcements', row)
return row
active: false
})
try {
if (!this.guild?.guild || !this.identity?.user) throw new Error('no guild')
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 = {}) {
@@ -25539,6 +25678,11 @@ class PearcordPlatform extends EventEmitter {
announcementCrosspostByChannel,
announcementFollowing,
announcementFollows,
announcementVersion:
announcementFollows.length +
Object.keys(announcementCrosspostByChannel || {}).length +
Number(this._announcementCrosspostHealWatermark ? 1 : 0),
crosspostPublishResult: this._lastCrosspostPublish || null,
announcementHubPublishers,
remoteAnnouncementHubSubs,
guildBoost,
@@ -26098,9 +26242,11 @@ const { userSettingsMixin } = require('./user-settings')
const { moderationMixin } = require('./moderation')
const { invitesDiscoveryMixin } = require('./invites-discovery')
const { threadsForumMixin } = require('./threads-forum')
const { announcementsMixin } = require('./announcements-mixin')
Object.assign(PearcordPlatform.prototype, pollSchedulingMixin)
Object.assign(PearcordPlatform.prototype, notificationsActivityMixin)
Object.assign(PearcordPlatform.prototype, userSettingsMixin)
Object.assign(PearcordPlatform.prototype, moderationMixin)
Object.assign(PearcordPlatform.prototype, invitesDiscoveryMixin)
Object.assign(PearcordPlatform.prototype, threadsForumMixin)
Object.assign(PearcordPlatform.prototype, announcementsMixin)