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.
This commit is contained in:
Raven Scott
2026-07-13 00:43:00 -04:00
parent c101ac832a
commit 2bd088567e
10 changed files with 534 additions and 22 deletions
+46 -6
View File
@@ -58,21 +58,61 @@ const moderationMixin = {
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 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 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 && mem?.roles === 'moderator') {
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 mem
return target.member || null
},
async _healAuditLogOnPartition (guildId) {
+186
View File
@@ -2,6 +2,11 @@
const { GuildPollStore, isPollClosed } = require('pearcord-polls')
const { ScheduledMessageStore } = require('pearcord-scheduled-messages')
const {
GuildEventsStore,
EVENT_STATUSES,
effectiveEventStatus
} = require('../../guild-events-store')
const {
COLLECTIONS,
DM_GUILD_ID,
@@ -37,6 +42,157 @@ const pollSchedulingMixin = {
return this._scheduledMessageStore
},
async _ensureGuildEventsStore () {
if (this._guildEventsStore) return this._guildEventsStore
this._guildEventsStore = new GuildEventsStore({ storagePath: this.storagePath })
await this._guildEventsStore.ready()
return this._guildEventsStore
},
_guildEventGossipKey (payload) {
if (!payload?.id || !payload?.guildId) return ''
return `gev:${payload.guildId}:${payload.id}:${payload.updatedAt || 0}:${payload.status || ''}`
},
_shouldGossipGuildEvent (payload) {
const hash = this._guildEventGossipKey(payload)
if (!hash) return false
if (!this._guildEventGossipKeys) this._guildEventGossipKeys = new Set()
if (this._guildEventGossipKeys.has(hash)) return false
this._guildEventGossipKeys.add(hash)
if (this._guildEventGossipKeys.size > 4096) {
const first = this._guildEventGossipKeys.values().next().value
if (first) this._guildEventGossipKeys.delete(first)
}
return true
},
_gossipGuildEventIfNew (payload) {
if (!this._shouldGossipGuildEvent(payload) || !this.guild?.gossipGuildScheduledEvent) return
this.guild.gossipGuildScheduledEvent(payload)
},
async _assertCanManageGuildEvents () {
if (this.mode !== 'guild' || !this.guild?.guild) {
throw new Error('guild events require a server')
}
const uid = this.identity?.user?.id
if (!uid) throw new Error('not signed in')
const mem = await this.db
.get(COLLECTIONS.MEMBERS, { guildId: this.guild.guild.id, userId: uid })
.catch(() => null)
const roles = mem?.roles || 'member'
if (roles === 'owner') return true
if (roleHasPermission(roles, PERMISSION.MANAGE_GUILD)) return true
if (roleHasPermission(roles, PERMISSION.MANAGE_CHANNELS)) return true
throw new Error('Manage Server or Manage Channels required to edit events')
},
async listGuildEvents ({ includeCancelled = true } = {}) {
const guildId = this.guild?.guild?.id
if (!guildId || this.mode !== 'guild') return []
const store = await this._ensureGuildEventsStore()
return store.list(guildId, { includeCancelled, limit: 64 })
},
async createGuildEvent (payload = {}) {
await this._assertCanManageGuildEvents()
const guildId = this.guild.guild.id
const userId = this.identity.user.id
const store = await this._ensureGuildEventsStore()
await store.assertCapacity(guildId)
const startAt = Number(payload.startAt) || 0
if (!startAt || startAt < Date.now() - 60_000) {
throw new Error('event start must be in the future (or within the last minute)')
}
const row = await store.upsert({
id: id(),
guildId,
name: payload.name,
description: payload.description || '',
location: payload.location || null,
channelId: payload.channelId || null,
kind: payload.kind || 'external',
status: EVENT_STATUSES.SCHEDULED,
startAt,
endAt: payload.endAt != null ? Number(payload.endAt) || null : null,
createdBy: userId,
createdAt: Date.now()
})
this._gossipGuildEventIfNew(row)
this.emit('guild-scheduled-event', row)
this.log.info('guild.event.create', {
guildId,
eventId: row.id,
startAt: row.startAt,
kind: row.kind
})
return row
},
async updateGuildEvent (eventId, patch = {}) {
await this._assertCanManageGuildEvents()
const guildId = this.guild.guild.id
const store = await this._ensureGuildEventsStore()
const prev = await store.get(guildId, eventId)
if (!prev) throw new Error('event not found')
if (normalizeEventStatus(prev.status) === EVENT_STATUSES.CANCELLED) {
throw new Error('cancelled events cannot be edited')
}
const next = await store.upsert({
...prev,
id: prev.eventId || prev.id,
guildId,
name: patch.name != null ? patch.name : prev.name,
description: patch.description != null ? patch.description : prev.description,
location: patch.location !== undefined ? patch.location : prev.location,
channelId: patch.channelId !== undefined ? patch.channelId : prev.channelId,
kind: patch.kind != null ? patch.kind : prev.kind,
status: patch.status != null ? patch.status : prev.status,
startAt: patch.startAt != null ? Number(patch.startAt) : prev.startAt,
endAt: patch.endAt !== undefined ? (patch.endAt == null ? null : Number(patch.endAt)) : prev.endAt,
createdBy: prev.createdBy,
createdAt: prev.createdAt
})
this._gossipGuildEventIfNew(next)
this.emit('guild-scheduled-event', next)
return next
},
async cancelGuildEvent (eventId) {
await this._assertCanManageGuildEvents()
const guildId = this.guild.guild.id
const store = await this._ensureGuildEventsStore()
const prev = await store.get(guildId, eventId)
if (!prev) throw new Error('event not found')
const next = await store.upsert({
...prev,
id: prev.eventId || prev.id,
guildId,
status: EVENT_STATUSES.CANCELLED
})
this._gossipGuildEventIfNew(next)
this.emit('guild-scheduled-event', next)
this.log.info('guild.event.cancel', { guildId, eventId })
return next
},
async ingestGuildScheduledEvent (payload) {
if (!payload?.id || !payload?.guildId) return null
const store = await this._ensureGuildEventsStore()
const prev = await store.get(payload.guildId, payload.id)
if (prev && Number(prev.updatedAt || 0) > Number(payload.updatedAt || 0)) {
return prev
}
const next = await store.upsert({
...payload,
id: payload.id,
guildId: payload.guildId
})
this.emit('guild-scheduled-event', next)
return next
},
_pollGossipKey (payload) {
if (!payload) return ''
if (payload.messageId && payload.updatedAt) {
@@ -533,7 +689,37 @@ const pollSchedulingMixin = {
} catch {
return 'json'
}
},
async _guildEventsSnapshotForView () {
if (this.mode !== 'guild' || !this.guild?.guild?.id) return []
try {
const rows = await this.listGuildEvents({ includeCancelled: true })
return rows.map((r) => ({
id: r.eventId || r.id,
guildId: r.guildId,
name: r.name,
description: r.description || '',
location: r.location || null,
channelId: r.channelId || null,
kind: r.kind || 'external',
status: r.status,
effectiveStatus: r.effectiveStatus || effectiveEventStatus(r),
startAt: r.startAt,
endAt: r.endAt || null,
createdBy: r.createdBy || null,
updatedAt: r.updatedAt || null
}))
} catch {
return []
}
}
}
function normalizeEventStatus (s) {
const v = String(s || '').toLowerCase()
if (v === 'cancelled' || v === 'active' || v === 'completed' || v === 'scheduled') return v
return 'scheduled'
}
module.exports = { pollSchedulingMixin }