Skip to content

Add anonymous forum post support #41

Description

@BastiDood

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'
pnpm db:generate

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:

  1. Load configured channel row by channelId.
  2. Insert the confession first to allocate confessionId.
  3. Insert pending_channel_thread with kind = 'new-forum' and submitted title.
  4. Update confession pending_channel_thread_id.
  5. Log to the configured text log channel.
  6. If approval is required, stop until approval.
  7. If approval is not required, create the forum post immediately.
  8. 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:

  1. Load settings by channelId.
  2. Resolve threadId to the matching approved_channel_thread.
  3. Insert the confession with that row's pending_channel_thread_id.
  4. Log the submission with thread destination metadata.
  5. Publish with createMessage(threadId, ...) when approval rules allow.

It must not create a forum post.

Log Channel UI

Keep the existing embed title format:

{label} #{confessionId}

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:
pnpm lint
pnpm fmt:fix

Metadata

Metadata

Assignees

No one assigned

    Labels

    featNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions