Anonymous Forum Post Support Spec
Add /forum for anonymous forum posts.
Dependencies
This ticket is blocked by:
- setup command retrofit
- anonymous thread support
Those prerequisites provide:
/setup log-channel:<text> target-channel:<text>
app.pending_channel_thread
app.approved_channel_thread
app.confession.pending_channel_thread_id
- known-thread handling for
/confess, Reply Anonymously, /resend, and approval dispatch
This ticket adds only forum-specific setup support, forum command handling, and Discord forum post creation.
Command Model
/forum
Use /forum for anonymous forum posts.
- Slash command with a required forum channel select option.
- The selected forum must already be configured by
/setup target-channel:<forum>.
- Opens a bespoke modal with:
- required
title
- required
content
- optional
attachment
- Inserts a
pending_channel_thread row for the submitted forum post.
- Inserts the confession with
channel_id and pending_channel_thread_id.
- Creates a Discord forum post when approval rules allow.
- Inserts an
approved_channel_thread row after Discord creates the forum post thread.
Command shape:
/forum channel:<forum-channel>
The submitted title is the Discord forum thread API name.
The anonymous confession embed is the forum post starter message body.
/confess
Use /confess only for anonymous regular messages.
- Do not use it to create forum posts.
- If invoked in a forum root, reject with
Use /forum to create an anonymous forum post.
- If invoked inside a known forum post thread, publish a regular anonymous message in that thread.
- Reject unknown forum post threads.
Other Commands
/thread rejects in forum roots and forum post threads.
Reply Anonymously works inside known forum post threads as a message-reference reply.
Discord API Facts
- Forum channels have channel type
GuildForum (15) and media channels have channel type GuildMedia (16) according to Discord's channel type docs.
- Forum/media channels are thread-only channels. They do not accept direct messages via
POST /channels/{channel.id}/messages.
- A forum post is created with
POST /channels/{channel.id}/threads.
- For forum/media channels, the forum thread creation request includes required
name and required starter message fields.
- The created forum post is a public thread.
- Discord's forum thread creation docs require
SEND_MESSAGES on the forum channel and note that CREATE_PUBLIC_THREADS is ignored for forum/media post creation.
- If the forum has
REQUIRE_TAG, applied_tags must include a valid available tag ID.
Current Codebase State
ChannelType.GuildForum and ChannelType.GuildMedia already exist in src/lib/server/models/discord/channel.ts.
- Public publish currently uses
DiscordClient.ENV.createMessage(...), which cannot publish directly to forum roots.
createConfessionPayload(...) already creates the embed payload that can be used as the starter message body.
- Log channels are text channels.
Data Model Changes
app.pending_channel_thread_kind
Add the forum creation kind to the existing schema-scoped Postgres enum.
alter type app.pending_channel_thread_kind add value 'new-forum';
Semantics:
new-thread: standalone public thread under a text channel.
new-forum: forum post thread under a forum channel.
Migration Scope
Generate a migration for:
app.pending_channel_thread_kind value 'new-forum'
No custom backfill migration is required because existing pending thread rows keep their existing new-thread kind.
Discord Model Changes
Extend Channel with the forum fields needed for setup and command validation.
const ForumTag = object({
id: Snowflake,
name: string(),
moderated: boolean(),
});
export const Channel = object({
...existing,
flags: optional(number()),
available_tags: optional(array(ForumTag)),
});
Do not parse channel name.
Discord Client Changes
Add a method beside createMessage.
interface CreateForumThread {
name: string;
message: Omit<CreateMessage, 'attachments'>;
applied_tags?: Snowflake[];
}
async createForumThread(
channelId: Snowflake,
data: CreateForumThread,
idempotencySeed: string,
files?: CreateMessageFile[],
) {
POST `/channels/${channelId}/threads`;
}
The response model should parse the created thread ID and starter message.
const CreatedForumThread = object({
id: Snowflake,
type: literal(ChannelType.PublicThread),
parent_id: Snowflake,
message: optional(Message),
});
Event Schema Changes
Do not add a new submission mode.
Forum posts use the existing new-thread submission mode. The worker differentiates standalone text-channel threads from forum posts by writing and later reading pending_channel_thread.kind = 'new-forum'.
const ConfessionSubmissionMode = picklist(['message', 'new-thread']);
export const ConfessionSubmitEvent = eventType('discord/confession.submit', {
version: '4.0.0',
schema: ConfessionSubmitEventData,
});
For /forum:
mode = 'new-thread'
channelId = selectedForumId
threadId = null
threadTitle = submittedTitle
parentMessageId = null
For /confess inside a known forum post:
mode = 'message'
channelId = parentForumId
threadId = currentForumPostThreadId
threadTitle = null
Setup Changes
Allow /setup target-channel:<forum>.
Validation:
target-channel may be GuildText or GuildForum.
log-channel remains GuildText.
- Reject
GuildMedia.
- Reject forums with
REQUIRE_TAG.
- If
/setup is invoked inside a forum post thread without target-channel, setup fetches and configures the parent forum.
Setup continues to upsert app.channel by targetChannelId. It does not persist the Discord channel type.
Command Flow
/forum
Initial slash command handling:
const forum = resolvedChannels[selectedChannelId];
strictEqual(forum.type, ChannelType.GuildForum);
assertConfiguredChannel(selectedChannelId);
assertForumDoesNotRequireTags(forum);
assert(hasAllFlags(member.permissions, SEND_MESSAGES));
return createForumConfessionModal({
forumChannelId: selectedChannelId,
});
Modal submit queues:
ConfessionSubmitEvent.create({
mode: 'new-thread',
channelId: selectedForumId,
threadId: null,
threadTitle: submittedTitle,
...
});
Worker:
- Load configured channel row by
channelId.
- Insert the confession first to allocate
confessionId.
- Insert
pending_channel_thread with kind = 'new-forum' and submitted title.
- Update confession
pending_channel_thread_id.
- Log to the configured text log channel.
- If approval is required, stop until approval.
- If approval is not required, create the forum post immediately.
- Insert
approved_channel_thread.
Forum post API name:
const name = pendingChannelThread.title;
Forum post starter message:
const message = createConfessionPayload(confession, attachment);
Approval Dispatch
Approved forum submissions are created after approval.
if (confession.pendingChannelThread?.kind === 'new-forum') {
assert(confession.pendingChannelThread !== null);
assert(confession.approvedChannelThread === null);
const thread = await createForumThread(confession.channelId, {
name: confession.pendingChannelThread.title,
message: createConfessionPayload(confession, attachment),
});
await insertApprovedChannelThread({
pendingChannelThreadId: confession.pendingChannelThread.id,
threadId: thread.id,
});
return;
}
Confessions that already have a joined approved_channel_thread row publish to that thread with createMessage.
/confess Inside Forum Posts
Known forum post threads are regular thread channels for message creation.
const ref = resolveConfessionChannelRef(interaction.channel);
assert(ref.threadId !== null);
assertKnownThread(ref.channelId, ref.threadId);
ConfessionSubmitEvent.create({
mode: 'message',
channelId: ref.channelId,
threadId: ref.threadId,
threadTitle: null,
...
});
Worker:
- Load settings by
channelId.
- Resolve
threadId to the matching approved_channel_thread.
- Insert the confession with that row's
pending_channel_thread_id.
- Log the submission with thread destination metadata.
- Publish with
createMessage(threadId, ...) when approval rules allow.
It must not create a forum post.
Log Channel UI
Keep the existing embed title format:
Add destination metadata fields only when useful:
Destination
- Forum post after creation:
<#threadId>
- Approval-required forum post before creation:
Pending forum post in <#channelId>
/confess inside a forum post: <#threadId>
Parent Forum
- Present for forum post destinations.
- Value:
<#channelId>
Post Title
- Present when
pending_channel_thread.title is non-null.
Reply To
- Present only for
Reply Anonymously when a Discord message URL can be built.
Do not add a submission type field.
After approval creates the forum post, update the moderation log embed so Destination points to <#threadId>.
/resend
Preserve the original stored destination.
/resend from a parent forum loads confession by channelId and confessionId.
- If the confession has a joined
approved_channel_thread, resend to that forum post thread with createMessage.
- If the confession has
pending_channel_thread_id but no joined approved_channel_thread, reject because the forum post has not been created yet.
- Do not create a new forum post from
/resend.
- Do not add a forum resend subcommand.
UX Rules
/forum always requires a forum channel argument.
/forum never infers a target from the current channel.
/confess never creates forum posts.
/thread never creates forum posts.
- Forum post title is required in the modal.
- The submitted title is the forum post API name.
- The forum post starter message is the anonymous confession embed.
- The modal should show the selected forum destination.
Edge Cases Matrix
| Feature |
Configured forum |
Unconfigured forum |
Known forum post thread |
/setup target-channel:<forum> |
Configure forum |
Configure forum |
Configure selected forum |
/forum channel:<forum> |
Create forum post |
Error |
Target selected forum |
/confess |
Reject; use /forum |
Reject |
Anonymous message in thread |
/thread |
Reject; use /forum |
Reject |
Reject; use /confess |
Reply Anonymously |
N/A in root |
N/A |
Message-reference reply in thread |
| Approval required |
Log to text; approval creates forum post |
Error |
Log to text; approval posts in current thread |
/resend |
Resend to original post thread |
Error |
Resend to original stored destination |
Verification Plan
- Unit-test
/forum channel option validation.
- Unit-test forum modal parsing requires title.
- Unit-test forum worker creates post and inserts
approved_channel_thread.
- Unit-test approval-required forum flow stores
pending_channel_thread and creates the post only after approval.
- Unit-test forum post starter message uses the confession embed payload.
- Unit-test
/confess inside created forum posts.
- Unit-test
/resend publishes to original stored forum post thread.
- Unit-test
/resend rejects pending forum posts without an approved thread row.
- Unit-test setup accepts configured forum roots.
- Unit-test setup rejects required-tag forums.
- Manual Discord tests:
/setup target-channel:<forum> log-channel:<text>.
/forum channel:<configured-forum> with title.
/forum with approval required.
/confess inside created forum post.
Reply Anonymously inside created forum post.
/resend from parent forum resends a forum confession to its original post thread.
- Required project checks after implementation:
Anonymous Forum Post Support Spec
Add
/forumfor anonymous forum posts.Dependencies
This ticket is blocked by:
Those prerequisites provide:
/setup log-channel:<text> target-channel:<text>app.pending_channel_threadapp.approved_channel_threadapp.confession.pending_channel_thread_id/confess,Reply Anonymously,/resend, and approval dispatchThis ticket adds only forum-specific setup support, forum command handling, and Discord forum post creation.
Command Model
/forumUse
/forumfor anonymous forum posts./setup target-channel:<forum>.titlecontentattachmentpending_channel_threadrow for the submitted forum post.channel_idandpending_channel_thread_id.approved_channel_threadrow after Discord creates the forum post thread.Command shape:
The submitted title is the Discord forum thread API
name.The anonymous confession embed is the forum post starter message body.
/confessUse
/confessonly for anonymous regular messages.Use /forum to create an anonymous forum post.Other Commands
/threadrejects in forum roots and forum post threads.Reply Anonymouslyworks inside known forum post threads as a message-reference reply.Discord API Facts
GuildForum(15) and media channels have channel typeGuildMedia(16) according to Discord's channel type docs.POST /channels/{channel.id}/messages.POST /channels/{channel.id}/threads.nameand required startermessagefields.SEND_MESSAGESon the forum channel and note thatCREATE_PUBLIC_THREADSis ignored for forum/media post creation.REQUIRE_TAG,applied_tagsmust include a valid available tag ID.Current Codebase State
ChannelType.GuildForumandChannelType.GuildMediaalready exist insrc/lib/server/models/discord/channel.ts.DiscordClient.ENV.createMessage(...), which cannot publish directly to forum roots.createConfessionPayload(...)already creates the embed payload that can be used as the starter message body.Data Model Changes
app.pending_channel_thread_kindAdd the forum creation kind to the existing schema-scoped Postgres enum.
Semantics:
new-thread: standalone public thread under a text channel.new-forum: forum post thread under a forum channel.Migration Scope
Generate a migration for:
app.pending_channel_thread_kindvalue'new-forum'No custom backfill migration is required because existing pending thread rows keep their existing
new-threadkind.Discord Model Changes
Extend
Channelwith the forum fields needed for setup and command validation.Do not parse channel
name.Discord Client Changes
Add a method beside
createMessage.The response model should parse the created thread ID and starter message.
Event Schema Changes
Do not add a new submission mode.
Forum posts use the existing new-thread submission mode. The worker differentiates standalone text-channel threads from forum posts by writing and later reading
pending_channel_thread.kind = 'new-forum'.For
/forum:mode = 'new-thread'channelId = selectedForumIdthreadId = nullthreadTitle = submittedTitleparentMessageId = nullFor
/confessinside a known forum post:mode = 'message'channelId = parentForumIdthreadId = currentForumPostThreadIdthreadTitle = nullSetup Changes
Allow
/setup target-channel:<forum>.Validation:
target-channelmay beGuildTextorGuildForum.log-channelremainsGuildText.GuildMedia.REQUIRE_TAG./setupis invoked inside a forum post thread withouttarget-channel, setup fetches and configures the parent forum.Setup continues to upsert
app.channelbytargetChannelId. It does not persist the Discord channel type.Command Flow
/forumInitial slash command handling:
Modal submit queues:
Worker:
channelId.confessionId.pending_channel_threadwithkind = 'new-forum'and submitted title.pending_channel_thread_id.approved_channel_thread.Forum post API name:
Forum post starter message:
Approval Dispatch
Approved forum submissions are created after approval.
Confessions that already have a joined
approved_channel_threadrow publish to that thread withcreateMessage./confessInside Forum PostsKnown forum post threads are regular thread channels for message creation.
Worker:
channelId.threadIdto the matchingapproved_channel_thread.pending_channel_thread_id.createMessage(threadId, ...)when approval rules allow.It must not create a forum post.
Log Channel UI
Keep the existing embed title format:
Add destination metadata fields only when useful:
Destination<#threadId>Pending forum post in <#channelId>/confessinside a forum post:<#threadId>Parent Forum<#channelId>Post Titlepending_channel_thread.titleis non-null.Reply ToReply Anonymouslywhen a Discord message URL can be built.Do not add a submission type field.
After approval creates the forum post, update the moderation log embed so
Destinationpoints to<#threadId>./resendPreserve the original stored destination.
/resendfrom a parent forum loads confession bychannelIdandconfessionId.approved_channel_thread, resend to that forum post thread withcreateMessage.pending_channel_thread_idbut no joinedapproved_channel_thread, reject because the forum post has not been created yet./resend.UX Rules
/forumalways requires a forum channel argument./forumnever infers a target from the current channel./confessnever creates forum posts./threadnever creates forum posts.Edge Cases Matrix
/setup target-channel:<forum>/forum channel:<forum>/confess/forum/thread/forum/confessReply Anonymously/resendVerification Plan
/forumchannel option validation.approved_channel_thread.pending_channel_threadand creates the post only after approval./confessinside created forum posts./resendpublishes to original stored forum post thread./resendrejects pending forum posts without an approved thread row./setup target-channel:<forum> log-channel:<text>./forum channel:<configured-forum>with title./forumwith approval required./confessinside created forum post.Reply Anonymouslyinside created forum post./resendfrom parent forum resends a forum confession to its original post thread.