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:
@@ -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 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 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`.
|
||||
|
||||
@@ -182,6 +182,12 @@ const {
|
||||
buildChannelFollowExportCsvBody,
|
||||
signChannelFollowExportCsv,
|
||||
verifyChannelFollowExportCsv,
|
||||
buildCustomStatusCsvBody,
|
||||
signCustomStatusCsv,
|
||||
verifyCustomStatusCsv,
|
||||
buildGuildWidgetExportCsvBody,
|
||||
signGuildWidgetExportCsv,
|
||||
verifyGuildWidgetExportCsv,
|
||||
formatAutomationScheduleDigestExport,
|
||||
mergeHookFailureDigests,
|
||||
buildAutomationScheduleDashboard,
|
||||
@@ -11165,6 +11171,12 @@ class PearcordPlatform extends EventEmitter {
|
||||
guildInstance.on('channel-follow-export-csv-sync', (payload) => {
|
||||
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) => {
|
||||
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 () {
|
||||
if (!this.guild?.guild) {
|
||||
return { enabled: false, intervalHours: 24, filter: 'all', lastExportAt: 0 }
|
||||
@@ -31221,6 +31417,11 @@ class PearcordPlatform extends EventEmitter {
|
||||
let channelFollowExportCsvRows = []
|
||||
let lastChannelFollowExport = null
|
||||
let guildNotificationOverrides = []
|
||||
let customStatusCsvMeta = null
|
||||
let customStatusCsvRows = []
|
||||
let guildWidgetExportCsvRows = []
|
||||
let lastGuildWidgetExport = null
|
||||
let guildCustomStatuses = []
|
||||
let lastComplianceSnapshot = null
|
||||
let lastArchivePeerExport = null
|
||||
let automationScheduleDashboard = null
|
||||
@@ -31805,6 +32006,10 @@ class PearcordPlatform extends EventEmitter {
|
||||
this._notificationOverrideCsvDuplicateAt = null
|
||||
const channelFollowExportCsvDuplicateAt = this._channelFollowExportCsvDuplicateAt || null
|
||||
this._channelFollowExportCsvDuplicateAt = null
|
||||
const customStatusCsvDuplicateAt = this._customStatusCsvDuplicateAt || null
|
||||
this._customStatusCsvDuplicateAt = null
|
||||
const guildWidgetExportCsvDuplicateAt = this._guildWidgetExportCsvDuplicateAt || null
|
||||
this._guildWidgetExportCsvDuplicateAt = null
|
||||
return {
|
||||
onboarded: this.onboarded,
|
||||
sessionReady: this._sessionReady,
|
||||
@@ -31974,6 +32179,8 @@ class PearcordPlatform extends EventEmitter {
|
||||
readStateExportCsvDuplicateAt,
|
||||
notificationOverrideCsvDuplicateAt,
|
||||
channelFollowExportCsvDuplicateAt,
|
||||
customStatusCsvDuplicateAt,
|
||||
guildWidgetExportCsvDuplicateAt,
|
||||
digestRelayHandoffCsvMeta,
|
||||
digestRelayHandoffCsvRows,
|
||||
archivePeerExportCsvRows,
|
||||
@@ -32083,6 +32290,13 @@ class PearcordPlatform extends EventEmitter {
|
||||
guildNotificationOverrides,
|
||||
guildNotificationCsvCount:
|
||||
(notificationOverrideCsvRows || []).length + (guildNotificationOverrides || []).length,
|
||||
customStatusCsvMeta,
|
||||
customStatusCsvRows,
|
||||
guildWidgetExportCsvRows,
|
||||
lastGuildWidgetExport,
|
||||
guildCustomStatuses,
|
||||
guildStatusCsvCount:
|
||||
(customStatusCsvRows || []).length + (guildCustomStatuses || []).length,
|
||||
automationScheduleDashboard,
|
||||
automationHealthDashboard,
|
||||
automationDigestNotifyPrefs,
|
||||
@@ -32703,6 +32917,7 @@ const { voiceCsvMixin } = require('./voice-csv-mixin')
|
||||
const { screenCsvMixin } = require('./screen-csv-mixin')
|
||||
const { presenceCsvMixin } = require('./presence-csv-mixin')
|
||||
const { notificationCsvMixin } = require('./notification-csv-mixin')
|
||||
const { statusCsvMixin } = require('./status-csv-mixin')
|
||||
Object.assign(PearcordPlatform.prototype, pollSchedulingMixin)
|
||||
Object.assign(PearcordPlatform.prototype, notificationsActivityMixin)
|
||||
Object.assign(PearcordPlatform.prototype, userSettingsMixin)
|
||||
@@ -32739,3 +32954,4 @@ Object.assign(PearcordPlatform.prototype, voiceCsvMixin)
|
||||
Object.assign(PearcordPlatform.prototype, screenCsvMixin)
|
||||
Object.assign(PearcordPlatform.prototype, presenceCsvMixin)
|
||||
Object.assign(PearcordPlatform.prototype, notificationCsvMixin)
|
||||
Object.assign(PearcordPlatform.prototype, statusCsvMixin)
|
||||
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user