Files
pearcord-platform/mixins/domain/moderation.js
T
Raven Scott 2bd088567e feat: guild events, moderation hierarchy, audit depth, hidden channels
Guild scheduled events store/API, role-position moderation checks, DM thread
lock path, audit pagination, view hiddenChannels + composerMaxChars, mutual
guilds list, voice-note transcript helper.
2026-07-13 00:43:00 -04:00

222 lines
8.2 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)
},
/**
* Highest custom-role position for a member (Discord-style ladder).
* Builtin owner = +∞; moderator builtin = high; lurker/member = low baseline.
*/
async _memberModerationRank (userId) {
const guildId = this.guild?.guild?.id
if (!guildId || !userId) return { rank: -1, isOwner: false, roles: 'member' }
if (userId === this.guild.guild.ownerId) {
return { rank: Number.MAX_SAFE_INTEGER, isOwner: true, roles: 'owner' }
}
const mem = await this.db
.get(COLLECTIONS.MEMBERS, { guildId, userId })
.catch(() => null)
if (mem?.roles === 'owner') {
return { rank: Number.MAX_SAFE_INTEGER, isOwner: true, roles: 'owner' }
}
let rank = 0
const builtin = String(mem?.roles || 'member')
if (builtin === 'moderator') rank = 1000
else if (builtin === 'lurker') rank = -10
else rank = 0
if (this.guildRoles) {
const roleIds = await this.guildRoles.getMemberRoleIds(guildId, userId).catch(() => [])
const all = await this.guildRoles.listRoles(guildId).catch(() => [])
const byId = new Map((all || []).map((r) => [r.id, r]))
for (const rid of roleIds || []) {
const r = byId.get(rid)
if (r) rank = Math.max(rank, Number(r.position) || 0)
}
}
return { rank, isOwner: false, roles: builtin, member: mem }
},
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 target = await this._memberModerationRank(userId)
if (target.isOwner || target.roles === 'owner') {
throw new Error('cannot moderate the server owner')
}
const isGuildOwner = user.id === this.guild.guild.ownerId
if (isGuildOwner) return target.member || null
const actor = await this._memberModerationRank(user.id)
// Must strictly outrank target (equal top role cannot moderate peer).
if (actor.rank <= target.rank) {
throw new Error('cannot moderate members with an equal or higher role')
}
// Legacy builtin: non-owner mods cannot moderate other builtin moderators without custom rank edge
if (actor.roles !== 'moderator' && actor.roles !== 'owner' && target.roles === 'moderator' && target.rank >= 1000) {
throw new Error('cannot moderate members with a higher role')
}
return target.member || null
},
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
}