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.Config) (*chatplatform.Provider, error) {
    return New(cfg)
}

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

2. Construction must not need credentials

New validates configuration. Connect reaches out. Nothing in construction may require a working token, a network call, or a live session.

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 New 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 with a dummy 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

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

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.

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 with it.

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.