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
}

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

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

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

p, err := c.Provider(ctx, guildID,
    chatplatform.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
Space Scope ID "" yes, for platforms with a space concept the provider, at construction
AllowedChannels Scope []ID nil effectively, since an empty list reads nothing the provider, at construction
ReadOnly Scope bool false no not validated; it is a request

The contract itself validates none of them. Neither type has a Validate method, and nothing in this module inspects either before handing it to a provider. Every 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
)

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 simply 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, chatplatform.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.

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, chatplatform.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 a parse error. 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) additionally 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.

The allowlist governs the inbound feed only. No Actor or Moderator method consults it in the shipping provider, so a Ref naming a channel outside the allowlist is acted on. See Where the allowlist stops.

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,
    chatplatform.WithAllowedChannels(chans...),
    chatplatform.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 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 WithReadOnly() ProviderOption
func NewScope(space ID, opts ...ProviderOption) Scope

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.

Config: the deprecated single-space type

type Config struct {
    Token           string
    Space           ID
    AllowedChannels []ID
    ReadOnly        bool
    Needs           []Need
}

func (c Config) Scope() Scope
func (c Config) ChannelAllowed(id ID) bool

Deprecated. Removed one minor release after the one that introduced Client. It flattens a connection and a space into one struct, which is exactly the conflation the split exists to undo: New gives back a Provider that owns a transport, so looping it dials once per space and meets connection rate limits in production rather than in a test with one space.

Config.Scope() converts one to the new shape, and Config.ChannelAllowed delegates to Scope.ChannelAllowed, so the semantics described on this page are the same either way. It is only the ownership that differs.

Needs here is as on ClientConfig, with one difference kept alive only so this path keeps compiling: empty means the provider's historic default rather than "nothing declared", which is the blind choice ClientConfig.Needs exists to prevent.

To migrate: move Token and Needs to ClientConfig, and pass Space, AllowedChannels and ReadOnly to Client.Provider as options.