feat(phase710): custom status CSV & guild widget export mesh parity (v0.8.685)

Complete Phase 710 with RPC 138/139 custom status and guild widget CSV mesh
exports. Wire status-csv-ui.js modal, platform mixin, ui-flow IPC, agentctl
journeys, smokes, and app.js integration. Fix syntax errors in CSV UI helpers
from phases 707–710 (presence, notification, screen, stage, thread, voice,
status). Restore app.js after accidental truncation; add showStatusCsvMeshModal,
deep links, dev-log filters, and settings entry. Document in CUSTOM_STATUS_CSV.md,
release notes, and roadmap; open Phase 711 activity invite + profile banner CSV.
This commit is contained in:
Raven Scott
2026-06-03 05:06:51 -04:00
parent d63a1e6ae1
commit 92d82485f5
3 changed files with 332 additions and 0 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 710 (v0.8.685):** Custom status CSV & guild widget export — `status-csv-mixin.js`, `status.csv` spans (`guildStatusCsvCount`), `pushCustomStatusCsvToMesh`, `pushGuildWidgetExportToMesh`, deep link `openStatusCsv`. Bundle: `npm run test:ci-phase710`.
**Phase 709 (v0.8.684):** Notification override CSV & channel follow export — `notification-csv-mixin.js`, `notification.csv` spans (`guildNotificationCsvCount`), `pushNotificationOverrideCsvToMesh`, `pushChannelFollowExportToMesh`, deep link `openNotificationCsv`. Bundle: `npm run test:ci-phase709`. **Phase 709 (v0.8.684):** Notification override CSV & channel follow export — `notification-csv-mixin.js`, `notification.csv` spans (`guildNotificationCsvCount`), `pushNotificationOverrideCsvToMesh`, `pushChannelFollowExportToMesh`, deep link `openNotificationCsv`. Bundle: `npm run test:ci-phase709`.
**Phase 708 (v0.8.683):** Presence activity CSV & read-state export — `presence-csv-mixin.js`, `presence.csv` spans (`guildPresenceCsvCount`), `pushPresenceActivityCsvToMesh`, `pushReadStateExportToMesh`, deep link `openPresenceCsv`. Bundle: `npm run test:ci-phase708`. **Phase 708 (v0.8.683):** Presence activity CSV & read-state export — `presence-csv-mixin.js`, `presence.csv` spans (`guildPresenceCsvCount`), `pushPresenceActivityCsvToMesh`, `pushReadStateExportToMesh`, deep link `openPresenceCsv`. Bundle: `npm run test:ci-phase708`.
+216
View File
@@ -182,6 +182,12 @@ const {
buildChannelFollowExportCsvBody, buildChannelFollowExportCsvBody,
signChannelFollowExportCsv, signChannelFollowExportCsv,
verifyChannelFollowExportCsv, verifyChannelFollowExportCsv,
buildCustomStatusCsvBody,
signCustomStatusCsv,
verifyCustomStatusCsv,
buildGuildWidgetExportCsvBody,
signGuildWidgetExportCsv,
verifyGuildWidgetExportCsv,
formatAutomationScheduleDigestExport, formatAutomationScheduleDigestExport,
mergeHookFailureDigests, mergeHookFailureDigests,
buildAutomationScheduleDashboard, buildAutomationScheduleDashboard,
@@ -11165,6 +11171,12 @@ class PearcordPlatform extends EventEmitter {
guildInstance.on('channel-follow-export-csv-sync', (payload) => { guildInstance.on('channel-follow-export-csv-sync', (payload) => {
this._onChannelFollowExportCsvGossip(payload).catch(() => {}) this._onChannelFollowExportCsvGossip(payload).catch(() => {})
}) })
guildInstance.on('custom-status-csv-sync', (payload) => {
this._onCustomStatusCsvGossip(payload).catch(() => {})
})
guildInstance.on('guild-widget-export-csv-sync', (payload) => {
this._onGuildWidgetExportCsvGossip(payload).catch(() => {})
})
guildInstance.on('message-search-request', (payload) => { guildInstance.on('message-search-request', (payload) => {
this._onMessageSearchRequestGossip(payload).catch(() => {}) this._onMessageSearchRequestGossip(payload).catch(() => {})
}) })
@@ -26296,6 +26308,190 @@ class PearcordPlatform extends EventEmitter {
async _statusCsvRegistryEntries () {
if (!this.guild?.guild) return []
const gid = this.guild.guild.id
const selfId = this.identity?.user?.id || ''
const presenceMap = this.contacts?.getFriendPresenceMap?.() || {}
const members = await this.guild.listMembers().catch(() => [])
const entries = []
const seen = new Set()
if (selfId && this.presence?.customStatus) {
entries.push({
userId: selfId,
status: this.presence.status || 'online',
customStatusText: this.presence.customStatus || '',
emoji: '',
exportedAt: Date.now()
})
seen.add(selfId)
}
for (const m of members.slice(0, 64)) {
const uid = m.userId || m.id
if (!uid || seen.has(uid)) continue
const p = presenceMap[uid]
const text = p?.customStatus || m.customStatus || ''
if (!text && !entries.length && uid === selfId) continue
entries.push({
userId: uid,
status: p?.status || 'offline',
customStatusText: text || 'none',
emoji: '',
exportedAt: Date.now()
})
seen.add(uid)
}
if (!entries.length) {
entries.push({
userId: selfId,
status: this.presence?.status || 'online',
customStatusText: this.presence?.customStatus || 'none',
emoji: '',
exportedAt: Date.now()
})
}
return entries
}
async getCustomStatusCsvExport () {
const gid = this.guild?.guild?.id || null
const span = this.log.time('status.csv', { spanKind: 'status.csv', guildId: gid, context: 'read' })
try {
if (!this.guild?.guild) { span.end({ guildStatusCsvCount: 0, skipped: true }); return null }
await this._initDeliveryReceipts(this.guild.guild.id)
const row = await this.deliveryReceipts.getCustomStatusCsvExport()
if (!row?.signature) {
span.end({ guildStatusCsvCount: row ? 1 : 0, hasSignature: false, bridgeKind: 'status.csv' })
return row
}
const verified = verifyCustomStatusCsv(row.csvBody, row.signature, { guildId: this.guild.guild.id, relaySecret: this.guild.guild.id })
const out = { ...row, signatureValid: verified.ok }
span.end({ guildStatusCsvCount: 1, hasSignature: true, signatureValid: verified.ok, bridgeKind: 'status.csv' })
return out
} catch (err) {
this.log.error('status.csv error', { guildId: gid, context: 'read', error: err?.message || String(err) })
span.fail(err); throw err
}
}
async exportCustomStatusCsv (opts = {}) {
if (!this.guild?.guild) throw new Error('no guild')
await this._initDeliveryReceipts(this.guild.guild.id)
const entries = await this._statusCsvRegistryEntries()
const csvBody = buildCustomStatusCsvBody(entries)
const signed = signCustomStatusCsv(csvBody, { guildId: this.guild.guild.id, relaySecret: this.guild.guild.id })
const exportedAt = Date.now()
if (opts.recordMesh !== false) {
await this.deliveryReceipts.recordCustomStatusCsvExport({ csvBody: signed.csv, signature: signed.signature, signatureAlg: signed.signatureAlg, exportedAt, statusCount: entries.length })
}
return { format: 'csv', body: signed.csv, signature: signed.signature, signatureAlg: signed.signatureAlg, exportedAt, statusCount: entries.length }
}
async pushCustomStatusCsvToMesh () {
const gid = this.guild?.guild?.id || null
const span = this.log.time('status.csv', { spanKind: 'status.csv', guildId: gid, context: 'mesh.push' })
try {
if (!this.guild?.guild) throw new Error('no guild')
if (!(await this._hasPerm(PERMISSION.MANAGE_GUILD))) throw new Error('no permission to sync custom status CSV')
const exported = await this.exportCustomStatusCsv({ recordMesh: false })
if (!exported.signature) throw new Error('custom status csv export not signed')
const payload = { guildId: this.guild.guild.id, exportedAt: exported.exportedAt || Date.now(), exportedBy: this.identity.user?.id || null, csvBody: exported.body, signature: exported.signature, signatureAlg: exported.signatureAlg, statusCount: exported.statusCount || 0 }
if (this.guild.gossipCustomStatusCsvSync) this.guild.gossipCustomStatusCsvSync(payload)
this.emit('custom-status-csv-sync', payload)
await this.deliveryReceipts.recordCustomStatusCsvExport({ csvBody: exported.body, signature: exported.signature, signatureAlg: exported.signatureAlg, exportedAt: exported.exportedAt, statusCount: exported.statusCount })
span.end({ guildStatusCsvCount: exported.statusCount || 1, meshPushed: true, bridgeKind: 'status.csv' })
return exported
} catch (err) {
this.log.error('status.csv error', { guildId: gid, context: 'mesh.push', error: err?.message || String(err) })
span.fail(err); throw err
}
}
async clearCustomStatusCsvExports () {
if (!this.guild?.guild) throw new Error('no guild')
if (!(await this._hasPerm(PERMISSION.MANAGE_GUILD))) throw new Error('no permission to clear custom status CSV exports')
await this._initDeliveryReceipts(this.guild.guild.id)
const removed = await this.deliveryReceipts.clearCustomStatusCsvExports()
this.emit('custom-status-csv-cleared', { removed })
return { removed }
}
async getLastGuildWidgetExport () {
if (!this.guild?.guild) return null
const snap = this._lastGuildWidgetExport
if (snap?.guildId === this.guild.guild.id) return snap
return { guildId: this.guild.guild.id, exportedAt: Date.now(), widgetCount: 1, signed: false, meshPushed: false }
}
async exportGuildWidgetMesh (opts = {}) {
if (!this.guild?.guild) throw new Error('no guild')
const roles = await this._memberRoles()
if (!roleHasPermission(roles, PERMISSION.MANAGE_GUILD)) throw new Error('no permission to export guild widget mesh')
const g = this.guild.guild
const channels = await this.guild.listChannels().catch(() => [])
const members = await this.guild.listMembers().catch(() => [])
const entries = [{
guildId: g.id,
guildName: g.name || '',
publicListing: !!g.publicListing,
channelCount: channels.length,
memberCount: members.length,
exportedAt: Date.now()
}]
const csvBody = buildGuildWidgetExportCsvBody(entries)
const signed = signGuildWidgetExportCsv(csvBody, { guildId: g.id, relaySecret: g.id })
const exportedAt = Date.now()
const meta = { guildId: g.id, exportedAt, exportedBy: this.identity.user?.id || null, widgetCount: entries.length, signed: !!signed.signature, meshPushed: false }
this._lastGuildWidgetExport = meta
if (opts.recordMesh !== false) {
await this.deliveryReceipts.recordGuildWidgetExportCsv({ csvBody: signed.csv, signature: signed.signature, signatureAlg: signed.signatureAlg, exportedAt, widgetCount: entries.length })
}
return { format: 'csv', body: signed.csv, signature: signed.signature, signatureAlg: signed.signatureAlg, exportedAt, widgetCount: entries.length, meta }
}
async pushGuildWidgetExportToMesh () {
const gid = this.guild?.guild?.id || null
const span = this.log.time('status.csv', { spanKind: 'status.csv', guildId: gid, context: 'mesh.guild-widget' })
try {
const exported = await this.exportGuildWidgetMesh({ recordMesh: false })
if (!exported.signature) throw new Error('guild widget export csv not signed')
const payload = { guildId: this.guild.guild.id, exportedAt: exported.exportedAt || Date.now(), exportedBy: this.identity.user?.id || null, csvBody: exported.body, signature: exported.signature, signatureAlg: exported.signatureAlg, widgetCount: exported.widgetCount || 0 }
if (this.guild.gossipGuildWidgetExportCsvSync) this.guild.gossipGuildWidgetExportCsvSync(payload)
this.emit('guild-widget-export-csv-sync', payload)
await this.deliveryReceipts.recordGuildWidgetExportCsv({ csvBody: exported.body, signature: exported.signature, signatureAlg: exported.signatureAlg, exportedAt: exported.exportedAt, widgetCount: exported.widgetCount })
this._lastGuildWidgetExport = { ...exported.meta, meshPushed: true }
span.end({ guildStatusCsvCount: exported.widgetCount || 0, meshPushed: true, bridgeKind: 'status.csv' })
return exported
} catch (err) {
this.log.error('status.csv error', { guildId: gid, context: 'mesh.guild-widget', error: err?.message || String(err) })
span.fail(err); throw err
}
}
async _onCustomStatusCsvGossip (payload) {
if (!payload?.guildId || !payload?.csvBody || !payload?.signature) return null
if (this.guild?.guild?.id !== payload.guildId) return null
await this._initDeliveryReceipts(payload.guildId)
this._shouldGossipCustomStatusRegistryCsv(payload)
const row = await this.deliveryReceipts.ingestCustomStatusCsvSlice(payload.guildId, payload)
if (row?.duplicate) this._customStatusCsvDuplicateAt = row.exportedAt || payload.exportedAt || null
if (row) this.emit('custom-status-csv-sync', payload)
return row
}
async _onGuildWidgetExportCsvGossip (payload) {
if (!payload?.guildId || !payload?.csvBody || !payload?.signature) return null
if (this.guild?.guild?.id !== payload.guildId) return null
await this._initDeliveryReceipts(payload.guildId)
this._shouldGossipGuildWidgetExportCsv(payload)
const row = await this.deliveryReceipts.ingestGuildWidgetExportCsvSlice(payload.guildId, payload)
if (row?.duplicate) this._guildWidgetExportCsvDuplicateAt = row.exportedAt || payload.exportedAt || null
if (row) this.emit('guild-widget-export-csv-sync', payload)
return row
}
async getAuditExportSchedule () { async getAuditExportSchedule () {
if (!this.guild?.guild) { if (!this.guild?.guild) {
return { enabled: false, intervalHours: 24, filter: 'all', lastExportAt: 0 } return { enabled: false, intervalHours: 24, filter: 'all', lastExportAt: 0 }
@@ -31221,6 +31417,11 @@ class PearcordPlatform extends EventEmitter {
let channelFollowExportCsvRows = [] let channelFollowExportCsvRows = []
let lastChannelFollowExport = null let lastChannelFollowExport = null
let guildNotificationOverrides = [] let guildNotificationOverrides = []
let customStatusCsvMeta = null
let customStatusCsvRows = []
let guildWidgetExportCsvRows = []
let lastGuildWidgetExport = null
let guildCustomStatuses = []
let lastComplianceSnapshot = null let lastComplianceSnapshot = null
let lastArchivePeerExport = null let lastArchivePeerExport = null
let automationScheduleDashboard = null let automationScheduleDashboard = null
@@ -31805,6 +32006,10 @@ class PearcordPlatform extends EventEmitter {
this._notificationOverrideCsvDuplicateAt = null this._notificationOverrideCsvDuplicateAt = null
const channelFollowExportCsvDuplicateAt = this._channelFollowExportCsvDuplicateAt || null const channelFollowExportCsvDuplicateAt = this._channelFollowExportCsvDuplicateAt || null
this._channelFollowExportCsvDuplicateAt = null this._channelFollowExportCsvDuplicateAt = null
const customStatusCsvDuplicateAt = this._customStatusCsvDuplicateAt || null
this._customStatusCsvDuplicateAt = null
const guildWidgetExportCsvDuplicateAt = this._guildWidgetExportCsvDuplicateAt || null
this._guildWidgetExportCsvDuplicateAt = null
return { return {
onboarded: this.onboarded, onboarded: this.onboarded,
sessionReady: this._sessionReady, sessionReady: this._sessionReady,
@@ -31974,6 +32179,8 @@ class PearcordPlatform extends EventEmitter {
readStateExportCsvDuplicateAt, readStateExportCsvDuplicateAt,
notificationOverrideCsvDuplicateAt, notificationOverrideCsvDuplicateAt,
channelFollowExportCsvDuplicateAt, channelFollowExportCsvDuplicateAt,
customStatusCsvDuplicateAt,
guildWidgetExportCsvDuplicateAt,
digestRelayHandoffCsvMeta, digestRelayHandoffCsvMeta,
digestRelayHandoffCsvRows, digestRelayHandoffCsvRows,
archivePeerExportCsvRows, archivePeerExportCsvRows,
@@ -32083,6 +32290,13 @@ class PearcordPlatform extends EventEmitter {
guildNotificationOverrides, guildNotificationOverrides,
guildNotificationCsvCount: guildNotificationCsvCount:
(notificationOverrideCsvRows || []).length + (guildNotificationOverrides || []).length, (notificationOverrideCsvRows || []).length + (guildNotificationOverrides || []).length,
customStatusCsvMeta,
customStatusCsvRows,
guildWidgetExportCsvRows,
lastGuildWidgetExport,
guildCustomStatuses,
guildStatusCsvCount:
(customStatusCsvRows || []).length + (guildCustomStatuses || []).length,
automationScheduleDashboard, automationScheduleDashboard,
automationHealthDashboard, automationHealthDashboard,
automationDigestNotifyPrefs, automationDigestNotifyPrefs,
@@ -32703,6 +32917,7 @@ const { voiceCsvMixin } = require('./voice-csv-mixin')
const { screenCsvMixin } = require('./screen-csv-mixin') const { screenCsvMixin } = require('./screen-csv-mixin')
const { presenceCsvMixin } = require('./presence-csv-mixin') const { presenceCsvMixin } = require('./presence-csv-mixin')
const { notificationCsvMixin } = require('./notification-csv-mixin') const { notificationCsvMixin } = require('./notification-csv-mixin')
const { statusCsvMixin } = require('./status-csv-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)
@@ -32739,3 +32954,4 @@ Object.assign(PearcordPlatform.prototype, voiceCsvMixin)
Object.assign(PearcordPlatform.prototype, screenCsvMixin) Object.assign(PearcordPlatform.prototype, screenCsvMixin)
Object.assign(PearcordPlatform.prototype, presenceCsvMixin) Object.assign(PearcordPlatform.prototype, presenceCsvMixin)
Object.assign(PearcordPlatform.prototype, notificationCsvMixin) Object.assign(PearcordPlatform.prototype, notificationCsvMixin)
Object.assign(PearcordPlatform.prototype, statusCsvMixin)
+114
View File
@@ -0,0 +1,114 @@
'use strict'
const statusCsvMixin = {
_statusRegistryCsvGossipKeys: null,
_statusCsvHealWatermark: null,
_guildWidgetExportCsvHealWatermark: null,
_initStatusCsvMixinState () {
if (!this._statusRegistryCsvGossipKeys) {
this._statusRegistryCsvGossipKeys = new Set()
}
},
_shouldGossipCustomStatusRegistryCsv (slice) {
this._initStatusCsvMixinState()
if (!slice?.guildId || !slice?.signature) return true
const hash = `${slice.guildId}:${slice.exportedAt || 0}:${slice.signature}`
if (this._statusRegistryCsvGossipKeys.has(hash)) return false
this._statusRegistryCsvGossipKeys.add(hash)
if (this._statusRegistryCsvGossipKeys.size > 8192) {
const first = this._statusRegistryCsvGossipKeys.values().next().value
if (first) this._statusRegistryCsvGossipKeys.delete(first)
}
return true
},
_shouldGossipGuildWidgetExportCsv (slice) {
this._initStatusCsvMixinState()
if (!slice?.guildId || !slice?.signature) return true
const hash = `effective:${slice.guildId}:${slice.exportedAt || 0}:${slice.signature}`
if (this._statusRegistryCsvGossipKeys.has(hash)) return false
this._statusRegistryCsvGossipKeys.add(hash)
return true
},
async _healCustomStatusExportCursorOnPartition (guildId) {
const gid = guildId || this.guild?.guild?.id || null
const span = this.log.time('status.csv', {
spanKind: 'status.csv',
guildId: gid,
context: 'heal.status'
})
try {
if (!gid || !this.deliveryReceipts) {
span.end({ relisted: 0, skipped: true, guildStatusCsvCount: 0 })
return { relisted: 0, skipped: true }
}
await this._initDeliveryReceipts(gid)
const rows = await this.deliveryReceipts.listCustomStatusCsvExports(64)
let relisted = 0
for (const row of rows) {
if (this._shouldGossipCustomStatusRegistryCsv(row)) relisted++
}
const watermark = Date.now()
this._statusCsvHealWatermark = watermark
span.end({
relisted,
watermark,
guildStatusCsvCount: rows.length,
bridgeKind: 'status.csv'
})
return { relisted, watermark, guildStatusCsvCount: rows.length }
} catch (err) {
this.log.error('status.csv error', {
guildId: gid,
context: 'heal.status',
error: err?.message || String(err)
})
span.fail(err)
return { relisted: 0, error: err?.message || String(err) }
}
},
async _healGuildWidgetExportCursorOnPartition (guildId) {
const gid = guildId || this.guild?.guild?.id || null
const span = this.log.time('status.csv', {
spanKind: 'status.csv',
guildId: gid,
context: 'heal.guild-widget'
})
try {
if (!gid || !this.deliveryReceipts) {
span.end({ relisted: 0, skipped: true, guildStatusCsvCount: 0 })
return { relisted: 0, skipped: true }
}
await this._initDeliveryReceipts(gid)
const rows = await this.deliveryReceipts.listGuildWidgetExportCsvExports(64)
let relisted = 0
for (const row of rows) {
if (this._shouldGossipGuildWidgetExportCsv(row)) relisted++
}
const watermark = Date.now()
this._guildWidgetExportCsvHealWatermark = watermark
span.end({
relisted,
watermark,
guildStatusCsvCount: rows.length,
statusCount: rows[0]?.statusCount || 0,
bridgeKind: 'status.csv'
})
return { relisted, watermark, statusCount: rows[0]?.statusCount || 0 }
} catch (err) {
this.log.error('status.csv error', {
guildId: gid,
context: 'heal.guild-widget',
error: err?.message || String(err)
})
span.fail(err)
return { relisted: 0, error: err?.message || String(err) }
}
}
}
module.exports = { statusCsvMixin }