Skip to content

Author a provider

A provider teaches this module how to talk to one platform. It ships as your own module (nothing needs to be contributed here) and a consumer enables it with a blank import.

The guidance below is not abstract. Every rule with a "why" attached is there because building the Discord provider proved it, usually by breaking first.

1. Register at init

package myplatform

import chatplatform "gitlab.com/phpboyscout/go/chat-platform"

const Name = "myplatform"

func init() {
    if err := chatplatform.Register(Name, factory); err != nil {
        panic("chat-platform-myplatform: " + err.Error())
    }
}

func factory(cfg chatplatform.ClientConfig) (chatplatform.Client, error) {
    return NewClient(cfg)
}

Register refuses a duplicate rather than overwriting it, so a blank import cannot silently displace another provider with init order picking the winner.

A Factory returns a Client, not a Provider. A Client is a transport and a Provider is a scope on it, so your Client.Provider mints one per space over the same connection:

func (c *client) Provider(ctx context.Context, space chatplatform.ID,
    opts ...chatplatform.ProviderOption) (*chatplatform.Provider, error) {

    scope := chatplatform.NewScope(space, opts...)
    // ...one reader and (unless scope.ReadOnly) one actor, over the shared transport
}

Use NewScope rather than interpreting the options yourself, so every provider resolves them identically.

Two rules the conformance harness checks, and both are easy to get wrong:

  • A second scope must be mintable, and must be a distinct Provider. Returning a cached one for two different spaces is how a consumer ends up reading another tenant's channels.
  • Closing one scope must not close the transport. Its siblings are still using it. Only Client.Close takes the connection down.

Ask the platform only for what was declared

ClientConfig.Needs is what the consumer declared it will use, and you must not request platform privileges beyond it:

func intentsFor(cfg chatplatform.ClientConfig) gateway.Intents {
    in := gateway.IntentGuilds
    if cfg.Needed(chatplatform.NeedMessages) {
        in |= gateway.IntentGuildMessages | gateway.IntentMessageContent
    }
    // ...
    return in
}

Requesting more than was declared is a silent safety bug: everything works, and the bot holds privileges nobody decided to give it. Requesting a privileged scope the application was never granted is worse on some platforms: Discord closes the gateway with 4014, so an over-broad request stops the bot connecting at all.

2. Construction must not need credentials

NewClient validates configuration. Connect reaches out. Nothing in construction may require a working token, a network call, or a live session. That includes minting a scope: Client.Provider must not dial either.

This is the rule most likely to be broken by accident, because platform SDKs invite it. Discord's does: disgo.New derives the application ID from the bot token and fails on anything that is not one. Building the client in the constructor made the provider impossible to construct without real credentials, which meant its capabilities could not be inspected, and the conformance harness could not run at all, since it deliberately runs offline and sends no token.

So build lazily, and share the client through a small holder that both the reader and the actor hold:

type session struct {
    mu     sync.RWMutex
    client *sdk.Client
}

func (r *reader) Connect(ctx context.Context) error {
    client := r.session.get()

    if client == nil {
        built, err := r.build()   // the SDK constructor lives here, not in New
        if err != nil {
            return err
        }

        r.session.set(built)
        client = built
    }

    return client.Open(ctx)
}

Every Actor method then checks the holder and returns ErrNotConnected:

func (a *actor) rest() (sdk.API, error) {
    c := a.session.get()
    if c == nil {
        return nil, chatplatform.ErrNotConnected
    }

    return c.API, nil
}

A caller who starts work early gets a sentinel they can handle, not a nil-pointer panic.

3. Apply the allowlist yourself

Scope.ChannelAllowed fails closed, and the check belongs in your event handler. An allowlist a consumer has to remember to apply is not an allowlist.

Check the scope the event belongs to, not "the" allowlist. One transport carries several scopes, and consulting the wrong one leaks a tenant's channels to another. Route the event to its space first, then ask that scope.

Reject a malformed channel ID at construction rather than skipping it. Dropping it silently produces an allowlist that is not the one the operator wrote, which for a list governing whose messages get read is the worst outcome available.

3b. Refuse a second scope on a space that already has one

A space has at most one live scope per Client. Asked for a second while the first is live, refuse: return a nil *Provider and an error matching ErrAlreadyScoped, name the space, and leave the existing scope alone.

The reason to be careful here is that the obvious implementation is a bare write to a map keyed by space, and a bare write is the bug. The second scope replaces the first, the displaced consumer keeps a *Provider whose channel is open and healthy-looking, and it never receives again. No error, no log line.

Hold one lock across the check and the write.

c.mu.Lock()
if _, taken := c.scopes[space]; taken {
    c.mu.Unlock()

    return nil, fmt.Errorf("myplatform: space %s already has a live scope: %w",
        space, chatplatform.ErrAlreadyScoped)
}
c.scopes[space] = r
c.mu.Unlock()

Checking under the lock, releasing it, and then writing under it again looks equivalent and is not. Two callers can both pass the check before either writes, and both get a scope. That version is correct in every sequential test and the race detector cannot see it, because every access is mutex-protected, so there is no unsynchronised memory to report. The conformance harness catches it by making fifty concurrent calls and counting how many won.

Key the guard on the space, not on the client. One scope per client passes every same-space check and breaks every multi-tenant consumer, which is what the harness's second space is there to catch.

Release the space when the scope closes, including a scope that never connected. Without that the guard is a lock held for the life of the transport, and a consumer that closes and re-mints (reconfiguring, or rebuilding after an error) is refused forever with no way back short of a new Client. Unregister by identity rather than by key if you can, so a late Close on an already displaced scope cannot delete a live one.

Do not offer a "does this space have a scope?" query to soften the refusal. It invites a check-then-act race in the consumer that the refusal itself does not have.

4. Never block the gateway read loop

Most SDKs deliver events synchronously on the connection's read goroutine. A slow consumer there stalls heartbeats and eventually costs you the session.

Buffer generously and drop rather than block:

select {
case r.messages <- msg:
default:
    // Losing one message is bad; losing the session loses every one after it.
}

5. Fail closed on identity

Authorisation is decided from Member.Roles. An event you cannot attribute to anybody must be dropped, not delivered with an empty identity that looks authoritative.

Guard the SDK call that produces it. Discord's SDK dereferences the user unconditionally when an interaction carries no member, so a payload with neither panicked inside a gateway callback, which takes the whole process down. A bot that dies on a malformed frame is worse than one that ignores it:

func attributeToUser(user func() sdk.User) (m chatplatform.Member, ok bool) {
    defer func() {
        if recover() != nil {
            m, ok = chatplatform.Member{}, false
        }
    }()

    return toMember(user(), nil), true
}

Roles come only from a guild/workspace member. A bare user has none, and inventing an empty set is worse than carrying none.

6. Attach handlers in one place

If you offer an option to inject a pre-built client (useful for testing), that client has none of your handlers on it. Register them from a single method applied to both the client you build and the client you are given:

func (r *reader) listen(c *sdk.Client) {
    c.AddEventListeners(/* every handler */)
}

Miss this and the bot connects, reports healthy, and delivers nothing. It is invisible to any test that drives the actor directly.

7. Close under the same lock as every send

Shutdown races the transport by nature: an event can arrive between "mark closed" and "close the channel". Do both in one critical section, and take the read lock in the send path.

8. Report reconnect losses honestly

ConnState.LastReconnectLostEvents is the signal that distinguishes a self-healing resume from a reconnect that silently dropped everything buffered during the gap.

Record it from whatever your platform actually tells you, at the moment it tells you. Do not derive it from connection state afterwards. Discord reissues a session ID after a resume and after a re-identify, so inferring it from the session made the flag permanently false. The one signal the provider existed to surface was dead, and nothing failed.

Discord says which happened directly: RESUMED means the gap was replayed, a second READY means it was not.

func (r *reader) onReady(*events.Ready) {
    lost := r.identified   // a second READY is a re-identify
    r.identified = true
    r.state = chatplatform.ConnState{
        Status: chatplatform.StatusConnected, LastReconnectLostEvents: lost, Since: time.Now(),
    }
}

func (r *reader) onResumed(*events.Resumed) {
    r.identified = true
    r.state = chatplatform.ConnState{Status: chatplatform.StatusConnected, Since: time.Now()}
}

See Reconnects and lost events.

9. If your platform has a special first response

Some platforms reserve the initial response to an interaction for particular kinds of reply. Discord accepts a modal only as an initial response.

That collides with the contract's requirement to acknowledge an interaction on receipt, which exists so a caller can retrieve documents and call a model without meeting a three-second deadline. Acknowledge eagerly and OpenForm becomes permanently impossible.

Resolve it with a lazy acknowledgement. Give the caller first refusal on the response slot and let the timer fire only if they do not take it:

const gracePeriod = 2 * time.Second   // the platform's deadline is 3s

p.timer = time.AfterFunc(gracePeriod, func() {
    if p.claim() {
        sendDeferredAck(p)
    }
})

claim is a one-shot the caller and the timer race for. Once it is gone:

Method While the slot is free After the acknowledgement
Respond initial response follow-up message
UpdateSource update the source message edit the original response
OpenForm open the form a distinct error

OpenForm must report something the caller can tell apart from ErrUnsupported. The capability exists; the moment passed. A caller that cannot distinguish them will retry something that can never succeed.

10. Implement capabilities honestly

Optional interfaces are found by type assertion. Implement one only if it genuinely works. A method returning "unsupported" tells the caller at runtime what the type system could have told them.

ReadOnly must yield a Provider with a nil Actor, which removes every capability that hangs off it. VoiceParticipants and ReactionObserver hang off the Reader and must survive; the harness fails a read-only scope that loses either.

11. Keep the dependency graph clean

Your module is the only place your platform's SDK may appear. Guard it:

func TestNoOtherPlatformSDK(t *testing.T) {
    // go list -deps must contain no sibling provider, no other platform's
    // client, and no framework.
}

12. Prove it with the conformance harness

The compiler checks your method set. It cannot check that you honour an allowlist, return the right sentinel, or refuse to build an Actor when asked for read-only.

See Run the conformance harness. Treat a failure as a finding about your provider before you treat it as one about the harness. On the Discord provider's first run it was right both times.

What to check your provider against

The rules above are the ones that were learned the hard way. The contract they implement is written out in full in the reference: