feat(guild): owner deleteGuild with mesh gossip and local purge
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -33,7 +33,7 @@ Expose a single `EventEmitter` API the UI drives over IPC (`apps/pearcord/index.
|
|||||||
| `register({ username, displayName })` | `_bootstrapAfterUser` |
|
| `register({ username, displayName })` | `_bootstrapAfterUser` |
|
||||||
| `importIdentityBundle(bundle)` | Device pair restore |
|
| `importIdentityBundle(bundle)` | Device pair restore |
|
||||||
| `onboarded`, `identity`, `storagePath`, `db`, `dbPath` | Session state |
|
| `onboarded`, `identity`, `storagePath`, `db`, `dbPath` | Session state |
|
||||||
| `listGuilds()`, `loadGuild(guildId)`, `createGuild({ name, publicListing? })`, `joinInvite(code)` | Guild session |
|
| `listGuilds()`, `loadGuild(guildId)`, `createGuild({ name, publicListing? })`, `deleteGuild({ confirmName? })`, `joinInvite(code)` | Guild session; owner-only `deleteGuild` gossips `GUILD_DELETE`, revokes discovery, purges local data ([GUILD_DELETE.md](../../docs/GUILD_DELETE.md)) |
|
||||||
|
|
||||||
**v0.8.431 (Phase 468):** `dm.open` span.end includes `group`, `peerUserId`, `participantCount`; `discovery.list` span.end includes `filterTag`; `contacts.accept` span.end includes `peerUserId`; `dm.open`/`contacts.accept`/`discovery.list` error logs use `error:` key. Bundle: `npm run test:phase468-discovery-dm`. See [DISCOVERY.md](../../docs/DISCOVERY.md), [CONTACTS.md](../../docs/CONTACTS.md).
|
**v0.8.431 (Phase 468):** `dm.open` span.end includes `group`, `peerUserId`, `participantCount`; `discovery.list` span.end includes `filterTag`; `contacts.accept` span.end includes `peerUserId`; `dm.open`/`contacts.accept`/`discovery.list` error logs use `error:` key. Bundle: `npm run test:phase468-discovery-dm`. See [DISCOVERY.md](../../docs/DISCOVERY.md), [CONTACTS.md](../../docs/CONTACTS.md).
|
||||||
|
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ class PearcordPlatform extends EventEmitter {
|
|||||||
this._guildLoading = false
|
this._guildLoading = false
|
||||||
this._guildLoadingGuildId = null
|
this._guildLoadingGuildId = null
|
||||||
this._guildLoadGeneration = 0
|
this._guildLoadGeneration = 0
|
||||||
|
this._guildDeleteInProgress = null
|
||||||
this._guildOpenNoViewableChannels = false
|
this._guildOpenNoViewableChannels = false
|
||||||
this._emojiAttachmentMissUntil = new Map()
|
this._emojiAttachmentMissUntil = new Map()
|
||||||
this._stickerAttachmentMissUntil = new Map()
|
this._stickerAttachmentMissUntil = new Map()
|
||||||
@@ -3500,6 +3501,14 @@ class PearcordPlatform extends EventEmitter {
|
|||||||
this._fanoutBotEvent(BOT_EVENTS.GUILD_UPDATE, { guild: g })
|
this._fanoutBotEvent(BOT_EVENTS.GUILD_UPDATE, { guild: g })
|
||||||
this.emit('guild-update', g)
|
this.emit('guild-update', g)
|
||||||
})
|
})
|
||||||
|
guildInstance.on('guild-delete', (payload) => {
|
||||||
|
this._ingestGuildDeleteFromNetwork(payload).catch((err) => {
|
||||||
|
this.log.warn('guild.delete ingest failed', {
|
||||||
|
guildId: payload?.guildId,
|
||||||
|
err: err?.message || String(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
guildInstance.on('kick', (p) => this._onKickGossip(p))
|
guildInstance.on('kick', (p) => this._onKickGossip(p))
|
||||||
guildInstance.on('timeout', (p) => {
|
guildInstance.on('timeout', (p) => {
|
||||||
this.moderation?.ingestGossip(p).catch(() => {})
|
this.moderation?.ingestGossip(p).catch(() => {})
|
||||||
@@ -5715,6 +5724,244 @@ class PearcordPlatform extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _purgeGuildLocalData (guildId) {
|
||||||
|
const gid = String(guildId || '')
|
||||||
|
if (!gid) return
|
||||||
|
const channels = await this.db.find(COLLECTIONS.CHANNELS, { guildId: gid })
|
||||||
|
const channelIds = new Set(channels.map((c) => c.id))
|
||||||
|
const messages = await this.db.find(COLLECTIONS.MESSAGES, { guildId: gid })
|
||||||
|
for (const m of messages) {
|
||||||
|
await this.db.delete(COLLECTIONS.MESSAGES, { channelId: m.channelId, id: m.id })
|
||||||
|
}
|
||||||
|
for (const channelId of channelIds) {
|
||||||
|
const reactions = await this.db.find(COLLECTIONS.REACTIONS, { channelId })
|
||||||
|
for (const r of reactions) {
|
||||||
|
await this.db.delete(COLLECTIONS.REACTIONS, {
|
||||||
|
channelId: r.channelId,
|
||||||
|
messageId: r.messageId,
|
||||||
|
emoji: r.emoji,
|
||||||
|
userId: r.userId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const pins = await this.db.find(COLLECTIONS.PINS, { channelId })
|
||||||
|
for (const p of pins) {
|
||||||
|
await this.db.delete(COLLECTIONS.PINS, {
|
||||||
|
channelId: p.channelId,
|
||||||
|
messageId: p.messageId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const rateLimits = await this.db.find(COLLECTIONS.RATE_LIMITS, { channelId })
|
||||||
|
for (const r of rateLimits) {
|
||||||
|
await this.db.delete(COLLECTIONS.RATE_LIMITS, {
|
||||||
|
userId: r.userId,
|
||||||
|
channelId: r.channelId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const ch of channels) {
|
||||||
|
await this.db.delete(COLLECTIONS.CHANNELS, { guildId: gid, id: ch.id })
|
||||||
|
await this.db
|
||||||
|
.delete(COLLECTIONS.THREAD_META, { guildId: gid, channelId: ch.id })
|
||||||
|
.catch(() => {})
|
||||||
|
await this.db
|
||||||
|
.delete(COLLECTIONS.CHANNEL_SETTINGS, { guildId: gid, channelId: ch.id })
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
const memberRows = await this.db.find(COLLECTIONS.MEMBERS, { guildId: gid })
|
||||||
|
for (const row of memberRows) {
|
||||||
|
await this.db.delete(COLLECTIONS.MEMBERS, { guildId: gid, userId: row.userId })
|
||||||
|
}
|
||||||
|
const banRows = await this.db.find(COLLECTIONS.BANS, { guildId: gid })
|
||||||
|
for (const row of banRows) {
|
||||||
|
await this.db.delete(COLLECTIONS.BANS, { guildId: gid, userId: row.userId })
|
||||||
|
}
|
||||||
|
const slashRows = await this.db.find(COLLECTIONS.SLASH_COMMANDS, { guildId: gid })
|
||||||
|
for (const row of slashRows) {
|
||||||
|
await this.db.delete(COLLECTIONS.SLASH_COMMANDS, { guildId: gid, name: row.name })
|
||||||
|
}
|
||||||
|
const readRows = await this.db.find(COLLECTIONS.READ_STATE, { guildId: gid })
|
||||||
|
for (const row of readRows) {
|
||||||
|
await this.db.delete(COLLECTIONS.READ_STATE, {
|
||||||
|
userId: row.userId,
|
||||||
|
guildId: gid,
|
||||||
|
channelId: row.channelId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const mentionRows = await this.db.find(COLLECTIONS.MENTION_ALERTS, { guildId: gid })
|
||||||
|
for (const row of mentionRows) {
|
||||||
|
await this.db.delete(COLLECTIONS.MENTION_ALERTS, {
|
||||||
|
userId: row.userId,
|
||||||
|
guildId: gid,
|
||||||
|
channelId: row.channelId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const invites = await this.db.find(COLLECTIONS.INVITES, { guildId: gid })
|
||||||
|
for (const inv of invites) {
|
||||||
|
await this.db.delete(COLLECTIONS.INVITES, { code: inv.code })
|
||||||
|
}
|
||||||
|
const bots = await this.db.find(COLLECTIONS.GUILD_BOTS, { guildId: gid })
|
||||||
|
for (const b of bots) {
|
||||||
|
await this.db.delete(COLLECTIONS.GUILD_BOTS, { id: b.id })
|
||||||
|
}
|
||||||
|
const audit = await this.db.find(COLLECTIONS.AUDIT_LOG, { guildId: gid })
|
||||||
|
for (const a of audit) {
|
||||||
|
await this.db.delete(COLLECTIONS.AUDIT_LOG, { id: a.id })
|
||||||
|
}
|
||||||
|
await this.db.delete(COLLECTIONS.GUILDS, { id: gid })
|
||||||
|
const rolesStore = new PearcordGuildRoles({ storagePath: this.storagePath, guildId: gid })
|
||||||
|
await rolesStore.ready()
|
||||||
|
await rolesStore.purgeGuild(gid).catch(() => {})
|
||||||
|
const emojiStore = this.emojiRegistry || new (require('pearcord-emoji').PearcordEmojiRegistry)({
|
||||||
|
storagePath: this.storagePath,
|
||||||
|
guildId: gid
|
||||||
|
})
|
||||||
|
if (!this.emojiRegistry) await emojiStore.ready()
|
||||||
|
await emojiStore.purgeGuild(gid).catch(() => {})
|
||||||
|
const stickerStore =
|
||||||
|
this.stickerRegistry ||
|
||||||
|
new (require('pearcord-stickers').PearcordStickerRegistry)({
|
||||||
|
storagePath: this.storagePath,
|
||||||
|
guildId: gid
|
||||||
|
})
|
||||||
|
if (!this.stickerRegistry) await stickerStore.ready()
|
||||||
|
await stickerStore.purgeGuild(gid).catch(() => {})
|
||||||
|
if (this.automationExport) {
|
||||||
|
await this._initAutomationExport(gid)
|
||||||
|
const hooks = await this.automationExport.listHooks().catch(() => [])
|
||||||
|
for (const h of hooks) {
|
||||||
|
await this.automationExport.deleteHook(h.id).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.clearGuildSearchIndex(gid)
|
||||||
|
await this.notifications?.clearForGuild(gid).catch(() => {})
|
||||||
|
this._guildPresenceSummary.delete(gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _cleanupUserPrefsAfterGuildDelete (guildId) {
|
||||||
|
const gid = String(guildId || '')
|
||||||
|
if (!gid || !this.userSettings) return
|
||||||
|
const prefs = await this.userSettings.getPrefs().catch(() => ({}))
|
||||||
|
const lastChannelByGuild = { ...(prefs.lastChannelByGuild || {}) }
|
||||||
|
delete lastChannelByGuild[gid]
|
||||||
|
const searchRecentByGuild = { ...(prefs.searchRecentByGuild || {}) }
|
||||||
|
delete searchRecentByGuild[gid]
|
||||||
|
const mutedGuildIds = (prefs.mutedGuildIds || []).filter((id) => id !== gid)
|
||||||
|
let serverFolders = prefs.serverFolders
|
||||||
|
if (Array.isArray(serverFolders)) {
|
||||||
|
serverFolders = serverFolders.map((folder) => {
|
||||||
|
if (!folder || typeof folder !== 'object') return folder
|
||||||
|
const guildIds = (folder.guildIds || []).filter((id) => id !== gid)
|
||||||
|
return { ...folder, guildIds }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const patch = {
|
||||||
|
lastChannelByGuild,
|
||||||
|
searchRecentByGuild,
|
||||||
|
mutedGuildIds
|
||||||
|
}
|
||||||
|
if (serverFolders) patch.serverFolders = serverFolders
|
||||||
|
if (prefs.companionLastGuildId === gid) patch.companionLastGuildId = null
|
||||||
|
await this.userSettings.setPrefs(patch, { gossip: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async _navigateAfterGuildRemoved (guildId) {
|
||||||
|
const wasActive = this.guild?.guild?.id === guildId
|
||||||
|
if (!wasActive) return
|
||||||
|
await this._leaveMeshes()
|
||||||
|
this.guild = null
|
||||||
|
this.messages = null
|
||||||
|
this.activeChannelId = null
|
||||||
|
this.voice = null
|
||||||
|
this._invalidateSearchCache()
|
||||||
|
const remaining = await this.listGuilds()
|
||||||
|
this.guilds = remaining
|
||||||
|
if (remaining.length) {
|
||||||
|
await this.loadGuild(remaining[0].id)
|
||||||
|
} else {
|
||||||
|
this.mode = 'home'
|
||||||
|
this.emit('home')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _ingestGuildDeleteFromNetwork (payload) {
|
||||||
|
const guildId = payload?.guildId || payload?.id
|
||||||
|
if (!guildId) return null
|
||||||
|
const exists = await this.db.get(COLLECTIONS.GUILDS, { id: guildId }).catch(() => null)
|
||||||
|
if (!exists) return null
|
||||||
|
if (this._guildDeleteInProgress === guildId) return payload
|
||||||
|
await this._purgeGuildLocalData(guildId)
|
||||||
|
await this._cleanupUserPrefsAfterGuildDelete(guildId)
|
||||||
|
await this.discovery?.removePublicListing(guildId).catch(() => {})
|
||||||
|
await this.discovery?.removeGuildDirectory(guildId).catch(() => {})
|
||||||
|
this.guilds = await this.listGuilds()
|
||||||
|
await this._navigateAfterGuildRemoved(guildId)
|
||||||
|
this.emit('guild-deleted', payload)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteGuild (opts = {}) {
|
||||||
|
const user = this.identity.user
|
||||||
|
if (!user) throw new Error('register first')
|
||||||
|
if (!this.guild?.guild) throw new Error('no guild')
|
||||||
|
const guild = this.guild.guild
|
||||||
|
if (guild.ownerId !== user.id) {
|
||||||
|
throw new Error('only the server owner can delete this server')
|
||||||
|
}
|
||||||
|
const guildId = guild.id
|
||||||
|
const confirmName = String(opts.confirmName || '').trim()
|
||||||
|
if (confirmName && confirmName !== guild.name) {
|
||||||
|
throw new Error('server name does not match')
|
||||||
|
}
|
||||||
|
const span = this.log.time('guild.delete', {
|
||||||
|
guildId,
|
||||||
|
spanKind: 'guild.delete'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
this._guildDeleteInProgress = guildId
|
||||||
|
const payload = {
|
||||||
|
guildId,
|
||||||
|
deletedAt: Date.now(),
|
||||||
|
deletedBy: user.id,
|
||||||
|
name: guild.name
|
||||||
|
}
|
||||||
|
if (this.guild?.peers?.size) {
|
||||||
|
this.guild.gossipGuildDelete(payload)
|
||||||
|
}
|
||||||
|
await this.discovery?.revokeGuildListing(guildId).catch(() => {})
|
||||||
|
await this._leaveMeshes()
|
||||||
|
await this._purgeGuildLocalData(guildId)
|
||||||
|
await this._cleanupUserPrefsAfterGuildDelete(guildId)
|
||||||
|
this.guild = null
|
||||||
|
this.messages = null
|
||||||
|
this.activeChannelId = null
|
||||||
|
this._invalidateSearchCache()
|
||||||
|
this.guilds = await this.listGuilds()
|
||||||
|
const remaining = this.guilds
|
||||||
|
if (remaining.length) {
|
||||||
|
await this.loadGuild(remaining[0].id)
|
||||||
|
} else {
|
||||||
|
this.mode = 'home'
|
||||||
|
this.emit('home')
|
||||||
|
}
|
||||||
|
this.emit('guild-deleted', payload)
|
||||||
|
span.end({
|
||||||
|
guildId,
|
||||||
|
deletedAt: payload.deletedAt,
|
||||||
|
remainingGuildCount: remaining.length,
|
||||||
|
activeChannelId: this.activeChannelId,
|
||||||
|
guildCount: remaining.length,
|
||||||
|
spanKind: 'guild.delete'
|
||||||
|
})
|
||||||
|
return payload
|
||||||
|
} catch (err) {
|
||||||
|
this.log.error('guild.delete error', { guildId, err: err?.message || String(err) })
|
||||||
|
span.fail(err)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
if (this._guildDeleteInProgress === guildId) this._guildDeleteInProgress = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async stageAttachment ({ data, filename, mimeType }) {
|
async stageAttachment ({ data, filename, mimeType }) {
|
||||||
const guildId = this.mode === 'dm' ? DM_GUILD_ID : this.guild?.guild?.id || null
|
const guildId = this.mode === 'dm' ? DM_GUILD_ID : this.guild?.guild?.id || null
|
||||||
const channelId = this.activeChannelId
|
const channelId = this.activeChannelId
|
||||||
@@ -14619,6 +14866,10 @@ class PearcordPlatform extends EventEmitter {
|
|||||||
activityFeed,
|
activityFeed,
|
||||||
stats,
|
stats,
|
||||||
dbEngine: this.db.getEngine?.() || 'json',
|
dbEngine: this.db.getEngine?.() || 'json',
|
||||||
|
isGuildOwner:
|
||||||
|
this.mode === 'guild' &&
|
||||||
|
!!this.guild?.guild?.ownerId &&
|
||||||
|
this.guild.guild.ownerId === this.identity?.user?.id,
|
||||||
myPermissions,
|
myPermissions,
|
||||||
parentChannelPermissions,
|
parentChannelPermissions,
|
||||||
canPostInActiveChannel,
|
canPostInActiveChannel,
|
||||||
|
|||||||
Reference in New Issue
Block a user