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:
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user