Skip to content

Reader, Actor and the value types

Provider is what a factory returns:

type Provider struct {
    Name   string
    Reader Reader
    Actor  Actor
    Needs  []Need
}

Name identifies the provider in logs and errors. Reader is never nil for a usable provider. Actor is nil when the scope was minted with WithReadOnly, and the optional capabilities are found by type-asserting it. Needs is what the Client was constructed with; the As* helpers consult it, so a capability that was never declared is not discoverable however capable the provider is.

Reader: the observing half

type Reader interface {
    Connect(ctx context.Context) error
    Messages() <-chan Message
    State() ConnState
    Close() error
}

Connect

Establishes a session and returns once it is usable. This is where the network happens; construction does not touch it. An error means no session was established.

Providers are not required to keep reconnecting after Connect returns successfully; most platform SDKs do it themselves. What the contract does require is that a reconnect which lost events is reported through ConnState.LastReconnectLostEvents.

Messages

Yields inbound messages until the session ends. Two rules bind implementations:

  • Nothing outside the allowlist may be emitted. Filtering is the provider's job, not the consumer's.
  • A slow consumer must not block the platform's read loop. Most gateways deliver events synchronously on the connection's read goroutine, so a consumer that stalls there eventually costs the session. Providers buffer and drop rather than block, which means a consumer that falls far enough behind loses messages silently.

The channel is closed when the reader closes, so range terminates on Close. It is never nil for a conforming provider. A nil channel would block forever, which reads as a quiet platform rather than a broken provider, and the conformance harness checks for exactly that.

State

Returns the current ConnState. Cheap and safe to call from a health check on every request.

Close

Releases the session. Safe to call more than once, and safe on a Reader that never connected, because shutdown runs after failures, and a panic there buries the original error. Both properties are checked by the conformance harness.

Actor: the half that changes what people see

type Actor interface {
    ReplyInThread(ctx context.Context, to Ref, threadName, content string) (Ref, error)
    React(ctx context.Context, to Ref, emoji string) error
    ThreadHistory(ctx context.Context, threadID ID, limit int) ([]Message, error)
}

Everything here is observable by somebody. There are three methods and no more: this is the surface a support bot needs, not a general messaging API.

ReplyInThread

Posts content in a thread on the message named by to, and returns a Ref to the message it posted.

  • to.ThreadID empty: a thread is created on to.MessageID and named threadName, then the content is posted in it.
  • to.ThreadID set: the content is posted in that existing thread and threadName is ignored.

The returned Ref carries the channel, the message and the thread it landed in. Keep it to act on what you just said (react to it, replace it, retract it) and pass it back as to to reply again into the same thread. It used to return the thread's id, and that was a trap: a reaction arrives against a message, so a caller storing the thread id as a join key got something that looked wired up and never matched.

There is no method that posts an ordinary message to a channel. Every reply is threaded, on purpose: a bot answering in the channel body is a bot shouting over people. See What this contract does not do.

React

Adds emoji as a reaction on to. The emoji string's format is the platform's own: a Unicode character on most platforms, a name-and-ID pair for a custom emoji on Discord. The contract does not normalise it, and the same string comes back as Reaction.Emoji() when somebody else uses it. See ReactionObserver.

ThreadHistory

Returns up to limit messages from a thread, oldest first. A transcript read in reverse is not a transcript, and providers reverse the platform's own ordering where it differs.

There is no pagination, no cursor and no "before this message" argument. limit is passed to the platform as given; a value the platform will not accept comes back as an error rather than being clamped. It is intended for carrying a conversation's context somewhere it can be read by people who were never in the thread, not for archiving a channel.

ConnState: what a health check reads

type ConnState struct {
    Status                  Status
    LastReconnectLostEvents bool
    Since                   time.Time
}

func (c ConnState) Healthy() bool // Status == StatusConnected
Field Meaning
Status the coarse connection state
LastReconnectLostEvents the most recent reconnect started a fresh session, so everything buffered during the gap was dropped
Since when the current Status began

The zero ConnState is disconnected, unhealthy, with no loss recorded, which is the right reading for a provider that has not connected yet.

Healthy deliberately ignores LastReconnectLostEvents. A session that resumed with loss is usable: messages are arriving and a restart would fix nothing, so folding the loss into a health check would take a working bot out of rotation. The loss is a separate signal, worth alerting on in its own right. The reasoning is in Reconnects and lost events.

Status: the three connection states

const (
    StatusDisconnected Status = iota // "disconnected"
    StatusReconnecting               // "reconnecting"
    StatusConnected                  // "connected"
)

Status is an int and implements fmt.Stringer. Any value outside the three renders as "unknown" rather than a number.

StatusDisconnected is the zero value, so a ConnState nobody has populated reports disconnected rather than connected. There is no StatusUnknown and no StatusDegraded.

Message: an inbound message

type Message struct {
    ID        ID
    ChannelID ID
    ThreadID  ID
    ParentID  ID
    Content   string
    Author    Member
    Addressed bool
}

func (m Message) Ref() Ref

Did this message speak to me?

Addressed reports that the message mentions the bot, or replies to something the bot said. It is the field to trigger on.

It is a boolean rather than a mention list on purpose. A consumer answering "was I spoken to" does not need to know who else was mentioned, and should not be parsing a platform's mention syntax out of Content. That is exactly where a consumer picks up a dependency on the wire format this contract exists to hide.

This is not mention resolution. Nothing user-controlled reaches you through it: no display names, no rendered markup, no third parties. It is one fact only the provider can establish, because only the provider knows which account it authenticated as.

It is false when the provider cannot determine its own identity. That fails closed: a bot that does not know whether it was addressed must not assume it was.

ThreadID is set when the message arrived in a thread, and empty in a channel. Together they cover the three ways a bot is normally addressed: a mention, a reply, and a follow-up in a thread it opened, the last by keeping the Ref.ThreadID that ReplyInThread returned and comparing it against ThreadID.

A thread of an allowed channel is allowed. Providers admit it even though the thread's own ID is not in AllowedChannels; the alternative is a bot that can start a conversation it cannot hear. See Config.

Filtering again, downstream

ParentID is the channel a thread hangs off, and is empty outside a thread.

You need it only if you keep a second allowlist of your own, typically because AllowedChannels is fixed when the provider is constructed and yours can change while the bot runs. Such a consumer cannot re-derive the admission above from ChannelID, because a thread's ID is its own and never the one that was configured. Test ParentID in place of ChannelID wherever it is set:

func permitted(allowed map[chatplatform.ID]bool, m chatplatform.Message) bool {
    if m.ParentID != "" {
        return allowed[m.ParentID]
    }

    return allowed[m.ChannelID]
}

Without it the choice is between dropping every thread reply (reintroducing the bot that cannot hear its own conversation) and admitting all of them, which is not revocable: a channel removed from your allowlist would keep arriving through its threads.

ParentID is empty when the parent could not be resolved. Treat that as not permitted, matching ChannelAllowed: an unresolvable parent is not evidence of permission.

Every field is untrusted. Content reaches an LLM prompt, an issue body and a log line. The type deliberately offers no Markdown rendering and no mention resolution, conveniences that would invite a caller to treat it as safe.

ThreadID is empty when the message is not already in a thread.

Author.IsBot reports that the author is another bot; providers may filter bot-authored messages before they ever reach you, so seeing it true is provider-dependent and never something to rely on for control flow. It lives on Member rather than on Message, so a MemberInspector lookup or a voice participant can ask it too. Being a bot is a property of the account, not of what it sent.

Ref() returns the Ref to act on for this message, so a caller never assembles identifiers by hand. Getting ThreadID wrong is how a reply lands somewhere nobody is reading.

Member: who someone is

type Member struct {
    ID    ID
    Name  string
    Roles []ID
    IsBot bool
}

func (m Member) HasAnyRole(roles ...ID) bool

IsBot is true for webhooks on Discord as well as bots; the contract does not separate them. It is a bare bool, so a caller cannot tell "not a bot" from "this platform does not say". The worst an unreported value does is have a bot treated as a person.

Name is for display only. Authorisation is decided from Roles and nothing else, because a display name is user-controlled on most platforms and is not an identity.

HasAnyRole returns true if the member holds at least one of the given roles. Called with no arguments it returns false: an empty allowlist permits nothing, matching Config.ChannelAllowed. Roles are compared exactly as strings.

A Member with an empty Roles slice is a member with no roles, and a provider that could not attribute an event to anybody drops it rather than delivering an empty identity that looks authoritative.

Ref: what to act upon

type Ref struct {
    ChannelID ID
    MessageID ID
    ThreadID  ID
}

Identifies something to act on without exposing platform types. An empty ThreadID means "the channel"; where an Actor method creates a thread, an empty ThreadID asks for one.

Constructing a Ref by hand is legitimate but easy to get wrong. Prefer Message.Ref() or Interaction.Ref where one is available.

ID: an opaque platform identifier

type ID string

func (i ID) String() string
func (i ID) Valid() bool // i != ""

A channel, message, thread, member or role. It is a string because every platform's identifiers are string-representable, and a distinct type because a signature should say which of its strings are identifiers.

Valid only reports that the identifier is populated. It says nothing about the format and nothing about whether the platform knows it. A syntactically impossible ID passes Valid and fails at the provider.