Phase 668: composer embed preview and mesh embed guards.
Add prefetchComposerEmbed, clearComposerEmbedPreview, guild resolve rate limit, DM gossip bandwidth cap, URL-hash dedupe, and domain policy gates. 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 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`.
|
||||
|
||||
**Phase 667 (v0.8.642):** Markdown-aware unfurl — `shouldUnfurlMessageLayout` gates `_queueLinkEmbed`, edit URL change refresh, hub/emoji-only view skip, `markdown-embed suppressed` debug span. Bundle: `npm run test:ci-phase667`.
|
||||
|
||||
**Phase 666 (v0.8.641):** Embed parity — `exportEmbedDiagnostics`, `_healStaleEmbedCache`, `_embedPrefetchCoalesce`, `embed.prefetch`/`embed.heal` spans, DM+guild `_queueLinkEmbed`, DM `gossipLinkEmbed`, `retryLinkEmbedForMessage`, `setComposerImeComposing`, conversation-scoped cache, multi-embed view slice, `embed.clear` on edit. Bundle: `npm run test:ci-phase666`.
|
||||
|
||||
@@ -260,6 +260,7 @@ const {
|
||||
extractFirstUrl,
|
||||
extractUrls,
|
||||
shouldUnfurlMessageLayout,
|
||||
isEmbedDomainBlocked,
|
||||
parsePearcordDeepLink,
|
||||
ALLOWED_DEEP_LINK_KINDS,
|
||||
validatePastedNavigationInput,
|
||||
@@ -583,6 +584,10 @@ class PearcordPlatform extends EventEmitter {
|
||||
this._embedResolveLatencyByMessage = new Map()
|
||||
/** Suppress composer-driven embed prefetch during IME composition (Phase 666). */
|
||||
this._composerImeComposing = false
|
||||
this._composerEmbedPreview = null
|
||||
this._embedGossipUrlHashes = new Set()
|
||||
this._embedResolveRateByGuild = new Map()
|
||||
this._dmEmbedGossipBytesByConversation = new Map()
|
||||
}
|
||||
|
||||
_getGuildOpenSlowThresholdMs () {
|
||||
@@ -12472,7 +12477,7 @@ class PearcordPlatform extends EventEmitter {
|
||||
return this.readAttachmentPreview(hash)
|
||||
}
|
||||
|
||||
async updateGuildSettings ({ name, iconHash, nsfwGateEnabled }) {
|
||||
async updateGuildSettings ({ name, iconHash, nsfwGateEnabled, embedAllowlistDomains, embedAllowlistOnly }) {
|
||||
const guildId = this.guild?.guild?.id || null
|
||||
const prev = this.guild?.guild || null
|
||||
const span = this.log.time('guild.update', {
|
||||
@@ -12496,6 +12501,14 @@ class PearcordPlatform extends EventEmitter {
|
||||
nsfwGateEnabled:
|
||||
nsfwGateEnabled !== undefined ? !!nsfwGateEnabled : prev.nsfwGateEnabled !== false
|
||||
}
|
||||
if (embedAllowlistDomains !== undefined) {
|
||||
guild.embedAllowlistDomains = Array.isArray(embedAllowlistDomains)
|
||||
? embedAllowlistDomains.map((d) => String(d || '').trim().toLowerCase()).filter(Boolean).slice(0, 64)
|
||||
: []
|
||||
}
|
||||
if (embedAllowlistOnly !== undefined) {
|
||||
guild.embedAllowlistOnly = !!embedAllowlistOnly
|
||||
}
|
||||
await this.db.insert(COLLECTIONS.GUILDS, guild)
|
||||
this.guild.guild = guild
|
||||
await this._audit('guild.update', { guildId: guild.id, targetId: guild.id })
|
||||
@@ -13000,9 +13013,105 @@ class PearcordPlatform extends EventEmitter {
|
||||
|
||||
setComposerImeComposing (active) {
|
||||
this._composerImeComposing = !!active
|
||||
if (this._composerImeComposing) this._composerEmbedPreview = null
|
||||
return { active: this._composerImeComposing }
|
||||
}
|
||||
|
||||
clearComposerEmbedPreview () {
|
||||
this._composerEmbedPreview = null
|
||||
return { cleared: true }
|
||||
}
|
||||
|
||||
async prefetchComposerEmbed (url) {
|
||||
const target = String(url || '').trim()
|
||||
if (!target || this._composerImeComposing || this._readOnly) {
|
||||
this._composerEmbedPreview = null
|
||||
return { row: null, skipped: true }
|
||||
}
|
||||
const prefs = this.userSettings ? await this.userSettings.getPrefs() : null
|
||||
if (prefs && (prefs.showLinkPreviews === false || prefs.linkPreviewsEnabled === false)) {
|
||||
this._composerEmbedPreview = null
|
||||
return { row: null, previewsOff: true }
|
||||
}
|
||||
if (await this._embedPolicyBlocksUrl(target, prefs)) {
|
||||
this._composerEmbedPreview = null
|
||||
return { row: null, blocked: true }
|
||||
}
|
||||
const conversationId = this.mode === 'dm' ? this.activeChannelId : null
|
||||
const acceptLanguage = prefs?.embedResolveLocale || null
|
||||
const cached = await this.embeds?.get(target, { conversationId }).catch(() => null)
|
||||
if (cached) {
|
||||
this._composerEmbedPreview = { url: target, row: cached, fromCache: true }
|
||||
this.log.debug('embed.prefetch composer cache', {
|
||||
spanKind: 'embed.prefetch',
|
||||
url: target,
|
||||
fromCache: true,
|
||||
channelId: this.activeChannelId
|
||||
})
|
||||
return { row: cached, fromCache: true }
|
||||
}
|
||||
const row = await this.embeds
|
||||
?.resolve(target, { conversationId, acceptLanguage })
|
||||
.catch(() => null)
|
||||
this._composerEmbedPreview = row ? { url: target, row, fromCache: false } : null
|
||||
this.log.debug('embed.prefetch composer', {
|
||||
spanKind: 'embed.prefetch',
|
||||
url: target,
|
||||
fromCache: false,
|
||||
resolved: !!row,
|
||||
channelId: this.activeChannelId
|
||||
})
|
||||
return { row, fromCache: false }
|
||||
}
|
||||
|
||||
async _embedPolicyBlocksUrl (url, prefs = null) {
|
||||
if (!url) return true
|
||||
const p = prefs || (this.userSettings ? await this.userSettings.getPrefs() : null)
|
||||
const guild = this.guild?.guild || null
|
||||
return isEmbedDomainBlocked(url, {
|
||||
blockedDomains: p?.embedBlockedDomains || [],
|
||||
allowlistOnly: guild?.embedAllowlistOnly === true,
|
||||
allowedDomains: guild?.embedAllowlistDomains || []
|
||||
})
|
||||
}
|
||||
|
||||
_checkEmbedResolveRateLimit (guildId) {
|
||||
const key = guildId || 'dm'
|
||||
const windowMs = 60_000
|
||||
const cap = Math.max(4, Number(process.env.PEARCORD_EMBED_RESOLVE_RATE_PER_MIN) || 24)
|
||||
const now = Date.now()
|
||||
let bucket = this._embedResolveRateByGuild.get(key)
|
||||
if (!bucket || now - bucket.resetAt > windowMs) {
|
||||
bucket = { count: 0, resetAt: now }
|
||||
}
|
||||
if (bucket.count >= cap) return false
|
||||
bucket.count++
|
||||
this._embedResolveRateByGuild.set(key, bucket)
|
||||
return true
|
||||
}
|
||||
|
||||
_shouldGossipEmbedUrl (url) {
|
||||
const crypto = require('hypercore-crypto')
|
||||
const hash = b4a.toString(crypto.hash(b4a.from(String(url))), 'hex')
|
||||
if (this._embedGossipUrlHashes.has(hash)) return false
|
||||
this._embedGossipUrlHashes.add(hash)
|
||||
if (this._embedGossipUrlHashes.size > 2048) {
|
||||
const first = this._embedGossipUrlHashes.values().next().value
|
||||
if (first) this._embedGossipUrlHashes.delete(first)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
_trackDmEmbedGossipBytes (conversationId, row) {
|
||||
if (!conversationId || !row) return
|
||||
const cap = Number(process.env.PEARCORD_DM_EMBED_GOSSIP_BYTES_CAP) || 256 * 1024
|
||||
const est = JSON.stringify(row).length
|
||||
const prev = this._dmEmbedGossipBytesByConversation.get(conversationId) || 0
|
||||
if (prev + est > cap) return false
|
||||
this._dmEmbedGossipBytesByConversation.set(conversationId, prev + est)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 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')
|
||||
@@ -17444,6 +17553,28 @@ class PearcordPlatform extends EventEmitter {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (await this._embedPolicyBlocksUrl(url, prefs)) {
|
||||
this.log.debug('embed.domain blocked', { spanKind: 'embed.prefetch', url, messageId: msg.id })
|
||||
return
|
||||
}
|
||||
const guildIdForRate =
|
||||
msg.guildId || (this.mode === 'dm' ? DM_GUILD_ID : this.guild?.guild?.id) || null
|
||||
if (!this._checkEmbedResolveRateLimit(guildIdForRate)) {
|
||||
this.log.debug('embed.rate limited', { spanKind: 'embed.prefetch', url, guildId: guildIdForRate })
|
||||
return
|
||||
}
|
||||
if (plain.length > 8000) {
|
||||
this.log.debug('embed.payload too large', { spanKind: 'embed.prefetch', messageId: msg.id, len: plain.length })
|
||||
return
|
||||
}
|
||||
const guildIdForHook = msg.guildId || this.guild?.guild?.id || null
|
||||
if (prefs?.automationEmbedResolveEnabled === false && msg.authorId && guildIdForHook && this.webhooks) {
|
||||
const hook = await this.webhooks.getById(guildIdForHook, msg.authorId).catch(() => null)
|
||||
if (hook) {
|
||||
this.log.debug('embed.automation opt-out', { spanKind: 'embed.prefetch', messageId: msg.id })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this._embedPrefetchCoalesce.has(url)) {
|
||||
return this._embedPrefetchCoalesce.get(url)
|
||||
}
|
||||
@@ -17481,9 +17612,11 @@ class PearcordPlatform extends EventEmitter {
|
||||
const conversationId =
|
||||
this.mode === 'dm' ? msg.channelId || this.activeChannelId || null : null
|
||||
const fromCache = !!(await this.embeds.get(url, { conversationId }))
|
||||
const acceptLanguage = (await this.userSettings?.getPrefs()?.catch(() => null))?.embedResolveLocale || null
|
||||
const row = await this.embeds.resolveForContent(plain, {
|
||||
force: !!opts.force,
|
||||
conversationId
|
||||
conversationId,
|
||||
acceptLanguage
|
||||
})
|
||||
const latencyMs = Date.now() - started
|
||||
if (msg.id) this._embedResolveLatencyByMessage.set(msg.id, latencyMs)
|
||||
@@ -17496,7 +17629,7 @@ class PearcordPlatform extends EventEmitter {
|
||||
latencyMs
|
||||
})
|
||||
let gossipSent = false
|
||||
if (row && this.guild) {
|
||||
if (row && this.guild && this._shouldGossipEmbedUrl(row.url || url)) {
|
||||
const gossipSpan = this.log.time('embed.gossip', {
|
||||
spanKind: 'embed.gossip',
|
||||
messageId: msg.id,
|
||||
@@ -17537,15 +17670,17 @@ class PearcordPlatform extends EventEmitter {
|
||||
embedType: row?.embedType || null
|
||||
})
|
||||
gossipSent = true
|
||||
} else if (row && this.mode === 'dm' && this.dm?.gossipLinkEmbed) {
|
||||
} else if (row && this.mode === 'dm' && this.dm?.gossipLinkEmbed && this._shouldGossipEmbedUrl(row.url || url)) {
|
||||
const conversationId = msg.channelId || this.activeChannelId || null
|
||||
this.dm.gossipLinkEmbed({
|
||||
...row,
|
||||
messageId: msg.id,
|
||||
channelId: conversationId,
|
||||
conversationId
|
||||
})
|
||||
gossipSent = true
|
||||
if (this._trackDmEmbedGossipBytes(conversationId, row)) {
|
||||
this.dm.gossipLinkEmbed({
|
||||
...row,
|
||||
messageId: msg.id,
|
||||
channelId: conversationId,
|
||||
conversationId
|
||||
})
|
||||
gossipSent = true
|
||||
}
|
||||
}
|
||||
let urlHost = null
|
||||
try {
|
||||
@@ -20233,6 +20368,9 @@ class PearcordPlatform extends EventEmitter {
|
||||
await this.selectChannel(prevChannel)
|
||||
}
|
||||
if (this.guild) this.guild.gossipMessage(msg)
|
||||
if (String(content ?? '').length <= 4000) {
|
||||
this._queueLinkEmbed(msg).catch(() => {})
|
||||
}
|
||||
await this._audit('webhook.execute', {
|
||||
guildId: row.guildId,
|
||||
targetId: row.id,
|
||||
@@ -21316,6 +21454,7 @@ class PearcordPlatform extends EventEmitter {
|
||||
await this.openGroupDM({ channelId: row.channelId })
|
||||
}
|
||||
const msg = await this._sendPlainMessage(row.content, {})
|
||||
if (msg) this._queueLinkEmbed(msg).catch(() => {})
|
||||
return { msg, error: null }
|
||||
} catch (error) {
|
||||
return { msg: null, error }
|
||||
@@ -24500,6 +24639,7 @@ class PearcordPlatform extends EventEmitter {
|
||||
discoveryStats,
|
||||
linkEmbedsByMessage,
|
||||
embedResolveLatencyByMessage,
|
||||
composerEmbedPreview: this._composerEmbedPreview || null,
|
||||
threadsByParent,
|
||||
threadMessageCounts,
|
||||
threadLastActivity,
|
||||
|
||||
Reference in New Issue
Block a user