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