'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 }