Move 136 prototype mixins under mixins/domain, json-export, and runtime/* so the package root stays navigable. Manifest assign order is unchanged; apply-platform-mixins and runtime registry paths updated. Co-authored-by: Cursor <[email protected]>
182 lines
6.4 KiB
JavaScript
182 lines
6.4 KiB
JavaScript
'use strict'
|
|
|
|
const { COLLECTIONS, roleHasPermission, PERMISSION } = require('pearcord-shared')
|
|
|
|
const AUDIT_GOSSIP_WINDOW = 4096
|
|
|
|
const moderationMixin = {
|
|
_auditGossipKeys: null,
|
|
_auditHealWatermark: null,
|
|
_banHealWatermark: null,
|
|
|
|
_auditGossipKey (payload) {
|
|
if (!payload?.id) return ''
|
|
return `audit:${payload.id}:${payload.createdAt || 0}`
|
|
},
|
|
|
|
_shouldGossipAuditEntry (payload) {
|
|
const hash = this._auditGossipKey(payload)
|
|
if (!hash) return true
|
|
if (!this._auditGossipKeys) this._auditGossipKeys = new Set()
|
|
if (this._auditGossipKeys.has(hash)) return false
|
|
this._auditGossipKeys.add(hash)
|
|
if (this._auditGossipKeys.size > AUDIT_GOSSIP_WINDOW) {
|
|
const first = this._auditGossipKeys.values().next().value
|
|
if (first) this._auditGossipKeys.delete(first)
|
|
}
|
|
return true
|
|
},
|
|
|
|
_moderationActionMeta (action) {
|
|
return {
|
|
spanKind: 'moderation.action',
|
|
moderationAction: action
|
|
}
|
|
},
|
|
|
|
_classifyAuditFilterKind (actionStr) {
|
|
const a = String(actionStr || '').toLowerCase()
|
|
if (a.startsWith('automod.')) return 'automod'
|
|
if (a.startsWith('automation.')) return 'automation'
|
|
if (a.includes('member.ban') || a.includes('member.unban')) return 'bans'
|
|
if (a.includes('member.kick')) return 'kicks'
|
|
if (a.startsWith('member.role') || a.startsWith('role.')) return 'roles'
|
|
if (a.startsWith('channel.')) return 'channels'
|
|
if (
|
|
a.startsWith('member.') ||
|
|
a.startsWith('voice_') ||
|
|
a.startsWith('stage_') ||
|
|
a.startsWith('stage.')
|
|
) {
|
|
return 'moderation'
|
|
}
|
|
return 'other'
|
|
},
|
|
|
|
_filterAuditEntriesExtended (entries = [], filter = 'all') {
|
|
if (!filter || filter === 'all') return entries
|
|
return (entries || []).filter((e) => this._classifyAuditFilterKind(e.action) === filter)
|
|
},
|
|
|
|
async _assertCanModerateMember (userId) {
|
|
const user = this.identity.user
|
|
if (!user || !this.guild?.guild) throw new Error('no guild')
|
|
if (this.mode !== 'guild') throw new Error('moderation not available in DM')
|
|
if (userId === user.id) throw new Error('cannot moderate yourself')
|
|
if (userId === this.guild.guild.ownerId) throw new Error('cannot moderate the server owner')
|
|
const mem = await this.db
|
|
.get(COLLECTIONS.MEMBERS, { guildId: this.guild.guild.id, userId })
|
|
.catch(() => null)
|
|
if (mem?.roles === 'owner') throw new Error('cannot moderate the server owner')
|
|
const isGuildOwner = user.id === this.guild.guild.ownerId
|
|
if (!isGuildOwner && mem?.roles === 'moderator') {
|
|
throw new Error('cannot moderate members with a higher role')
|
|
}
|
|
return mem
|
|
},
|
|
|
|
async _healAuditLogOnPartition (guildId) {
|
|
const gid = guildId || this.guild?.guild?.id || null
|
|
const span = this.log.time('audit.heal', { spanKind: 'audit.heal', guildId: gid })
|
|
try {
|
|
if (!gid) {
|
|
span.end({ relisted: 0, skipped: true })
|
|
return { relisted: 0, skipped: true }
|
|
}
|
|
const rows = await this.db.find(COLLECTIONS.AUDIT_LOG, { guildId: gid })
|
|
const seen = new Set()
|
|
let deduped = 0
|
|
for (const row of rows) {
|
|
if (!row?.id) continue
|
|
if (seen.has(row.id)) {
|
|
await this.db.delete(COLLECTIONS.AUDIT_LOG, { id: row.id }).catch(() => {})
|
|
deduped += 1
|
|
continue
|
|
}
|
|
seen.add(row.id)
|
|
}
|
|
const watermark = Date.now()
|
|
this._auditHealWatermark = watermark
|
|
span.end({ relisted: seen.size, deduped, watermark, auditLogVersion: seen.size })
|
|
return { relisted: seen.size, deduped, watermark }
|
|
} catch (err) {
|
|
this.log.error('audit.heal error', { guildId: gid, error: err?.message || String(err) })
|
|
span.fail(err)
|
|
return { relisted: 0, error: err?.message || String(err) }
|
|
}
|
|
},
|
|
|
|
async _healBanListOnPartition (guildId) {
|
|
const gid = guildId || this.guild?.guild?.id || null
|
|
const span = this.log.time('moderation.heal', { spanKind: 'moderation.heal', guildId: gid })
|
|
try {
|
|
if (!gid) {
|
|
span.end({ relisted: 0, skipped: true })
|
|
return { relisted: 0, skipped: true }
|
|
}
|
|
const bans = await this.db.find(COLLECTIONS.BANS, { guildId: gid })
|
|
const seen = new Set()
|
|
let deduped = 0
|
|
for (const row of bans) {
|
|
const key = `${row.guildId}:${row.userId}`
|
|
if (!row?.userId) continue
|
|
if (seen.has(key)) {
|
|
await this.db.delete(COLLECTIONS.BANS, { guildId: gid, userId: row.userId }).catch(() => {})
|
|
deduped += 1
|
|
continue
|
|
}
|
|
seen.add(key)
|
|
}
|
|
const watermark = Date.now()
|
|
this._banHealWatermark = watermark
|
|
span.end({ relisted: seen.size, deduped, watermark, banCount: seen.size })
|
|
return { relisted: seen.size, deduped, watermark }
|
|
} catch (err) {
|
|
this.log.error('moderation.heal error', { guildId: gid, error: err?.message || String(err) })
|
|
span.fail(err)
|
|
return { relisted: 0, error: err?.message || String(err) }
|
|
}
|
|
},
|
|
|
|
async unbanMember ({ userId }) {
|
|
const guildId = this.guild?.guild?.id || null
|
|
const span = this.log.time('ban.unban', { userId, guildId })
|
|
try {
|
|
const user = this.identity.user
|
|
if (!user || !this.guild?.guild) throw new Error('no guild')
|
|
if (this.mode !== 'guild') throw new Error('moderation not available in DM')
|
|
await this._assertStepUp()
|
|
const roles = await this._memberRoles()
|
|
if (!roleHasPermission(roles, PERMISSION.MODERATE_MEMBERS)) {
|
|
throw new Error('no permission to unban')
|
|
}
|
|
const ban = await this.db.get(COLLECTIONS.BANS, {
|
|
guildId: this.guild.guild.id,
|
|
userId
|
|
})
|
|
if (!ban) throw new Error('user is not banned')
|
|
await this.db.delete(COLLECTIONS.BANS, { guildId: this.guild.guild.id, userId })
|
|
await this._audit('member.unban', { guildId: this.guild.guild.id, targetId: userId })
|
|
const banRows = await this.db.find(COLLECTIONS.BANS, { guildId: this.guild.guild.id })
|
|
span.end({
|
|
userId,
|
|
guildId,
|
|
banCount: banRows.length,
|
|
...this._moderationActionMeta('unban'),
|
|
activeChannelId: this.activeChannelId,
|
|
guildCount: (this.guilds || []).length
|
|
})
|
|
return { guildId: this.guild.guild.id, userId }
|
|
} catch (err) {
|
|
this.log.error('ban.unban error', { userId, guildId, error: err?.message || String(err) })
|
|
span.fail(err)
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
moderationMixin,
|
|
AUDIT_GOSSIP_WINDOW
|
|
}
|