Skip to content

Reader, Actor and the value types

Provider is what a factory returns:

type Provider struct {
    Name   string
    Reader Reader
    Actor  Actor
}

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.

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 — 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 the thread's ID.

  • 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 ID is the thread, which is what you keep in order to reply again into the same conversation.

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
}

### 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 ID 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](configuration.md).

### 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:

```go
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.

func (m Message) Ref() Ref

**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

```go
type Member struct {
    ID    ID
    Name  string
    Roles []ID
}

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

Name is for display only. Authorisation is decided from Roles and nothing else — 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.