Skip to content

Configuration

Configuration is split in two, and the split is the whole design.

A ClientConfig configures a connection. A Scope configures one space on it. Two tenants sharing a transport may legitimately allow different channels, and one may be observe-only while the other is not, so anything that could differ between them belongs to the scope, and only what the connection itself needs belongs to the client.

type ClientConfig struct {
    Token    string
    Needs    []Need
    Mentions MentionPolicy
}

type Scope struct {
    Space           ID
    AllowedChannels []ID
    PublicChannels  []ChannelKind
    ReadOnly        bool
}

You never build a Scope literal. It is assembled from ProviderOptions passed to Client.Provider:

c, err := discord.NewClient(comms.ClientConfig{
    Token: os.Getenv("DISCORD_TOKEN"),
    Needs: []comms.Need{comms.NeedMessages},
})
defer c.Close()

p, err := c.Provider(ctx, guildID,
    comms.WithAllowedChannels(chanA, chanB),
)

There are no provider-specific fields and no options struct on either type: anything one platform needs and another does not belongs in that provider's own functional options, or the contract starts carrying one vendor's vocabulary.

There is no configuration file format, no environment-variable binding and no defaulting layer in this module. Both are plain structs; where their values come from is the consuming application's problem.

Fields at a glance

Field On Type Zero value Required Validated by
Token ClientConfig string "" at Connect, not at construction the provider, on connect
Needs ClientConfig []Need nil no, but empty means nothing declared not validated; it is a declaration
Mentions ClientConfig MentionPolicy notifies nobody no MentionPolicy.Check, called by the provider on each Prompt
Space Scope ID "" yes, for platforms with a space concept the provider, at construction
AllowedChannels Scope []ID nil one of this or PublicChannels, since an empty scope reads nothing the provider, at construction
PublicChannels Scope []ChannelKind nil one of this or AllowedChannels Scope.Validate, called by the provider at construction
ReadOnly Scope bool false no not validated; it is a request
InteractionsAnywhere Scope bool false no not validated; accepted on every scope

The contract validates one thing itself: Scope.Validate refuses a scope no provider could serve, which is the public mode combined with an allowlist, or asked for with no kinds or with ChannelUnknown. A provider calls it in Client.Provider before anything else. Every other check in the table is one the provider performs.

Needs: what the bot declares it will use

Needs is the capabilities a consumer intends to use, and it is the one field here that changes what the provider asks the platform for.

const (
    NeedMessages     Need = ... // message content not otherwise earned
    NeedModeration   Need = ... // delete, time out
    NeedMemberLookup Need = ...
    NeedInteractions Need = ... // buttons, forms
    NeedCommands     Need = ... // slash-command registration
    NeedVoiceReceive Need = ...
    NeedVoiceSend    Need = ...
    NeedVoiceParticipants Need = ... // who is in a voice channel; on the Reader
    NeedAuthoring    Need = ... // replace and delete what the bot posted
    NeedIndicator    Need = ... // the consumer's words on the bot's name
    NeedReactions    Need = ... // hearing reactions change; on the Reader
    NeedSpaceDetails Need = ... // the space's current name; on the Reader
    NeedAllowlistChanges Need = ... // change a live scope's allowlist; on the Reader
    NeedChannelManagement Need = ... // create channels, change who sees them
    NeedRoleManagement    Need = ... // create roles, give and take them
    NeedChannelDetails    Need = ... // read one channel's name; on the Reader
    NeedPosting           Need = ... // post into a channel unprompted
    NeedStageFloor        Need = ... // invite members to speak on a stage
)

A provider MUST NOT request platform privileges beyond these. That is what turns least privilege from a convention somebody remembers into a property of the wiring, and it is why an empty Needs means "nothing declared" rather than "everything".

It cuts both ways, and both directions are silent failures:

  • Declaring too few is a liveness bug that announces itself: the capability is missing and the code that wanted it does not work.
  • Declaring too many is a safety bug that does not. Everything works, and the bot holds privileges nobody decided to give it. A provider filling in every need it supports feels helpful and is the mistake worth guarding against, which is why the conformance harness tests set equality rather than containment.

Needs also gates capability discovery. AsModerator and friends return false for a capability the client never declared, even where the provider implements it. So a need you forgot to declare looks exactly like a provider that cannot do it.

func (c ClientConfig) Needed(n Need) bool

Reports whether n was declared. Providers use it to decide what to ask the platform for. A zero ClientConfig answers false to everything, which is the safe direction: a provider that cannot tell what is wanted must not assume everything is.

What declaring one actually costs

Platform-specific, and worth checking before you declare out of caution.

On Discord, NeedMessages is the one that costs a privileged intent. Not declaring it is a real property and a strong one, but it is not one an end user can audit, and an earlier version of this page said it was.

Intents are not part of the OAuth2 authorisation request. They travel in the gateway IDENTIFY payload and are toggled in the Developer Portal, while the consent screen renders scopes and permissions. Intents are in neither, so the screen a server owner approves cannot disclose them. That is structural, not a quirk of one client's rendering.

The screen is not silent on message reading, though, and that is what makes it misleading rather than merely incomplete. A server owner is shown three things:

What they see What it suggests
Read Message History, ticked and itemised the bot can read messages
A line stating the application cannot read their messages it cannot, though this is about OAuth user scopes, not a bot in a server
(the intent, which actually decides) absent

Read Message History is necessary and not sufficient, which is exactly why its presence cannot answer the question. A bot holding that permission and lacking the intent gets content: "" back, from GET /channels/{id}/messages as well as from the gateway, since the privilege governs history too, which was measured rather than read. The screen discloses the necessary half and omits the deciding half.

So a reader cannot establish from it whether an application receives message content: one signal implies yes, one asserts no, and the one that settles it is not there.

What the property is worth is still considerable: an undeclared privilege is one the platform refuses to serve. Ask for an intent that was not granted and the gateway closes with 4014. So the guarantee does not depend on this consumer's code being correct, which is the strongest form such a guarantee can take. It is not visible to the person being asked to trust the bot.

Be careful how you describe this to a non-technical audience. "You can check for yourself" is not true, and reaching for it is easy because it is the more reassuring sentence.

The property is also bounded, and the unbounded version is false:

a bot that does not declare NeedMessages cannot read messages other than those addressed to it or sent by it

Discord exempts four cases from the privilege regardless: content in messages the app sent, content in DMs with the app, content in which the app is mentioned, and the content of a message a message-context-menu command was used on. The exemption is per message rather than per call, so a consumer holding no privilege still sees content in all four.

That bound matters because the unbounded sentence is the one somebody quotes in a privacy conversation, and it does not survive anyone who knows the platform asking a follow-up question.

Requesting a privileged intent the application has not been granted closes Discord's gateway with 4014. So declaring accurately is not tidiness. It is what keeps a bot connectable at all.

From the Discord provider's v0.5.1, that refusal reaches you as ErrForbidden from Connect, with the close code still in the chain and the portal's own wording for the intent in the message:

if err := p.Reader.Connect(ctx); err != nil {
    if errors.Is(err, comms.ErrForbidden) {
        // A grant is missing. Tell somebody who can add it — err.Error()
        // names the intent as the Discord portal labels it.
    }
}

It is a global misconfiguration wearing a per-tenant disguise. One person fixes it once in the developer portal, but it arrives as a connection failure at whichever scope dials first, and again at every scope after. Matching the sentinel is what lets a startup path say this is a configuration problem, not a network one instead of retrying forever against something no retry can fix.

Token: what authenticates the bot

A platform credential, passed through to the provider verbatim. This module never parses it, never logs it and never sends it anywhere.

It is not needed to construct a provider. Building a provider validates configuration; it does not reach the network. A provider built with an empty token constructs successfully and fails at Connect. That is deliberate (see Construction must not need credentials) and it is what allows the conformance harness to run in CI with no secrets at all.

So a typo in a token surfaces as a Connect error, never as a construction error. If you want a fast fail at startup, call Connect at startup.

Mentions: who the bot's posts may notify

type MentionPolicy struct {
    Members bool
}

By default nothing the bot posts notifies anybody, whatever its text contains. A <@id> in content, or an @everyone echoed from what a user typed, still renders, but no one is pinged. The text itself is not changed; what changes is what the platform does with it.

Set Members to let a prompt or a post notify the members it names in PromptSpec.Notify or PostSpec.Notify, and nobody else:

c, err := discord.NewClient(comms.ClientConfig{
    Token:    os.Getenv("DISCORD_TOKEN"),
    Needs:    []comms.Need{comms.NeedMessages, comms.NeedInteractions},
    Mentions: comms.MentionPolicy{Members: true},
})

It is set on the client, and covers every scope the client mints. A prompt or post that names members on a client without Members is refused with ErrInvalidArgument before anything is posted, rather than posted to nobody. Role and @everyone notifications are not offered. Spec 0031 records why.

Space: the one guild, workspace or network

The single space this provider serves, fixed at construction so no method has to take it.

A Provider serves exactly one, and no method on it takes a space argument.

To watch two guilds, mint two scopes from one Client, not two clients:

for _, g := range guilds {
    p, err := c.Provider(ctx, g.ID, comms.WithAllowedChannels(g.Channels...))
    // ...
}

They get separate readers and separate allowlists and share one connection, which is what a platform carrying many spaces over a single socket expects. Merging their message feeds is your code's job. See What this contract does not do.

What happens when it is wrong is up to the provider, and the Discord provider rejects both failure modes at construction: an empty Space returns an error wrapping ErrNotFound, and one that is not a valid snowflake returns an error wrapping ErrInvalidArgument with the parse error beside it. Neither is deferred to Connect, because a malformed space is a configuration mistake and not a platform problem.

AllowedChannels: the exhaustive read allowlist

The complete set of channels a Reader may emit messages from. Providers enforce it themselves; a consumer cannot reach past it.

An empty list permits nothing. This is the single most surprising default in the module, and it is deliberate:

func (s Scope) ChannelAllowed(id ID) bool {
    for _, a := range s.AllowedChannels {
        if a == id {
            return true
        }
    }

    return false
}

A watchlist that silently means everywhere is the wrong default for reading people's messages, so it fails closed. The symptom of forgetting to populate it is a bot that connects, reports healthy, and receives nothing, which is easily mistaken for a quiet channel. If a bot is connected and silent, check this first.

Threads of an allowed channel are allowed

ChannelAllowed answers about the ID it is given. A provider whose platform models a thread as its own channel (Discord does) also admits a thread whose parent is allowed.

Without that, the module is incoherent: a bot can be told to watch a channel, reply in a thread on a message there, and then never see the replies. It can start a conversation it cannot hear.

This does not widen the allowlist. The parent was named explicitly, a thread hangs off a message inside it, and a thread cannot exist where its parent does not. A consumer that genuinely wants channel-only messages can check ThreadID itself.

ChannelAllowed is exported so a provider (or a test) can apply exactly the check the contract describes rather than reimplementing it. It is a linear scan over the slice, intended for allowlists of the size a person maintains by hand.

A scope's allowlist is never widened by a sibling's. Where one Client carries several scopes, this is the check that keeps them apart, and a provider consulting the wrong scope's list would leak one tenant's channels to another. That is the reason the allowlist is a ProviderOption and not a ClientConfig field.

What happens when an entry is wrong is again the provider's call. The Discord provider rejects a malformed channel ID at construction rather than skipping it: dropping it silently would produce an allowlist that is not the one the operator wrote, which for a list governing whose messages get read is the worst outcome available.

It can change while the scope runs. From chat-platform v0.24.0, a scope that declared NeedAllowlistChanges can admit or remove channels through AllowlistEditor, from the next event, without closing the scope. AllowedChannels and ChannelAllowed on the Scope value stay the list it was minted with.

The allowlist governs reading, and every call that takes a bare channel id. Those calls refuse a channel outside it with ErrChannelDenied: ThreadHistory, ChannelInspector, VoiceReceiver.Join and VoiceParticipants.Participants, which read, and Poster.Post, ChannelManager and Floor, which write. No method that takes a Ref consults it, so a Ref naming a channel outside the allowlist is acted on. See Where the allowlist stops.

InteractionsAnywhere: commands from any channel of the space

p, err := c.Provider(ctx, guildID,
    comms.WithAllowedChannels(tableChannel),
    comms.WithInteractionsAnywhere(),
)

From chat-platform v0.30.0 (spec 0037).

By default an interaction counts as reading, so a command run outside the allowlist is dropped and the person sees "the application did not respond". With this option, commands, button presses and form submissions are admitted from any channel of the space, while the scope still reads only its allowlist:

  • Nothing more is read. Messages and reactions still come only from read channels. Every call that returns content through a bare channel id still refuses a channel outside the allowlist, so being commanded from a channel does not let the bot look around in it.
  • Interaction.OutsideAllowlist is true for an interaction from an unread channel, decided against the live allowlist.
  • Answer with Respond, which always works. Prompt needs the bot's own permissions in that channel, which a person may run a command without.
  • A prompt posted in an unread channel hears its answers. So with this option, DisallowChannels stops reads, not answers.
  • On a PublicChannels scope it admits interactions from private channels too, and still reads nothing there. It does nothing on a read-only scope, which receives no interactions.

PublicChannels: every channel the whole space can read

The other way to say what a scope reads, from chat-platform v0.22.0 (spec 0028). Instead of naming channels, a scope reads every channel of the kinds you ask for that everyone in the space can read, and the public threads under them:

p, err := c.Provider(ctx, guildID,
    comms.WithPublicChannels(comms.ChannelText, comms.ChannelForum),
)
  • Public means the platform's everyone role can view it. A channel readable only by a role is not public, however many members hold that role. How each provider computes it is in the providers reference.
  • Decided per event, against the channel's state at that moment. A channel made private stops being read from its next message, and a new public channel is read from its first, with no restart and no list to keep in step.
  • A thread is judged by its parent, for both visibility and kind, so a post in a public forum arrives when you asked for ChannelForum. A private thread is never read, even under a public channel, because its audience is not the whole space. This is narrower than the allowlist, which admits a private thread under a named channel.
  • Unknown is not public. If the provider cannot yet establish a channel's visibility, for example before its cache has caught up after a reconnect, the event is dropped. Missing a public message is the cheaper mistake.
  • It applies everywhere the allowlist does: inbound messages, reactions, interactions (unless WithInteractionsAnywhere is set), and every call that takes a bare channel id, which refuses a channel outside the scope with ErrChannelDenied.
  • The kinds govern voice too. A bot that joins voice asks for ChannelVoice and ChannelStage, and asking for ChannelVoice also delivers that voice channel's text chat. Join still refuses a channel that carries no voice.

The kinds are required. WithPublicChannels() with none, or with ChannelUnknown, is refused with ErrInvalidArgument, because a mode meaning "every kind" would widen silently as a platform added kinds. So is a scope that also sets WithAllowedChannels: the two are exclusive.

A provider that cannot judge "public" refuses the scope with ErrUnsupported from Client.Provider, rather than reading nothing quietly. The Discord voice node is one: it has no gateway and no cache.

Under this mode Scope.ChannelAllowed answers false for everything, because whether a channel is public is platform state a Scope does not hold.

ReadOnly: asking for a provider that cannot post

Set it and the provider returns a Provider whose Actor is nil:

p, _ := c.Provider(ctx, guild,
    comms.WithAllowedChannels(chans...),
    comms.WithReadOnly(),
)

p.Actor == nil // always

Not an Actor that refuses at call time. No Actor at all. Every optional capability hangs off Actor except VoiceParticipants and ReactionObserver, so this removes moderation, member lookup, interactive components, command registration, authoring, voice capture and playback, and the indicator along with it, while leaving a read-only scope able to ask who is in a voice channel and to hear reactions land. There is no combination of settings that yields something able to delete a message but not to reply.

The cost is that p.Actor must be nil-checked, or constructed in a way that guarantees it. The reasoning is in Reading and acting are separate.

ReadOnly is a request the provider is required to honour, and the conformance harness checks that it does. A provider that returns a non-nil Actor for a read-only config fails conformance.

Where credentials should come from

This module takes a string. It has no opinion on where you got it, and no integration with any secret store, by design, since it has no third-party dependencies at all.

In a phpboyscout tool the token resolves through go/credentials before it reaches ClientConfig. Anywhere else, read it however your application reads secrets. Just do not put it in a config file you commit.

ProviderOption: how a scope is built

type ProviderOption func(*Scope)

func WithAllowedChannels(ids ...ID) ProviderOption
func WithPublicChannels(kinds ...ChannelKind) ProviderOption
func WithReadOnly() ProviderOption
func NewScope(space ID, opts ...ProviderOption) Scope
func (s Scope) Validate() error
func (s Scope) ReadsPublicChannels() bool

Options are per scope and never widen a sibling's. A shared transport carries several, and each is answerable only for what it was given.

WithAllowedChannels appends rather than replaces, so calling it twice unions the two sets. WithReadOnly takes no argument. There is no WithReadOnly(false), because a scope that is not asked to be read-only already is not.

NewScope is for provider authors, not consumers. A provider implementing Client.Provider calls it to apply the options it was handed, so every provider resolves them identically instead of each interpreting the list itself. A nil option in the slice is skipped rather than panicking.