Skip to content

Getting started

By the end of this you'll have a bot that reads a Discord channel and answers in a thread, with no Discord SDK anywhere in your own code. Then you'll make it read-only, and make it tell you when a reconnect lost messages.

Allow about 15 minutes, plus Discord setup if the bot does not exist yet.

Before you start

You'll need:

  • Go 1.27 or later.
  • A Discord application with a bot user, and its token.
  • A test guild the bot is a member of. Use one you own, because this posts messages.
  • The Message Content intent enabled for your application in the Discord developer portal. Without it every message arrives with an empty Content, which looks exactly like a bot that is not working.
  • The guild's snowflake and one channel's snowflake. Turn on Developer Mode in Discord to copy them.

Install

The contract and the provider are separate modules. You depend on the contract; the provider is a blank import.

go get gitlab.com/phpboyscout/go/chat-platform
go get gitlab.com/phpboyscout/go/chat-platform-discord

Connect and read

package main

import (
    "context"
    "log"
    "os"

    chatplatform "gitlab.com/phpboyscout/go/chat-platform"
    _ "gitlab.com/phpboyscout/go/chat-platform-discord"
)

func main() {
    ctx := context.Background()

    // One client is one connection. Declare what you will use: the provider
    // asks the platform for that and nothing more.
    c, err := chatplatform.NewClient(ctx, "discord", chatplatform.ClientConfig{
        Token: os.Getenv("DISCORD_TOKEN"),
        Needs: []chatplatform.Need{chatplatform.NeedMessages},
    })
    if err != nil {
        log.Fatal(err)
    }

    defer c.Close()

    // One provider is one space. Serving several means several of these, over
    // the same connection.
    p, err := c.Provider(ctx, "1531227937678954747",
        chatplatform.WithAllowedChannels("1531227938622800055"),
    )
    if err != nil {
        log.Fatal(err)
    }

    defer p.Reader.Close()

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

    for msg := range p.Reader.Messages() {
        if _, err := p.Actor.ReplyInThread(ctx, msg.Ref(), "Re: "+msg.Author.Name, "Looking into it."); err != nil {
            log.Print(err)
        }
    }
}

Three things are worth noticing.

NewClient takes a name. The provider registered itself when it was imported, and NewClient looks it up; it returns ErrNotFound if nothing is registered under that name. Swapping platforms is a configuration change and an import, not a code change.

AllowedChannels is enforced by the provider. An empty allowlist permits nothing. A watchlist that silently means everywhere is the wrong default for reading people's messages, so it fails closed.

Connect is where the network happens. NewClient and Provider validate configuration and nothing else, so you can construct a provider, inspect what it supports, and test all of it without credentials.

Run it, then post something in the allowlisted channel. A thread appears on your message, named Re: <you>, with Looking into it. inside it.

If nothing happens, the two usual causes are an empty AllowedChannels (which permits nothing rather than everything) and the Message Content intent not being enabled.

Read without being able to write

Pass WithReadOnly and the provider comes back with no Actor at all.

p, _ := c.Provider(ctx, guild,
    chatplatform.WithAllowedChannels(channels...),
    chatplatform.WithReadOnly(),
)

if p.Actor != nil {
    panic("a read-only scope has no Actor")
}

It is per scope, so one tenant can observe while another acts on the same connection.

There is no flag to check and no method that refuses at call time. A deployment that cannot post cannot delete a message either, and that is a property of the types rather than of a check somebody remembers to write.

Use an optional capability

Moderation, member lookup, interactive components and slash commands are optional. Ask for them by type assertion:

if mod, ok := chatplatform.AsModerator(p); ok {
    if err := mod.DeleteMessage(ctx, ref, "off-topic"); err != nil {
        log.Print(err)
    }
}

A provider that has none of them is legitimate, which is why they are not part of Actor. On a read-only provider the answer is always false for these, because there is no Actor to assert against. VoiceParticipants and ReactionObserver are the two that survive, since they hang off the Reader.

See Optional capabilities for why, and the capability reference for what each one offers.

Notice a reconnect that lost messages

if st := p.Reader.State(); st.LastReconnectLostEvents {
    log.Print("reconnect re-identified; messages during the gap were dropped")
}

This is the failure worth alerting on, and it is invisible without being told. See Reconnects and lost events.

Where to go next