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:
@@ -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`.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user