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.
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.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 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¶
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.