Skip to content

Get a moderator's approval before the bot posts

By the end of this you'll have a bot that never answers on its own. It drafts a reply, posts a card offering Post it, Edit first and Discard, and waits. A moderator presses a button; only then does anything reach the person who asked.

That is the whole Interactive capability end to end: prompts, buttons, a prefilled form, and replacing the card once it has been actioned.

Allow about 30 minutes, most of it Discord setup if you have not done it before.

Before you start

You'll need:

  • Go 1.27 or later.
  • A Discord application with a bot user, and its token.
  • A test guild you can add the bot to. Do not do this in a real community. The bot posts and edits messages.
  • The Message Content and Server Members intents enabled for your application in the Discord developer portal. Without Message Content, every message arrives with an empty Content and the bot has nothing to answer.
  • Three snowflake IDs to hand: the guild, one channel, and the role you'll treat as moderators. Turn on Developer Mode in Discord to copy them.

If you have not run this module at all yet, do Getting started first. It gets the connection working before you add anything on top.

Set the four values as environment variables so nothing is baked into the code:

export DISCORD_TOKEN=...
export DISCORD_GUILD=...
export DISCORD_CHANNEL=...
export DISCORD_MODERATOR_ROLE=...

Ask the provider for the Interactive capability

Interactive is optional. It is not on Actor, so you ask for it by name and handle the case where it is missing:

c, err := chatplatform.NewClient(ctx, "discord", chatplatform.ClientConfig{
    Token: os.Getenv("DISCORD_TOKEN"),
    Needs: []chatplatform.Need{
        chatplatform.NeedMessages,
        chatplatform.NeedInteractions,
    },
})
if err != nil {
    log.Fatal(err)
}

defer c.Close()

p, err := c.Provider(ctx, chatplatform.ID(os.Getenv("DISCORD_GUILD")),
    chatplatform.WithAllowedChannels(chatplatform.ID(os.Getenv("DISCORD_CHANNEL"))),
)
if err != nil {
    log.Fatal(err)
}

act, ok := chatplatform.AsInteractive(p)
if !ok {
    log.Fatal("this provider has no Interactive capability")
}

moderator := chatplatform.ID(os.Getenv("DISCORD_MODERATOR_ROLE"))
cards := &pending{by: map[chatplatform.ID]draft{}}

if err := p.Reader.Connect(ctx); err != nil {
    log.Fatal(err)
}

AsInteractive answers false rather than panicking on a provider that does not have it, and on a read-only one, which has no Actor at all and therefore no capabilities either. Fail early and say which capability was missing; a nil check three functions later tells nobody anything.

cards is the map of pending drafts defined in the next section, and moderator is the role the button handler will check.

Remember which question each card belongs to

This is the part that catches people out, so it is worth doing before the fun bit.

When a moderator presses a button, the Interaction you receive points at the card, not at the original question. Interaction.Ref.MessageID is the card's own message ID. Nothing carries the question along with it.

So keep a small map from card ID to the draft it is reviewing:

type draft struct {
    question chatplatform.Ref
    answer   string
    asker    string
}

type pending struct {
    mu sync.Mutex
    by map[chatplatform.ID]draft
}

func (p *pending) put(card chatplatform.ID, d draft) {
    p.mu.Lock()
    defer p.mu.Unlock()

    p.by[card] = d
}

func (p *pending) take(card chatplatform.ID) (draft, bool) {
    p.mu.Lock()
    defer p.mu.Unlock()

    d, ok := p.by[card]
    delete(p.by, card)

    return d, ok
}

The mutex is not optional: interactions arrive on their own goroutine while your message loop is still running.

An in-memory map loses every pending card when the process restarts. That is fine for a tutorial and wrong for production, where a card posted before a deploy becomes a set of buttons that no longer do anything. Put the drafts somewhere that survives a restart when you build this for real, and see why choice keys must be stable.

Post the card instead of the answer

draftAnswer is wherever your bot's answer comes from: retrieval, a model, a lookup table. For now a constant is enough:

func draftAnswer(string) string {
    return "Try `just ci` first — it runs the same checks the pipeline does."
}

In the message loop, draft an answer and post it as a prompt rather than a reply:

for msg := range p.Reader.Messages() {
    answer := draftAnswer(msg.Content)

    card, err := act.Prompt(ctx, msg.Ref(), chatplatform.PromptSpec{
        Content: "Draft answer for " + msg.Author.Name + ":\n\n> " + answer,
        Choices: []chatplatform.Choice{
            {Key: "post", Label: "Post it", Style: chatplatform.StylePrimary},
            {Key: "edit", Label: "Edit first"},
            {Key: "discard", Label: "Discard", Style: chatplatform.StyleDanger},
        },
    })
    if err != nil {
        log.Print(err)

        continue
    }

    cards.put(card.MessageID, draft{question: msg.Ref(), answer: answer, asker: msg.Author.Name})
}

Prompt returns a Ref to the card; its MessageID is the key you just stored the draft under, and the one a button press will carry back. Choice.Key is what comes back when a button is pressed; Label is what the moderator reads.

Style is a hint. Discord renders StyleDanger in red, and a platform without styling ignores it entirely, so never let anything depend on it.

Send a message in your channel now and the card appears with three buttons. Nothing else happens yet, because nothing is reading the interactions.

Handle the button press, and check the role first

Interactions arrive on their own channel. Read it in a goroutine:

go func() {
    for in := range act.Interactions() {
        if !in.By.HasAnyRole(moderator) {
            _ = act.Respond(ctx, in.Token, "Moderators only.", true)

            continue
        }

        switch in.Type {
        case chatplatform.ChoiceSelected:
            onChoice(ctx, p, act, cards, in)
        case chatplatform.FormSubmitted:
            onForm(ctx, p, act, cards, in)
        case chatplatform.CommandInvoked:
            _ = act.Respond(ctx, in.Token, "No commands are registered.", true)
        }
    }
}()

The role check is the authorisation, and it is yours to write. Discord can gate commands by permission, but buttons on a message are pressable by anybody who can see the message, and CommandSpec.RequiredRoles is not passed to Discord by this provider. Interaction.By.Roles is the only thing that decides this. Never By.Name, because a display name is user-controlled.

The true on Respond asks for an ephemeral reply, so the refusal is visible only to the person who pressed the button.

Post, or discard, and take the buttons away

onChoice switches on in.ChoiceKey, which is the Choice.Key you gave the button:

func onChoice(ctx context.Context, p *chatplatform.Provider, act chatplatform.Interactive, cards *pending, in chatplatform.Interaction) {
    switch in.ChoiceKey {
    case "post":
        d, ok := cards.take(in.Ref.MessageID)
        if !ok {
            _ = act.Respond(ctx, in.Token, "That card has already been actioned.", true)

            return
        }

        if _, err := p.Actor.ReplyInThread(ctx, d.question, "Re: "+d.asker, d.answer); err != nil {
            _ = act.Respond(ctx, in.Token, "Could not post: "+err.Error(), true)

            return
        }

        _ = act.UpdateSource(ctx, in.Token, "Posted by "+in.By.Name+".", nil)

    case "discard":
        cards.take(in.Ref.MessageID)

        _ = act.UpdateSource(ctx, in.Token, "Discarded by "+in.By.Name+".", nil)
    }
}

UpdateSource with nil choices replaces the card and removes the buttons. Do it every time, on every path. Without it the buttons stay live after they have been used, and the second moderator to come along actions the same thing again.

Press Post it and you'll see the card become a one-line record of who actioned it, and the answer appear in a new thread on the original question.

Open a prefilled form when the moderator wants to edit

A third case in the same switch:

    case "edit":
        d, ok := cards.take(in.Ref.MessageID)
        if !ok {
            _ = act.Respond(ctx, in.Token, "That card has already been actioned.", true)

            return
        }

        cards.put(in.Ref.MessageID, d) // put it back; the form has not been submitted yet

        err := act.OpenForm(ctx, in.Token, chatplatform.FormSpec{
            Title: "Edit the answer",
            Fields: []chatplatform.FieldSpec{{
                Key:       "answer",
                Label:     "Answer",
                Value:     d.answer,
                Multiline: true,
                Required:  true,
                MaxLen:    1500,
            }},
        })
        if err != nil {
            log.Print(err)
        }

Value prefills the field. That is the point of the whole exercise: the moderator sees and edits exactly what is about to be published in their name, rather than approving something they have not read.

Open the form promptly. Discord accepts a form only as the first response to an interaction, and the provider acknowledges on your behalf two seconds in so the interaction does not expire. Do slow work (retrieval, a model call) before you post the card, not between the button press and the form. If you are late you get discord.ErrFormWindowClosed, and no amount of retrying will open it.

Publish what came back from the form

A submitted form arrives as a second interaction, with Type of FormSubmitted and the values keyed by FieldSpec.Key:

func onForm(ctx context.Context, p *chatplatform.Provider, act chatplatform.Interactive, cards *pending, in chatplatform.Interaction) {
    d, ok := cards.take(in.Ref.MessageID)
    if !ok {
        _ = act.Respond(ctx, in.Token, "That card has already been actioned.", true)

        return
    }

    d.answer = in.Value("answer")

    if _, err := p.Actor.ReplyInThread(ctx, d.question, "Re: "+d.asker, d.answer); err != nil {
        _ = act.Respond(ctx, in.Token, "Could not post: "+err.Error(), true)

        return
    }

    _ = act.UpdateSource(ctx, in.Token, "Posted by "+in.By.Name+".", nil)
}

in.Value("answer") is in.Values["answer"] with a nil-map guard. It cannot tell an absent field from one submitted empty. Read the map directly if you need to know the difference.

Run it, choose Edit first, change a word, submit. The edited text appears in the thread and the card records who posted it.

Where this stops

A few things this deliberately does not handle, so you know before you build on it:

  • Restarts lose pending cards. In-memory map, as above.
  • Replies inside the thread come back as messages, because a thread of an allowed channel is admitted along with its parent, so this loop drafts a card for every follow-up as well as every question. Message.ThreadID is set on those; skip them if the bot should answer once and not follow the conversation.
  • PromptSpec.Ephemeral does nothing here. Discord has no ephemeral form of a message nobody has interacted with yet; the ephemeral argument to Respond is where privacy is actually available.
  • 25 choices is the ceiling on one card, and going over returns discord.ErrTooManyChoices rather than truncating.

Where to go next