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.
222 lines
8.2 KiB
JavaScript
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
|
|
}
|