feat(bots): Phase 684 bots-mixin gossip dedupe and partition heal (v0.8.659)

Add bots-mixin.js with install gossip dedupe, registry/token cursor partition
heal, uninstallBot API, sendBotMessage permission fallback, guildBotCount span
metadata, and openBots deep-link field. Update README for Phase 684.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-06-03 00:20:54 -04:00
co-authored by Cursor
parent bcd96e4936
commit 611977a135
3 changed files with 165 additions and 5 deletions
+4
View File
@@ -2,6 +2,10 @@
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 684 (v0.8.659):** Bot integrations & install flow — `bots-mixin.js`, bot install gossip dedupe, `_healBotRegistryOnPartition` + `_healBotTokenCursorOnPartition`, `uninstallBot`, `guildBotCount`/`installKind` span metadata, deep link `openBots`. Bundle: `npm run test:ci-phase684`.
**Phase 683 (v0.8.658):** Slash commands registry — `slash-mixin.js`, command gossip dedupe, partition heal, deep link `openSlash`. Bundle: `npm run test:ci-phase683`.
**Phase 682 (v0.8.657):** Effective permissions & member roles — `member-roles-mixin.js`, member role gossip dedupe, `_healMemberRoleLinksOnPartition`, view `effectivePermissionCount`, deep link `?roles=1`. Bundle: `npm run test:ci-phase682`.
**Phase 681 (v0.8.656):** Channel permissions editor — `permissions-mixin.js`, overwrite gossip dedupe, `_healChannelOverwritesOnPartition` + `_healGuildRolesOnPartition`, `permissionOverwriteCount` span metadata, deep link `?permissions=1`. Bundle: `npm run test:ci-phase681`.
+99
View File
@@ -0,0 +1,99 @@
'use strict'
const botsMixin = {
_botInstallGossipKeys: null,
_botRegistryHealWatermark: null,
_botTokenHealWatermark: null,
_initBotsMixinState () {
if (!this._botInstallGossipKeys) {
this._botInstallGossipKeys = new Set()
}
},
_shouldGossipBotInstall (bot) {
this._initBotsMixinState()
if (!bot?.guildId || !bot?.id) return true
const hash = `${bot.guildId}:${bot.id}:${bot.name || ''}:${bot.permissions ?? ''}:${bot.intents ?? ''}:${bot.publicKey || ''}:${bot.createdAt || 0}`
if (this._botInstallGossipKeys.has(hash)) return false
this._botInstallGossipKeys.add(hash)
if (this._botInstallGossipKeys.size > 8192) {
const first = this._botInstallGossipKeys.values().next().value
if (first) this._botInstallGossipKeys.delete(first)
}
return true
},
async _healBotRegistryOnPartition (guildId) {
const gid = guildId || this.guild?.guild?.id || null
const span = this.log.time('bot.heal', {
spanKind: 'bot.heal',
guildId: gid,
slice: 'registry'
})
try {
if (!gid || !this.listBots) {
span.end({ relisted: 0, skipped: true })
return { relisted: 0, skipped: true }
}
const rows = await this.listBots().catch(() => [])
let relisted = 0
for (const row of rows) {
if (row.guildId && row.guildId !== gid) continue
if (this._shouldGossipBotInstall(row) && this.guild?.gossipBotInstall) {
this.guild.gossipBotInstall(row)
relisted++
}
}
const watermark = Date.now()
this._botRegistryHealWatermark = watermark
span.end({ relisted, watermark, botCount: rows.length })
return { relisted, watermark, botCount: rows.length }
} catch (err) {
this.log.error('bot.heal error', {
guildId: gid,
slice: 'registry',
error: err?.message || String(err)
})
span.fail(err)
return { relisted: 0, error: err?.message || String(err) }
}
},
async _healBotTokenCursorOnPartition (guildId) {
const gid = guildId || this.guild?.guild?.id || null
const span = this.log.time('bot.install', {
spanKind: 'bot.install',
guildId: gid,
context: 'heal.token'
})
try {
if (!gid) {
span.end({ relisted: 0, skipped: true, guildBotCount: 0 })
return { relisted: 0, skipped: true }
}
const list = await this.listBots().catch(() => [])
const watermark = Date.now()
this._botTokenHealWatermark = watermark
span.end({
relisted: 0,
watermark,
guildBotCount: list.length,
activeCount: list.filter((b) => b.active !== false).length,
guildCount: (this.guilds || []).length,
activeChannelId: this.activeChannelId || null
})
return { relisted: 0, watermark, guildBotCount: list.length }
} catch (err) {
this.log.error('bot.install error', {
guildId: gid,
context: 'heal.token',
error: err?.message || String(err)
})
span.fail(err)
return { relisted: 0, error: err?.message || String(err) }
}
}
}
module.exports = { botsMixin }
+62 -5
View File
@@ -1589,6 +1589,12 @@ class PearcordPlatform extends EventEmitter {
const slashUsageHeal = await this._healSlashUsageCursorOnPartition(gid).catch(() => ({
relisted: 0
}))
const botRegistryHeal = await this._healBotRegistryOnPartition(gid).catch(() => ({
relisted: 0
}))
const botTokenHeal = await this._healBotTokenCursorOnPartition(gid).catch(() => ({
relisted: 0
}))
return {
voiceApplied,
emojiSlots,
@@ -1621,7 +1627,9 @@ class PearcordPlatform extends EventEmitter {
memberRoleLinksHeal,
effectivePermCacheHeal,
slashRegistryHeal,
slashUsageHeal
slashUsageHeal,
botRegistryHeal,
botTokenHeal
}
}
@@ -17047,7 +17055,8 @@ class PearcordPlatform extends EventEmitter {
openFollowAnnouncement: !!parsed.followAnnouncement,
openChannelPermissions: !!parsed.editChannelPermissions,
openAssignMemberRoles: !!parsed.assignMemberRoles,
openSlashCommands: !!parsed.openSlashCommands
openSlashCommands: !!parsed.openSlashCommands,
openBots: !!parsed.openBots
}
}
@@ -20889,7 +20898,9 @@ class PearcordPlatform extends EventEmitter {
})
const bot = payloadToBotRow(payload, this.identity.snapshot().publicKey)
await this._ingestGuildBot(bot)
this.guild.gossipBotInstall(bot)
if (this._shouldGossipBotInstall(bot)) {
this.guild.gossipBotInstall(bot)
}
this.emit('bot', bot)
span.end({
guildId,
@@ -20897,7 +20908,9 @@ class PearcordPlatform extends EventEmitter {
botName: String(bot.name || '').slice(0, 32),
permissionMask: Number(bot.permissions) || 0,
tokenIssued: !!token,
guildBotCount: (await this.listBots().catch(() => [])).length,
spanKind: 'bot.token',
installKind: 'bot.install',
activeChannelId: this.activeChannelId
})
return { token, bot, expiresAt: payload.expiresAt }
@@ -20924,7 +20937,9 @@ class PearcordPlatform extends EventEmitter {
throw new Error('load the target server before installing a bot')
}
await this._ingestGuildBot(bot)
if (this.guild) this.guild.gossipBotInstall(bot)
if (this.guild && this._shouldGossipBotInstall(bot)) {
this.guild.gossipBotInstall(bot)
}
this.emit('bot', bot)
span.end({
botId: bot.id,
@@ -20932,7 +20947,9 @@ class PearcordPlatform extends EventEmitter {
botName: String(bot.name || '').slice(0, 32),
permissionMask: Number(bot.permissions) || 0,
installed: true,
guildBotCount: (await this.listBots().catch(() => [])).length,
spanKind: 'bot.install',
installKind: 'bot.install',
activeChannelId: this.activeChannelId,
guildCount: (this.guilds || []).length
})
@@ -20981,7 +20998,8 @@ class PearcordPlatform extends EventEmitter {
if (!this.guild?.guild) throw new Error('no guild')
const bot = await this.db.get(COLLECTIONS.GUILD_BOTS, { id: botId })
if (!bot) throw new Error('bot not found')
if (!botHasPermission(bot.permissions, BOT_PERMISSION.SEND_MESSAGES)) {
const perms = normalizePermissions(bot.permissions)
if (!botHasPermission(perms, BOT_PERMISSION.SEND_MESSAGES)) {
throw new Error('bot lacks SEND_MESSAGES permission')
}
if (!chId) throw new Error('no channel')
@@ -21022,6 +21040,43 @@ class PearcordPlatform extends EventEmitter {
return this.db.find(COLLECTIONS.GUILD_BOTS, { guildId: this.guild.guild.id })
}
async uninstallBot (botId) {
const guildId = this.guild?.guild?.id || null
const span = this.log.time('bot.uninstall', { botId, guildId })
try {
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 uninstall bots')
}
const bot = await this.db.get(COLLECTIONS.GUILD_BOTS, { id: botId })
if (!bot) throw new Error('bot not found')
if (bot.managed) throw new Error('managed bot cannot be uninstalled')
await this.db.delete(COLLECTIONS.GUILD_BOTS, { id: botId })
this.emit('bot', { ...bot, removed: true })
const remaining = await this.listBots().catch(() => [])
span.end({
botId,
guildId,
botName: String(bot.name || '').slice(0, 32),
uninstalled: true,
guildBotCount: remaining.length,
spanKind: 'bot.uninstall',
activeChannelId: this.activeChannelId,
guildCount: (this.guilds || []).length
})
return bot
} catch (err) {
this.log.error('bot.uninstall error', {
botId,
guildId,
error: err?.message || String(err)
})
span.fail(err)
throw err
}
}
async listChannelWebhooks () {
if (!this.guild?.guild) return []
await this._ensureGuildModules()
@@ -26287,6 +26342,7 @@ const { announcementsMixin } = require('./announcements-mixin')
const { permissionsMixin } = require('./permissions-mixin')
const { memberRolesMixin } = require('./member-roles-mixin')
const { slashMixin } = require('./slash-mixin')
const { botsMixin } = require('./bots-mixin')
Object.assign(PearcordPlatform.prototype, pollSchedulingMixin)
Object.assign(PearcordPlatform.prototype, notificationsActivityMixin)
Object.assign(PearcordPlatform.prototype, userSettingsMixin)
@@ -26297,3 +26353,4 @@ Object.assign(PearcordPlatform.prototype, announcementsMixin)
Object.assign(PearcordPlatform.prototype, permissionsMixin)
Object.assign(PearcordPlatform.prototype, memberRolesMixin)
Object.assign(PearcordPlatform.prototype, slashMixin)
Object.assign(PearcordPlatform.prototype, botsMixin)