feat(v0.8.645): Phase 670 sticker/GIF mesh hardening and platform spans
Sticker and GIF rate limits, gossip dedupe, partition heal, pack reorder, announcement sticker fanout, DM sticker notifications, and extended sticker.send/sticker.create span metadata. 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 670 (v0.8.645):** Sticker/GIF depth — rate limits, gossip dedupe, partition heal, `reorderStickerPacks`, `packName`/`slotCount` spans, announcement sticker fanout, `_maybeNotifyDmSticker`. Bundle: `npm run test:ci-phase670`.
|
||||
|
||||
**Phase 669 (v0.8.644):** Reaction depth — `_checkReactionToggleRateLimit`, `_shouldGossipReaction` dedupe, `_healReactionListOnPartition`, `removeMemberReaction` + audit, `reaction.toggle` span `emojiName`, `notifyOnReaction` DM path, `reactionTotalsByMessage` for thread badges, frequent-reaction pref recording. Bundle: `npm run test:ci-phase669`.
|
||||
|
||||
**Phase 668 (v0.8.643):** Composer embed preview — `prefetchComposerEmbed`, `clearComposerEmbedPreview`, `composerEmbedPreview` view slice; guild embed resolve rate limit; DM gossip bandwidth cap; URL-hash dedupe; domain block/allowlist; automation embed opt-out; scheduled/webhook embed queue guards. Bundle: `npm run test:ci-phase668`.
|
||||
|
||||
@@ -592,6 +592,12 @@ class PearcordPlatform extends EventEmitter {
|
||||
this._reactionGossipKeys = new Set()
|
||||
/** Per-channel reaction toggle rate limit (Phase 669). */
|
||||
this._reactionToggleRateByChannel = new Map()
|
||||
/** Dedupe sticker gossip fanout (Phase 670). */
|
||||
this._stickerGossipKeys = new Set()
|
||||
/** Per-channel sticker send rate limit (Phase 670). */
|
||||
this._stickerSendRateByChannel = new Map()
|
||||
/** Per-channel GIF attachment send rate limit (Phase 670). */
|
||||
this._gifSendRateByChannel = new Map()
|
||||
}
|
||||
|
||||
_getGuildOpenSlowThresholdMs () {
|
||||
@@ -1528,7 +1534,10 @@ class PearcordPlatform extends EventEmitter {
|
||||
const reactionHeal = await this._healReactionListOnPartition(gid).catch(() => ({
|
||||
relisted: 0
|
||||
}))
|
||||
return { voiceApplied, emojiSlots, automodReconciled, voiceNoteHeal, attachmentHeal, embedHeal, reactionHeal }
|
||||
const stickerHeal = await this._healStickerRegistryOnPartition(gid).catch(() => ({
|
||||
relisted: 0
|
||||
}))
|
||||
return { voiceApplied, emojiSlots, automodReconciled, voiceNoteHeal, attachmentHeal, embedHeal, reactionHeal, stickerHeal }
|
||||
}
|
||||
|
||||
async _healReactionListOnPartition (guildId) {
|
||||
@@ -1549,6 +1558,31 @@ class PearcordPlatform extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
async _healStickerRegistryOnPartition (guildId) {
|
||||
const gid = guildId || this.guild?.guild?.id
|
||||
if (!gid || !this.stickerRegistry) return { relisted: 0, skipped: true }
|
||||
const span = this.log.time('sticker.heal', {
|
||||
spanKind: 'sticker.heal',
|
||||
guildId: gid
|
||||
})
|
||||
try {
|
||||
const rows = (await this.stickerRegistry.list().catch(() => [])).filter(
|
||||
(r) => !r.guildId || r.guildId === gid
|
||||
)
|
||||
if (this.guild?.gossipStickerUpsert) {
|
||||
for (const row of rows.slice(0, 64)) {
|
||||
if (this._shouldGossipSticker(row)) this.guild.gossipStickerUpsert(row)
|
||||
}
|
||||
}
|
||||
span.end({ relisted: rows.length, rowCount: rows.length })
|
||||
return { relisted: rows.length, rowCount: rows.length }
|
||||
} catch (err) {
|
||||
this.log.error('sticker.heal error', { error: err?.message || String(err) })
|
||||
span.fail(err)
|
||||
return { relisted: 0, error: err?.message || String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
async _healStaleEmbedCache (_guildId) {
|
||||
if (!this.embeds?.healStaleEntries) return { healed: 0, skipped: true }
|
||||
const span = this.log.time('embed.heal', {
|
||||
@@ -8896,6 +8930,9 @@ class PearcordPlatform extends EventEmitter {
|
||||
const reactionHeal = await this._healReactionListOnPartition(guildId).catch(() => ({
|
||||
relisted: 0
|
||||
}))
|
||||
const stickerHeal = await this._healStickerRegistryOnPartition(guildId).catch(() => ({
|
||||
relisted: 0
|
||||
}))
|
||||
const settingsDeviceSync = await this._healSettingsMeshDeviceSync(guildId).catch(() => null)
|
||||
const req = this.requestGuildSyncFromHost()
|
||||
const burst = this.requestGuildSyncBurst({ clearPushHalt: false, forcePush: true })
|
||||
@@ -8920,6 +8957,7 @@ class PearcordPlatform extends EventEmitter {
|
||||
contactsReconciled,
|
||||
extendedHeal,
|
||||
reactionHeal,
|
||||
stickerHeal,
|
||||
settingsDeviceSync,
|
||||
syncRequested: !!req?.requested,
|
||||
syncFanoutAckCount: req?.ackCount ?? 0,
|
||||
@@ -13205,6 +13243,78 @@ class PearcordPlatform extends EventEmitter {
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
_checkStickerSendRateLimit (channelId) {
|
||||
const key = channelId || this.activeChannelId || 'unknown'
|
||||
const windowMs = 60_000
|
||||
const cap = Math.max(4, Number(process.env.PEARCORD_STICKER_SEND_RATE_PER_MIN) || 30)
|
||||
const now = Date.now()
|
||||
let bucket = this._stickerSendRateByChannel.get(key)
|
||||
if (!bucket || now - bucket.resetAt > windowMs) {
|
||||
bucket = { count: 0, resetAt: now }
|
||||
}
|
||||
if (bucket.count >= cap) return false
|
||||
bucket.count++
|
||||
this._stickerSendRateByChannel.set(key, bucket)
|
||||
return true
|
||||
}
|
||||
|
||||
_checkGifSendRateLimit (channelId) {
|
||||
const key = channelId || this.activeChannelId || 'unknown'
|
||||
const windowMs = 60_000
|
||||
const cap = Math.max(4, Number(process.env.PEARCORD_GIF_SEND_RATE_PER_MIN) || 20)
|
||||
const now = Date.now()
|
||||
let bucket = this._gifSendRateByChannel.get(key)
|
||||
if (!bucket || now - bucket.resetAt > windowMs) {
|
||||
bucket = { count: 0, resetAt: now }
|
||||
}
|
||||
if (bucket.count >= cap) return false
|
||||
bucket.count++
|
||||
this._gifSendRateByChannel.set(key, bucket)
|
||||
return true
|
||||
}
|
||||
|
||||
_shouldGossipSticker (row) {
|
||||
if (!row?.guildId || !row?.name) return true
|
||||
const hash = `${row.guildId}:${row.name}:${row.updatedAt || 0}`
|
||||
if (this._stickerGossipKeys.has(hash)) return false
|
||||
this._stickerGossipKeys.add(hash)
|
||||
if (this._stickerGossipKeys.size > 4096) {
|
||||
const first = this._stickerGossipKeys.values().next().value
|
||||
if (first) this._stickerGossipKeys.delete(first)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async _recordFrequentStickerPref (name) {
|
||||
if (!this.userSettings || !name) return
|
||||
const prefs = await this.userSettings.getPrefs().catch(() => null)
|
||||
if (!prefs) return
|
||||
const token = String(name).trim().toLowerCase()
|
||||
const prev = Array.isArray(prefs.frequentStickers) ? prefs.frequentStickers : []
|
||||
const next = [token, ...prev.filter((e) => e !== token)].slice(0, 8)
|
||||
if (next.join('|') === prev.join('|')) return
|
||||
await this.userSettings.setPrefs({ frequentStickers: next }, { gossip: false })
|
||||
}
|
||||
|
||||
async _maybeNotifyDmSticker ({ message, stickerNames }) {
|
||||
if (!this.notifications || !message || !(stickerNames || []).length) return
|
||||
const prefs = await this.notifications.getPrefs().catch(() => null)
|
||||
if (prefs?.notifyOnSticker === false) return
|
||||
const userId = this.identity.user?.id
|
||||
if (!userId || message.authorId === userId) return
|
||||
if (this.mode !== 'dm') return
|
||||
const names = (stickerNames || []).slice(0, 3).map((n) => `:${n}:`).join(' ')
|
||||
await this.notifications
|
||||
.ingest({
|
||||
kind: 'message',
|
||||
channelId: message.channelId,
|
||||
messageId: message.id,
|
||||
authorId: message.authorId,
|
||||
title: `Sticker message ${names}`
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
/** Re-resolve embed for an existing message (read-only fetch; ignores slowmode send gates). */
|
||||
async retryLinkEmbedForMessage (messageId) {
|
||||
if (!this.embeds || !messageId) throw new Error('embed retry requires messageId')
|
||||
@@ -17599,25 +17709,39 @@ class PearcordPlatform extends EventEmitter {
|
||||
if (this.mode !== 'guild' || !this.guild?.guild) {
|
||||
throw new Error('stickers are only supported in guild channels')
|
||||
}
|
||||
if (!this._checkStickerSendRateLimit(this.activeChannelId)) {
|
||||
throw new Error('sticker send rate limited')
|
||||
}
|
||||
const perms = await this._myPermissions(this.activeChannelId).catch(() => null)
|
||||
if (perms && perms.sendMessages === false) {
|
||||
throw new Error('missing permission: send stickers')
|
||||
}
|
||||
await this._ensureGuildModules()
|
||||
if (!this.stickerRegistry) await this._initStickerRegistry(this.guild.guild.id)
|
||||
const out = []
|
||||
const packNames = []
|
||||
for (const name of names) {
|
||||
const row = await this.stickerRegistry.get(name)
|
||||
if (!row) throw new Error(`unknown sticker: ${name}`)
|
||||
out.push(row.name)
|
||||
if (row.packName) packNames.push(row.packName)
|
||||
await this._recordFrequentStickerPref(row.name)
|
||||
}
|
||||
span.end({
|
||||
resolved: out.length,
|
||||
names: out,
|
||||
packName: packNames[0] || null,
|
||||
packNames,
|
||||
spanKind: 'sticker.send',
|
||||
activeChannelId: this.activeChannelId,
|
||||
guildId: this.guild.guild.id,
|
||||
guildCount: (this.guilds || []).length
|
||||
})
|
||||
return out
|
||||
} catch (err) {
|
||||
this.log.error('sticker.send error', {
|
||||
channelId: this.activeChannelId || null,
|
||||
guildId: this.guild?.guild?.id || null,
|
||||
error: err?.message || String(err)
|
||||
})
|
||||
span.fail(err)
|
||||
@@ -18410,16 +18534,19 @@ class PearcordPlatform extends EventEmitter {
|
||||
data: image?.data || null
|
||||
})
|
||||
if (att) this.guild.gossipAttachmentMeta(att)
|
||||
this.guild.gossipStickerUpsert(row)
|
||||
if (this._shouldGossipSticker(row)) this.guild.gossipStickerUpsert(row)
|
||||
await this._audit('sticker.create', {
|
||||
guildId: this.guild.guild.id,
|
||||
targetId: row.name,
|
||||
meta: { name: row.name }
|
||||
meta: { name: row.name, packName: row.packName || null }
|
||||
})
|
||||
this.emit('guild-sticker', row)
|
||||
const slotCount = (await this.stickerRegistry.list().catch(() => [])).length
|
||||
span.end({
|
||||
name: row.name,
|
||||
guildId,
|
||||
packName: row.packName || null,
|
||||
slotCount,
|
||||
spanKind: 'sticker.create',
|
||||
activeChannelId: this.activeChannelId,
|
||||
guildCount: (this.guilds || []).length
|
||||
@@ -18436,6 +18563,29 @@ class PearcordPlatform extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
async reorderStickerPacks (packNames = []) {
|
||||
if (!this.guild?.guild) throw new Error('no guild')
|
||||
const user = this.identity.user
|
||||
if (!user) throw new Error('register first')
|
||||
const roles = await this._memberRoles()
|
||||
if (!roleHasPermission(roles, PERMISSION.MANAGE_GUILD)) {
|
||||
throw new Error('no permission to manage sticker packs')
|
||||
}
|
||||
if (!this.stickerRegistry) await this._initStickerRegistry(this.guild.guild.id)
|
||||
const names = (packNames || []).map((n) => String(n || '').trim().toLowerCase()).filter(Boolean)
|
||||
const packs = await this.stickerRegistry.listPacks()
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const pack = packs.find((p) => p.name === names[i])
|
||||
if (!pack) continue
|
||||
const row = await this.stickerRegistry.upsertPack({
|
||||
...pack,
|
||||
sortOrder: i
|
||||
})
|
||||
this.guild.gossipStickerPackUpsert(row)
|
||||
}
|
||||
return { reordered: names.length }
|
||||
}
|
||||
|
||||
async removeGuildSticker (name) {
|
||||
const guildId = this.guild?.guild?.id || null
|
||||
const span = this.log.time('sticker.delete', { name: name || null, guildId })
|
||||
@@ -20623,8 +20773,17 @@ class PearcordPlatform extends EventEmitter {
|
||||
await this._assertNotTimedOut()
|
||||
await this._assertSlowmode()
|
||||
const stickerNames = await this._normalizeStickerNames(opts.stickerNames)
|
||||
if (String(content || '').trim()) await this._assertAutomod(content)
|
||||
const attachmentIds = opts.attachmentIds || []
|
||||
if (attachmentIds.length && this.attachments) {
|
||||
for (const aid of attachmentIds) {
|
||||
const att = await this.attachments.get(aid).catch(() => null)
|
||||
const mt = String(att?.mimeType || '').toLowerCase()
|
||||
if (mt.includes('gif') && !this._checkGifSendRateLimit(this.activeChannelId)) {
|
||||
throw new Error('gif send rate limited')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (String(content || '').trim()) await this._assertAutomod(content)
|
||||
if (this.attachments && opts.attachmentUiById) {
|
||||
for (const [aid, meta] of Object.entries(opts.attachmentUiById)) {
|
||||
if (!attachmentIds.includes(aid)) continue
|
||||
@@ -20706,10 +20865,13 @@ class PearcordPlatform extends EventEmitter {
|
||||
}
|
||||
}
|
||||
this.emit('message', msg)
|
||||
if (stickerNames.length) {
|
||||
await this._maybeNotifyDmSticker({ message: msg, stickerNames }).catch(() => null)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
async _insertGuildMessage ({ guildId, channelId, content, authorId, hubMirror }) {
|
||||
async _insertGuildMessage ({ guildId, channelId, content, authorId, hubMirror, stickerNames }) {
|
||||
if (!this.messages || !guildId || !channelId) throw new Error('invalid message insert')
|
||||
const card = hubMirror ? buildHubMirrorCard(hubMirror) : null
|
||||
const body = card ? formatHubMirrorFallback(card) : content
|
||||
@@ -20719,7 +20881,8 @@ class PearcordPlatform extends EventEmitter {
|
||||
this.messages.setChannel({ channelId, guildId })
|
||||
const row = await this.messages.send(body || '', {
|
||||
authorId: authorId || this.identity.user?.id,
|
||||
hubMirrorJson
|
||||
hubMirrorJson,
|
||||
stickerNames: (stickerNames || []).length ? stickerNames : undefined
|
||||
})
|
||||
this.messages.setChannel({ channelId: prevChannel, guildId: prevGuild })
|
||||
const enriched = enrichMessageRow(row)
|
||||
@@ -20862,6 +21025,7 @@ class PearcordPlatform extends EventEmitter {
|
||||
if (!targets.length) return []
|
||||
const channels = await this.guild.listChannels()
|
||||
const body = formatCrosspostBody(sourceCh.name, sourceMsg.content)
|
||||
const stickerNames = (sourceMsg.stickerNames || []).slice(0, 3)
|
||||
const out = []
|
||||
for (const targetId of targets) {
|
||||
if (targetId === sourceMsg.channelId) continue
|
||||
@@ -20872,7 +21036,8 @@ class PearcordPlatform extends EventEmitter {
|
||||
guildId: sourceMsg.guildId,
|
||||
channelId: targetId,
|
||||
content: body,
|
||||
authorId: sourceMsg.authorId
|
||||
authorId: sourceMsg.authorId,
|
||||
stickerNames: stickerNames.length ? stickerNames : undefined
|
||||
})
|
||||
out.push(row)
|
||||
} catch {
|
||||
@@ -24058,6 +24223,10 @@ class PearcordPlatform extends EventEmitter {
|
||||
0
|
||||
)
|
||||
}
|
||||
const rootStickerNames = {}
|
||||
for (const [msgId, root] of Object.entries(threadRootMessages || {})) {
|
||||
if ((root?.stickerNames || []).length) rootStickerNames[msgId] = root.stickerNames
|
||||
}
|
||||
const threadsPanel =
|
||||
this.mode === 'guild' && guild
|
||||
? buildThreadsPanel({
|
||||
@@ -24068,6 +24237,7 @@ class PearcordPlatform extends EventEmitter {
|
||||
threadLastActivity,
|
||||
rootMessages: threadRootMessages,
|
||||
reactionTotalsByMessage,
|
||||
rootStickerNames,
|
||||
activeChannelId: this.activeChannelId,
|
||||
includeArchived: this._threadsPanelIncludeArchived,
|
||||
threadFilter: this._threadsPanelFilter,
|
||||
@@ -24086,6 +24256,7 @@ class PearcordPlatform extends EventEmitter {
|
||||
threadLastActivity,
|
||||
rootMessages: threadRootMessages,
|
||||
reactionTotalsByMessage,
|
||||
rootStickerNames,
|
||||
activeChannelId: this.activeChannelId,
|
||||
includeArchived: false,
|
||||
threadFilter: 'active',
|
||||
|
||||
Reference in New Issue
Block a user