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
+157
View File
@@ -0,0 +1,157 @@
'use strict'
const path = require('bare-path')
const { JsonStore } = require('pearcord-db/store-json')
const COLLECTION = '@pearcord/guild-scheduled-events'
const MAX_EVENTS_PER_GUILD = 64
const MAX_NAME_LEN = 100
const MAX_DESC_LEN = 1000
const MAX_LOCATION_LEN = 200
const EVENT_STATUSES = Object.freeze({
SCHEDULED: 'scheduled',
ACTIVE: 'active',
COMPLETED: 'completed',
CANCELLED: 'cancelled'
})
const EVENT_KINDS = Object.freeze({
EXTERNAL: 'external',
VOICE: 'voice',
STAGE: 'stage',
TEXT: 'text'
})
function normalizeStatus (s) {
const v = String(s || EVENT_STATUSES.SCHEDULED).toLowerCase()
if (Object.values(EVENT_STATUSES).includes(v)) return v
return EVENT_STATUSES.SCHEDULED
}
function normalizeKind (k) {
const v = String(k || EVENT_KINDS.EXTERNAL).toLowerCase()
if (Object.values(EVENT_KINDS).includes(v)) return v
return EVENT_KINDS.EXTERNAL
}
/**
* Derive display status from stored status + start/end times.
* Cancelled stays cancelled; otherwise clock advances scheduled→active→completed.
*/
function effectiveEventStatus (row, now = Date.now()) {
const st = normalizeStatus(row?.status)
if (st === EVENT_STATUSES.CANCELLED) return EVENT_STATUSES.CANCELLED
const start = Number(row?.startAt) || 0
const end = Number(row?.endAt) || 0
if (end && now >= end) return EVENT_STATUSES.COMPLETED
if (start && now >= start) return EVENT_STATUSES.ACTIVE
return EVENT_STATUSES.SCHEDULED
}
/**
* Local P2P guild scheduled events (Discord-style Events calendar, no HTTP).
* Mesh-synced via GUILD_SCHEDULED_EVENT_UPSERT gossip.
*/
class GuildEventsStore {
constructor (opts = {}) {
this.storagePath = opts.storagePath || './pearcord-storage'
this.store = new JsonStore(path.join(this.storagePath, 'guild-events'))
}
async ready () {
await this.store.ready()
return this
}
_key (guildId, id) {
return { id: `${guildId}:${id}`, guildId, eventId: id }
}
normalize (partial = {}) {
const guildId = String(partial.guildId || '').trim()
const id = String(partial.id || partial.eventId || '').trim()
const name = String(partial.name || '').trim().slice(0, MAX_NAME_LEN)
const description = String(partial.description || '').trim().slice(0, MAX_DESC_LEN)
const location = String(partial.location || '').trim().slice(0, MAX_LOCATION_LEN)
const startAt = Number(partial.startAt) || 0
let endAt = partial.endAt != null ? Number(partial.endAt) || 0 : 0
if (endAt && startAt && endAt < startAt) endAt = startAt
return {
id,
guildId,
name,
description,
location: location || null,
channelId: partial.channelId ? String(partial.channelId) : null,
kind: normalizeKind(partial.kind),
status: normalizeStatus(partial.status),
startAt,
endAt: endAt || null,
createdBy: partial.createdBy || null,
createdAt: Number(partial.createdAt) || Date.now(),
updatedAt: Number(partial.updatedAt) || Date.now()
}
}
async upsert (row) {
const next = this.normalize(row)
if (!next.guildId || !next.id) throw new Error('guildId and id required')
if (!next.name) throw new Error('event name required')
if (!next.startAt) throw new Error('event startAt required')
const key = this._key(next.guildId, next.id)
const stored = {
...key,
...next,
eventId: next.id,
updatedAt: Date.now()
}
await this.store.insert(COLLECTION, stored)
return stored
}
async get (guildId, id) {
if (!guildId || !id) return null
return this.store.get(COLLECTION, this._key(guildId, id))
}
async list (guildId, { includeCancelled = true, limit = MAX_EVENTS_PER_GUILD } = {}) {
if (!guildId) return []
const raw = await this.store.find(COLLECTION, { guildId })
let rows = Array.isArray(raw) ? raw : raw ? [raw] : []
if (!includeCancelled) {
rows = rows.filter((r) => normalizeStatus(r.status) !== EVENT_STATUSES.CANCELLED)
}
rows = rows
.map((r) => ({
...r,
id: r.eventId || r.id,
effectiveStatus: effectiveEventStatus(r)
}))
.sort((a, b) => (a.startAt || 0) - (b.startAt || 0))
return rows.slice(0, Math.max(1, Math.min(128, limit)))
}
async count (guildId) {
const rows = await this.list(guildId, { includeCancelled: true, limit: 256 })
return rows.length
}
async assertCapacity (guildId) {
const n = await this.count(guildId)
if (n >= MAX_EVENTS_PER_GUILD) {
throw new Error(`guild event calendar full (${MAX_EVENTS_PER_GUILD} max)`)
}
}
}
module.exports = {
GuildEventsStore,
EVENT_STATUSES,
EVENT_KINDS,
effectiveEventStatus,
COLLECTION,
MAX_EVENTS_PER_GUILD,
MAX_NAME_LEN,
MAX_DESC_LEN
}
+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 }
@@ -327,6 +327,13 @@ _voiceNoteGate (channel = null) {
})
},
async setVoiceNoteDraftTranscript (text) {
if (!this.voiceNotes) throw new Error('voice notes not ready')
const row = await this.voiceNotes.setDraftTranscript(null, text)
this.emit('voice-note-draft', row)
return row
},
async stageVoiceNoteDraft ({ data, durationMs, mimeType, waveformPeaks }) {
const guildId = this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id || null
const channelId = this.activeChannelId
@@ -36,11 +36,13 @@ async listAuditLog (limit = 50, opts = {}) {
const rows = await this.db.find(sharedScope.COLLECTIONS.AUDIT_LOG, { guildId })
rows.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
const filterKey = opts.filter || deliveryScope.AUDIT_FILTER.ALL
const extended = new Set(['bans', 'kicks', 'roles', 'channels'])
const extended = new Set(['bans', 'kicks', 'roles', 'channels', 'automod', 'moderation', 'messages'])
const filtered = extended.has(filterKey)
? this._filterAuditEntriesExtended(rows, filterKey)
: deliveryScope.filterAuditEntries(rows, filterKey)
const slice = filtered.slice(0, limit)
const cap = Math.min(2000, Math.max(1, Number(limit) || 50))
const offset = Math.max(0, Number(opts.offset) || 0)
const slice = filtered.slice(offset, offset + cap)
const out = []
for (const row of slice) {
const actor = await this.db.get(sharedScope.COLLECTIONS.USERS, { id: row.actorId })
@@ -49,6 +51,11 @@ async listAuditLog (limit = 50, opts = {}) {
actorName: actor?.displayName || actor?.username || row.actorId?.slice(0, 8)
})
}
// Attach pagination metadata on the array for UI (non-enumerable-ish via props).
out.total = filtered.length
out.offset = offset
out.limit = cap
out.hasMore = offset + slice.length < filtered.length
return out
},
@@ -865,12 +865,21 @@ async lockThread (threadId, locked = true) {
const guildId = this.guild?.guild?.id || null
const span = this.log.time('thread.lock', {
spanKind: 'thread.lock',
guildId,
guildId: this.mode === 'dm' ? 'dm' : guildId,
threadId,
locked: !!locked
})
try {
if (this.mode === 'dm') throw new Error('thread lock not supported in dm')
// DM / group-DM threads: local-first lock for participants (no Manage Channels).
if (this.mode === 'dm') {
if (!this.dm || typeof this.dm.setDmThreadLocked !== 'function') {
throw new Error('thread lock not available for this conversation')
}
const merged = await this.dm.setDmThreadLocked(threadId, !!locked)
this.emit('channel-update', merged)
span.end({ guildId: 'dm', threadId, locked: !!locked, dm: true })
return merged
}
if (!this.guild?.guild) throw new Error('no guild')
const roles = await this._memberRoles()
if (!sharedScope.roleHasPermission(roles, sharedScope.PERMISSION.MANAGE_CHANNELS)) {
@@ -430,6 +430,15 @@ async dryRunAutomodMessage (content, opts = {}) {
async _sendPlainMessage (content, opts = {}) {
if (!this.messages) throw new Error('no channel')
// Locked threads (guild + DM): block new messages while locked.
if (this.mode === 'dm' && this.dm?.channel?.locked) {
throw new Error('this thread is locked')
}
if (this.mode === 'guild' && this.guild && this.activeChannelId) {
const channels = await this.guild.listChannels().catch(() => [])
const ch = (channels || []).find((c) => c.id === this.activeChannelId)
if (ch?.locked) throw new Error('this thread is locked')
}
if (this.mode === 'guild') await this._assertCanParticipate('send')
if (this.mode === 'guild' && sharedScope.contentHasEveryoneOrHereMention(content)) {
let permChannelId = this.activeChannelId
@@ -653,7 +662,7 @@ async sendMessage (content, opts = {}) {
guildCount: (this.guilds || []).length,
contentLength: body.length,
channelMessageCount: channelRows.length,
remainingCharCapacity: Math.max(0, 2000 - body.length),
remainingCharCapacity: Math.max(0, 4000 - body.length),
hasReply: !!opts.replyToId,
replyToId: opts.replyToId || null,
hasMention: /(?:<@[^>]+>|@\w)/.test(body),
@@ -443,20 +443,36 @@ async unblockContact (peerUserId) {
}
},
async _mutualGuildCount (peerUserId) {
async _listMutualGuilds (peerUserId) {
const uid = this.identity?.user?.id
if (!uid || !peerUserId) return 0
let count = 0
if (!uid || !peerUserId) return []
const out = []
for (const g of this.guilds || []) {
if (!g?.id) continue
const self = await this.db
.get(sharedScope.COLLECTIONS.MEMBERS, { guildId: g.id, userId: uid })
.catch(() => null)
const peer = await this.db
.get(sharedScope.COLLECTIONS.MEMBERS, { guildId: g.id, userId: peerUserId })
.catch(() => null)
if (self && peer) count++
if (self && peer) {
out.push({
id: g.id,
name: g.name || g.id.slice(0, 8),
isCurrent: !!(this.guild?.guild?.id && this.guild.guild.id === g.id)
})
}
}
return count
return out
},
async listMutualGuilds (peerUserId) {
return this._listMutualGuilds(peerUserId)
},
async _mutualGuildCount (peerUserId) {
const list = await this._listMutualGuilds(peerUserId)
return list.length
},
async listContacts () {
@@ -465,21 +481,34 @@ async listContacts () {
const accepted = await this.contacts.listAccepted()
const enriched = []
for (const f of accepted) {
const pr = presenceMap[f.peerUserId]
enriched.push({
...f,
presence: presenceMap[f.peerUserId]?.status || 'offline',
customStatus: presenceMap[f.peerUserId]?.customStatus || null,
activity: presenceMap[f.peerUserId]?.activity || null,
avatarHash: presenceMap[f.peerUserId]?.avatarHash || f.avatarHash || null,
presence: pr?.status || 'offline',
lastSeen: pr?.lastSeen || pr?.at || null,
customStatus: pr?.customStatus || null,
activity: pr?.activity || null,
avatarHash: pr?.avatarHash || f.avatarHash || null,
mutualGuildCount: await this._mutualGuildCount(f.peerUserId)
})
}
const offlineDirectory =
typeof this.contacts.getOfflineDirectory === 'function'
? this.contacts.getOfflineDirectory(accepted.map((a) => a.peerUserId))
: enriched
.filter((f) => !f.presence || f.presence === 'offline')
.map((f) => ({
peerUserId: f.peerUserId,
lastSeen: f.lastSeen,
status: 'offline'
}))
return {
accepted: enriched,
pendingIn: await this.contacts.listPendingIncoming(),
pendingOut: await this.contacts.listPendingOutgoing(),
blocked: await this.contacts.listBlocked(),
friendPresence: presenceMap
friendPresence: presenceMap,
offlineDirectory
}
},
@@ -832,6 +832,11 @@ _wireGuild (guildInstance) {
guildInstance.on('scheduled-message-export-json-sync', (payload) => {
this._onScheduledMessageExportJsonGossip(payload).catch(() => {})
})
guildInstance.on('guild-scheduled-event', (payload) => {
this.ingestGuildScheduledEvent(payload)
.then(() => this.emit('guild-scheduled-event', payload))
.catch(() => {})
})
guildInstance.on('pin-registry-json-sync', (payload) => {
this._onPinRegistryJsonGossip(payload).catch(() => {})
})
+64 -1
View File
@@ -455,6 +455,37 @@ async view (opts = {}) {
}
members = await this._enrichMembersForView(members)
channels = await this._filterVisibleChannels(allChannels)
// Admin-only: channels hidden by VIEW_CHANNEL overwrites (browse, not open).
try {
const canManage =
user?.id &&
(user.id === guild.ownerId ||
(await this._myPermissions(null).catch(() => null))?.manageGuild ||
(await this._myPermissions(null).catch(() => null))?.manageChannels)
if (canManage && Array.isArray(allChannels)) {
const visibleIds = new Set((channels || []).map((c) => c.id))
this._hiddenChannelsForView = allChannels
.filter(
(c) =>
c &&
!visibleIds.has(c.id) &&
c.type !== 'category' &&
c.type !== sharedScope.CHANNEL_TYPES?.CATEGORY
)
.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
parentId: c.parentId || null,
hidden: true
}))
.slice(0, 64)
} else {
this._hiddenChannelsForView = []
}
} catch {
this._hiddenChannelsForView = []
}
this._resolveActiveChannelForView(channels, {
guildId: guild.id,
mode: 'guild',
@@ -810,7 +841,7 @@ async view (opts = {}) {
}
let auditLog = []
if (guild && this.mode === 'guild') {
auditLog = await this.listAuditLog(40)
auditLog = await this.listAuditLog(120)
}
const attachmentsByMessage = {}
if (this.attachments && messages.length) {
@@ -2058,6 +2089,34 @@ async view (opts = {}) {
scheduledQueue = []
scheduledQueueCount = 0
}
let guildScheduledEvents = []
try {
guildScheduledEvents =
typeof this._guildEventsSnapshotForView === 'function'
? await this._guildEventsSnapshotForView()
: []
} catch {
guildScheduledEvents = []
}
/** Boost level 2+ unlocks longer composer (Nitro-style tier, local P2P boosts). */
let composerMaxChars = 2000
try {
const bl = Number(guildBoost?.level ?? guildBoost?.boostLevel)
const count = Number(guildBoost?.boostCount) || 0
const level =
Number.isFinite(bl) && bl > 0
? bl
: count >= 14
? 3
: count >= 7
? 2
: count >= 2
? 1
: 0
if (level >= 2) composerMaxChars = 4000
} catch {
composerMaxChars = 2000
}
try {
if (this.mode === 'guild' && this.guild?.guild?.id) {
const store = await this._ensureGuildPollStore()
@@ -2314,6 +2373,7 @@ async view (opts = {}) {
guilds: this.guilds,
guild,
channels,
hiddenChannels: this._hiddenChannelsForView || [],
members,
usersById,
guildCustomRoles,
@@ -2326,6 +2386,9 @@ async view (opts = {}) {
scheduledQueue,
pollCount,
scheduledQueueCount,
guildScheduledEvents,
guildScheduledEventCount: guildScheduledEvents.length,
composerMaxChars,
userPrefsEngine,
dmPinsEngine: this.dm?.pinsEngine || null,
globalPresence,