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.Closetakes 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:
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:
- Configuration: what each
ClientConfigandScopefield obliges you to do. - Reader, Actor and the value types: the methods you must supply, and the guarantees a caller reads them as.
- Capability interfaces: what each optional
interface promises, and exactly what
Validaterejects. - Errors: which sentinel to return for what.
- What this contract does not do: so you do not implement something the contract deliberately leaves out.