feat(guild): forum channels with createForumThread and deferred thread-meta

Allow thread parents to be text or forum channels. Add createForumThread,
setThreadRootMessage, and infer forumPost from parent type. Defer HyperDB
thread-meta insert until rootMessageId exists (fixes null encode on posts).

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-05-21 21:30:11 -04:00
co-authored by Cursor
parent 8aeaf33bb5
commit 2ebcf9c891
+84 -4
View File
@@ -79,8 +79,11 @@ class PearcordGuild extends EventEmitter {
}
if (type === CHANNEL_TYPES.THREAD) {
const parent = await this.db.get(COLLECTIONS.CHANNELS, { guildId, id: parentId })
if (!parent || parent.type !== CHANNEL_TYPES.TEXT) {
throw new Error('thread parent must be a text channel')
if (
!parent ||
(parent.type !== CHANNEL_TYPES.TEXT && parent.type !== CHANNEL_TYPES.FORUM)
) {
throw new Error('thread parent must be a text or forum channel')
}
}
const channel = {
@@ -103,6 +106,9 @@ class PearcordGuild extends EventEmitter {
})
return this._attachThreadMeta(channel)
}
if (type === CHANNEL_TYPES.THREAD) {
return this._attachThreadMeta(channel)
}
return channel
}
@@ -111,6 +117,7 @@ class PearcordGuild extends EventEmitter {
}
async _upsertThreadMeta (meta) {
if (!meta.rootMessageId) throw new Error('rootMessageId required for thread meta')
const row = {
guildId: meta.guildId,
channelId: meta.channelId,
@@ -125,10 +132,26 @@ class PearcordGuild extends EventEmitter {
async _attachThreadMeta (channel) {
if (!channel || channel.type !== CHANNEL_TYPES.THREAD) return channel
const meta = await this._getThreadMeta(channel.guildId, channel.id)
if (!meta) return channel
let forumPost = false
if (channel.parentId) {
const parent = await this.db.get(COLLECTIONS.CHANNELS, {
guildId: channel.guildId,
id: channel.parentId
})
forumPost = parent?.type === CHANNEL_TYPES.FORUM
}
if (!meta) {
return {
...channel,
forumPost,
archived: false,
createdAt: channel.createdAt || null
}
}
return {
...channel,
rootMessageId: meta.rootMessageId,
forumPost,
archived: meta.archived === true,
createdAt: meta.createdAt
}
@@ -498,6 +521,60 @@ class PearcordGuild extends EventEmitter {
return this.createChannel({ name, type: CHANNEL_TYPES.CATEGORY })
}
async createForumThread ({ parentChannelId, name }) {
if (!this.guild) throw new Error('no active guild')
if (!parentChannelId || !name) {
throw new Error('parentChannelId and name required')
}
const parent = await this.db.get(COLLECTIONS.CHANNELS, {
guildId: this.guild.id,
id: parentChannelId
})
if (!parent || parent.type !== CHANNEL_TYPES.FORUM) {
throw new Error('forum posts must be under a forum channel')
}
const existing = await this.listChannels()
const threads = existing.filter(
(c) => c.type === CHANNEL_TYPES.THREAD && c.parentId === parentChannelId
)
const channel = await this._createChannel(
this.guild.id,
String(name).slice(0, 80),
CHANNEL_TYPES.THREAD,
threads.length,
parentChannelId,
{ forumPost: true, archived: false }
)
this.emit('channel', channel)
return channel
}
async setThreadRootMessage (threadId, rootMessageId) {
if (!this.guild) throw new Error('no active guild')
if (!threadId || !rootMessageId) throw new Error('threadId and rootMessageId required')
const ch = await this.db.get(COLLECTIONS.CHANNELS, {
guildId: this.guild.id,
id: threadId
})
if (!ch || ch.type !== CHANNEL_TYPES.THREAD) throw new Error('not a thread channel')
const root = await this.db.get(COLLECTIONS.MESSAGES, {
channelId: threadId,
id: rootMessageId
})
if (!root) throw new Error('root message not found in thread')
const prev = await this._getThreadMeta(this.guild.id, threadId)
await this._upsertThreadMeta({
guildId: this.guild.id,
channelId: threadId,
rootMessageId,
archived: prev?.archived === true,
createdAt: prev?.createdAt || now()
})
const merged = await this._attachThreadMeta(ch)
this.emit('channel', merged)
return merged
}
async createThread ({ parentChannelId, rootMessageId, name }) {
if (!this.guild) throw new Error('no active guild')
if (!parentChannelId || !rootMessageId) {
@@ -582,14 +659,17 @@ class PearcordGuild extends EventEmitter {
await this.db.insert(COLLECTIONS.CHANNELS, base)
if (channel.type === CHANNEL_TYPES.THREAD) {
const prev = await this._getThreadMeta(channel.guildId, channel.id)
const rootMessageId = channel.rootMessageId ?? prev?.rootMessageId
if (rootMessageId) {
await this._upsertThreadMeta({
guildId: channel.guildId,
channelId: channel.id,
rootMessageId: channel.rootMessageId || prev?.rootMessageId,
rootMessageId,
archived: channel.archived === true,
createdAt: channel.createdAt || prev?.createdAt || now()
})
}
}
const merged = await this._attachThreadMeta(base)
this.emit('channel-update', merged)
return merged