Reader, Actor and the value types¶
Provider is what a factory returns:
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.ThreadIDempty — a thread is created onto.MessageIDand namedthreadName, then the content is posted in it.to.ThreadIDset — the content is posted in that existing thread andthreadNameis 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¶
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¶
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.