Documentation
¶
Overview ¶
Package chatplatform is a platform-agnostic contract for chat bots.
It defines what a bot needs from a chat platform — receive messages, reply in a thread, react, and optionally moderate — without naming any particular platform. Providers live in their own modules and register themselves at init(), so this package carries no vendor SDK and no HTTP stack.
Reading and acting are separate ¶
Reader observes. Actor changes what people see. They are separate interfaces because that makes an observe-only deployment structural: build a provider with Config.ReadOnly and Provider.Actor is nil, so the process cannot post regardless of the logic above it.
Capabilities are optional ¶
Moderator, MemberInspector, Interactive and Commands are discovered by type assertion. A provider implementing none of them is legitimate — a read-only bridge, or a platform with no equivalent concept.
One provider serves one space ¶
A "space" is a guild, workspace or network, fixed at construction. Scoping it there keeps the concept out of every method signature, and out of a contract that would otherwise have to pick one platform's word for it.
Index ¶
- Variables
- func Register(name string, f Factory) error
- func Registered() []string
- func Unregister(name string)
- type Actor
- type Choice
- type ChoiceStyle
- type CommandOption
- type CommandSpec
- type Commands
- type Config
- type ConnState
- type Factory
- type FieldSpec
- type FormSpec
- type ID
- type Interaction
- type InteractionType
- type Interactive
- type Member
- type MemberInspector
- type Message
- type Moderator
- type PromptSpec
- type Provider
- type Reader
- type Ref
- type ResponseToken
- type Status
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNotConnected is returned by an Actor whose Reader has no live session. ErrNotConnected = errors.NewSentinel("chat-platform.not_connected", "chatplatform: not connected") // ErrUnsupported is returned when a provider cannot honour a request the // platform has no equivalent for. Prefer omitting an optional capability // over implementing it to return this. ErrUnsupported = errors.NewSentinel("chat-platform.unsupported", "chatplatform: capability not supported by this provider") // ErrChannelDenied is returned when a Ref names a channel outside the // allowlist. Providers enforce the allowlist themselves; a caller must not // be able to reach past it by constructing a Ref. ErrChannelDenied = errors.NewSentinel("chat-platform.channel_denied", "chatplatform: channel not in allowlist") // ErrNotFound is returned when a referenced message, thread or member does // not exist. Distinct from a transport failure: retrying will not help. ErrNotFound = errors.NewSentinel("chat-platform.not_found", "chatplatform: not found") // ErrAlreadyRegistered is returned by Register when a factory already // exists under that name. ErrAlreadyRegistered = errors.NewSentinel("chat-platform.already_registered", "chatplatform: a provider is already registered under this name") // ErrInvalidName is returned by Register for an empty provider name. ErrInvalidName = errors.NewSentinel("chat-platform.invalid_name", "chatplatform: provider name must not be empty") // ErrNilFactory is returned by Register for a nil factory. ErrNilFactory = errors.NewSentinel("chat-platform.nil_factory", "chatplatform: factory must not be nil") // Prompt and form validation. Ambiguous keys are the failure these guard: // routing an interaction by a duplicate key means taking the wrong action. ErrEmptyContent = errors.NewSentinel("chat-platform.empty_content", "chatplatform: prompt content must not be empty") ErrNoChoices = errors.NewSentinel("chat-platform.no_choices", "chatplatform: prompt must offer at least one choice") ErrEmptyChoiceKey = errors.NewSentinel("chat-platform.empty_choice_key", "chatplatform: choice key must not be empty") ErrEmptyChoiceLabel = errors.NewSentinel("chat-platform.empty_choice_label", "chatplatform: choice label must not be empty") ErrDuplicateChoiceKey = errors.NewSentinel("chat-platform.duplicate_choice_key", "chatplatform: choice keys must be unique") ErrEmptyTitle = errors.NewSentinel("chat-platform.empty_title", "chatplatform: form title must not be empty") ErrNoFields = errors.NewSentinel("chat-platform.no_fields", "chatplatform: form must have at least one field") ErrEmptyFieldKey = errors.NewSentinel("chat-platform.empty_field_key", "chatplatform: field key must not be empty") ErrDuplicateFieldKey = errors.NewSentinel("chat-platform.duplicate_field_key", "chatplatform: field keys must be unique") // Command validation. ErrEmptyCommandName = errors.NewSentinel("chat-platform.empty_command_name", "chatplatform: command name must not be empty") ErrEmptyDescription = errors.NewSentinel("chat-platform.empty_description", "chatplatform: command description must not be empty") ErrEmptyOptionName = errors.NewSentinel("chat-platform.empty_option_name", "chatplatform: option name must not be empty") ErrDuplicateOptionName = errors.NewSentinel("chat-platform.duplicate_option_name", "chatplatform: option names must be unique") )
Sentinels use the standard library rather than github.com/cockroachdb/errors, which the wider toolkit prefers. This module asserts a zero third-party dependency graph (see depfootprint_test.go) — a property worth more here than consistency of error library, because it is what lets a consumer accept a Reader without inheriting anything. Providers are free to use richer errors; they wrap these.
Functions ¶
func Register ¶
Register associates a name with a Factory. Safe to call concurrently.
It returns ErrAlreadyRegistered rather than overwriting. Silently replacing would let a blank-imported provider displace another with no diagnostic and initialisation order deciding the winner — so the failure would surface later, as the wrong provider being used, a long way from its cause.
Returning an error rather than panicking leaves the decision with the caller. A provider registering from init() has nothing sensible to do with a failure and should panic at its own call site, where the panic names the module at fault:
func init() {
if err := chatplatform.Register("discord", New); err != nil {
panic("chat-platform-discord: " + err.Error())
}
}
func Registered ¶
func Registered() []string
Registered lists known provider names, sorted, so callers rendering them — a help string, an error listing valid values — get a stable order.
func Unregister ¶
func Unregister(name string)
Unregister removes a factory. It exists for tests, which must be able to leave the registry as they found it; production code registers once from init() and never removes.
Types ¶
type Actor ¶
type Actor interface {
// ReplyInThread posts content in a thread on the referenced message,
// creating the thread if Ref.ThreadID is empty. Returns the thread's id.
ReplyInThread(ctx context.Context, to Ref, threadName, content string) (ID, error)
// React adds a reaction to the referenced message.
React(ctx context.Context, to Ref, emoji string) error
// ThreadHistory returns up to limit messages from a thread, oldest first.
// Used to carry conversation context somewhere it can be read by people who
// were never in the thread.
ThreadHistory(ctx context.Context, threadID ID, limit int) ([]Message, error)
}
Actor changes what people see. Everything here is observable by somebody.
type Choice ¶
type Choice struct {
// Key identifies the choice when it comes back. It is matched exactly, so
// it must be stable across restarts — an interaction can arrive long after
// the prompt was posted.
Key string
// Label is what the person reads.
Label string
Style ChoiceStyle
}
Choice is one labelled option on a prompt.
type ChoiceStyle ¶
type ChoiceStyle int
ChoiceStyle hints at how prominent or dangerous a choice is. It is a hint: a platform without styling ignores it, and no behaviour may depend on it.
const ( // StyleDefault is an ordinary choice. StyleDefault ChoiceStyle = iota // StylePrimary is the choice a person most likely wants. StylePrimary // StyleDanger marks a destructive choice — deleting, banning. StyleDanger )
func (ChoiceStyle) String ¶
func (s ChoiceStyle) String() string
String implements fmt.Stringer. Unknown values render as default, since a style is advisory and an unrecognised one must not break rendering.
type CommandOption ¶
CommandOption is one argument to a command.
type CommandSpec ¶
type CommandSpec struct {
Name string
Description string
Options []CommandOption
// RequiredRoles restricts who may invoke the command, where the platform
// can enforce it. A provider that cannot MUST still deliver the
// interaction — the caller re-checks Interaction.By.Roles regardless,
// because platform-side gating is a convenience and never the authority.
RequiredRoles []ID
}
CommandSpec declares a command.
func (CommandSpec) Validate ¶
func (c CommandSpec) Validate() error
Validate reports whether the command can be registered and its arguments read back unambiguously.
type Commands ¶
type Commands interface {
// RegisterCommands declares the complete set, replacing whatever was
// registered before.
//
// Declarative rather than incremental: it is idempotent, safe to run on
// every start, and leaves no way for the registered set to drift from the
// declared one. Partial updates are impossible by design.
RegisterCommands(ctx context.Context, cmds []CommandSpec) error
}
Commands registers the commands a platform offers its users.
func AsCommands ¶
AsCommands returns the provider's Commands, if it has one.
type Config ¶
type Config struct {
// Token authenticates the bot.
Token string
// Space is the single guild, workspace or network this provider serves,
// fixed here so no method has to take it — and so the contract never has to
// choose one platform's word for the concept.
Space ID
// AllowedChannels is the exhaustive set of channels a Reader may emit from.
// Empty means none. Providers enforce this themselves; a consumer must not
// be able to reach past it.
AllowedChannels []ID
// ReadOnly asks for an observing provider. A provider MUST return a
// Provider with a nil Actor when this is set, rather than an Actor that
// refuses at call time — the point is that there is nothing to misuse.
ReadOnly bool
}
Config is what every provider is constructed from.
There are no provider-specific fields. Anything one platform needs and another does not belongs in that provider's own options, or the contract starts carrying one vendor's vocabulary.
func (Config) ChannelAllowed ¶
ChannelAllowed reports whether a channel is in the allowlist. It fails closed: an empty allowlist permits nothing, because a watchlist that silently means "everything" is the wrong default for reading people's messages.
Threads ¶
This answers about the id it is given and nothing else. A provider whose platform models a thread as its own channel MUST additionally admit a thread whose PARENT is allowed, because the alternative is incoherent: a bot can be told to watch a channel, reply in a thread on a message there, and then never see the replies — able to start a conversation it cannot hear.
Admitting the thread does not widen the allowlist. The parent was named, the thread hangs off a message in it, and a thread cannot be created anywhere its parent is not. A consumer that wants channel-only can compare ThreadID itself.
A consumer keeping its own allowlist — because this one is fixed at construction and theirs is not — reproduces the decision with Message.ParentID, testing that in place of ChannelID whenever it is set. A provider that admits a thread MUST populate it, or the consumer is left choosing between dropping every thread reply and admitting them unrevocably.
type ConnState ¶
type ConnState struct {
Status Status
// LastReconnectLostEvents reports whether the most recent reconnect started
// a fresh session rather than resuming the previous one.
//
// A fresh session means every event buffered during the gap was DROPPED. For
// a bot that answers questions, those are questions nobody will be answered
// — a failure with no error, no crash and no trace. Nothing else surfaces
// it, so the contract does.
LastReconnectLostEvents bool
// Since is when the current Status began.
Since time.Time
}
ConnState describes a Reader's connection, so a health check can distinguish "the process is alive" from "the platform is reachable".
type FieldSpec ¶
type FieldSpec struct {
// Key identifies the value when the form comes back.
Key string
Label string
// Value prefills the field. This is what lets a person see and edit exactly
// what is about to be published on their behalf, rather than consenting to
// something they have not read.
Value string
Multiline bool
Required bool
// MaxLen is an optional character limit. Zero means the platform's default.
MaxLen int
}
FieldSpec is one text input on a form.
type ID ¶
type ID string
ID is 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. Providers using typed identifiers convert at one boundary rather than at every call site.
type Interaction ¶
type Interaction struct {
Type InteractionType
Token ResponseToken
// Ref locates what was acted upon.
Ref Ref
// By is who acted. Authorisation is decided from By.Roles — never from a
// claim in message content, and never from By.Name.
By Member
// ChoiceKey is set when Type is ChoiceSelected.
ChoiceKey string
// Values holds submitted fields when Type is FormSubmitted.
Values map[string]string
// Command and Args are set when Type is CommandInvoked.
Command string
Args map[string]string
}
Interaction is a person acting on a prompt, form or command.
func (Interaction) Arg ¶
func (i Interaction) Arg(key string) string
Arg returns a command argument, or empty if absent.
func (Interaction) Value ¶
func (i Interaction) Value(key string) string
Value returns a submitted form field, or empty if absent. Safe on a zero Interaction: an interaction carrying no values is ordinary, not exceptional.
type InteractionType ¶
type InteractionType int
InteractionType distinguishes what a person did.
const ( // ChoiceSelected means a choice on a prompt was picked. ChoiceSelected InteractionType = iota // FormSubmitted means a form was filled in and submitted. FormSubmitted // CommandInvoked means a registered command was run. CommandInvoked )
func (InteractionType) String ¶
func (t InteractionType) String() string
String implements fmt.Stringer.
type Interactive ¶
type Interactive interface {
// Prompt posts a message offering choices, returning the message id.
Prompt(ctx context.Context, to Ref, p PromptSpec) (ID, error)
// OpenForm opens a form in response to an interaction. Most platforms only
// permit this as a direct response, which is why it takes a token rather
// than a Ref.
OpenForm(ctx context.Context, tok ResponseToken, f FormSpec) error
// Respond answers an interaction.
Respond(ctx context.Context, tok ResponseToken, content string, ephemeral bool) error
// UpdateSource replaces the message an interaction came from — a card
// showing who actioned it, with the choices removed.
//
// Without this, buttons stay live after they have been used and a second
// person actions the same thing. Passing nil choices removes them.
UpdateSource(ctx context.Context, tok ResponseToken, content string, choices []Choice) error
// Interactions yields interactions until the session ends.
Interactions() <-chan Interaction
}
Interactive is components: prompts offering choices, and forms.
The surface stops deliberately short of any platform's component model. There are no rows, no styling beyond a hint, no custom-id encoding and no message flags — reproducing those would make this Discord's API with different names, which is what the boundary exists to prevent.
func AsInteractive ¶
func AsInteractive(p *Provider) (Interactive, bool)
AsInteractive returns the provider's Interactive, if it has one.
type Member ¶
Member is a person on the platform.
Name is for display. Authorisation is decided from Roles and nothing else — a display name is user-controlled on most platforms and is not an identity.
func (Member) HasAnyRole ¶
HasAnyRole reports whether the member holds at least one of the given roles. An empty argument list returns false: an empty allowlist permits nothing.
type MemberInspector ¶
type MemberInspector interface {
// Member returns a member's current identity and roles. Returns ErrNotFound
// if they are not in the space.
Member(ctx context.Context, userID ID) (Member, error)
// MemberJoined returns when a member joined the space.
MemberJoined(ctx context.Context, userID ID) (time.Time, error)
}
MemberInspector supplies the signals that change how an ambiguous message reads — an account created yesterday posting its fourth message is a different situation from a member of two years.
func AsMemberInspector ¶
func AsMemberInspector(p *Provider) (MemberInspector, bool)
AsMemberInspector returns the provider's MemberInspector, if it has one.
type Message ¶
type Message struct {
ID ID
ChannelID ID
// ThreadID is empty when the message is not already in a thread.
ThreadID ID
// ParentID is the channel a thread hangs off, and is empty when the message
// is not in a thread — so ChannelID is the channel to test in that case.
//
// It exists because ChannelAllowed admits a thread on behalf of its parent,
// and a consumer that keeps its own allowlist cannot otherwise agree with
// that decision: a thread's id is its own, never the configured one. Without
// the parent, such a consumer must either drop every thread reply or accept
// every one of them, and the second is not revocable — a channel removed
// from the allowlist would keep leaking through its threads.
//
// Empty when the platform models threads as their own channels but the
// parent could not be resolved. Treat that as "not permitted", matching
// ChannelAllowed: an unresolvable parent is not evidence of permission.
ParentID ID
Content string
IsBot bool
Author Member
// Addressed reports that this message speaks to the bot directly: it
// mentions the bot, or it replies to something the bot said.
//
// A boolean rather than a mention list, deliberately. A consumer that must
// answer "was I spoken to" should not have to learn who else was mentioned,
// nor parse a platform's mention syntax out of Content — which is where a
// consumer picks up a dependency on the very wire format this contract
// exists to hide.
//
// This is NOT mention resolution. Nothing user-controlled reaches the
// consumer through it: no display names, no rendered markup, no list of
// third parties. It is one fact the provider is uniquely able to establish,
// because only the provider knows which account it authenticated as.
//
// False when the provider cannot determine its own identity, which fails
// closed: a bot that does not know whether it was addressed must not assume
// it was.
Addressed bool
}
Message is an inbound message, normalised across platforms.
Every field here 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.
type Moderator ¶
type Moderator interface {
// DeleteMessage removes a message. reason is recorded in the platform's
// audit log where one exists.
DeleteMessage(ctx context.Context, ref Ref, reason string) error
// TimeoutMember silences a member for a period. Providers MUST treat a
// non-positive duration as a request to lift an existing timeout rather
// than as an error, so a caller has one way to say "undo this".
TimeoutMember(ctx context.Context, userID ID, d time.Duration, reason string) error
}
Moderator is the destructive surface.
A caller MUST NOT be able to reach these from anything a person typed or a model produced. The path to them belongs behind an authorisation check on a verified interaction, and should share no code with the answering path.
func AsModerator ¶
AsModerator returns the provider's Moderator, if it has one.
type PromptSpec ¶
type PromptSpec struct {
Content string
Choices []Choice
// Ephemeral asks that only the person who triggered it can see the result,
// where the platform supports that. Providers that cannot MUST post
// normally rather than fail — losing privacy is better than losing the
// answer, and the caller cannot recover from a refusal here.
Ephemeral bool
}
PromptSpec is a message offering a set of choices.
func (PromptSpec) Validate ¶
func (p PromptSpec) Validate() error
Validate reports whether the prompt can be routed unambiguously.
Duplicate or empty keys are the failure worth catching: a moderation card carries dismiss, delete and ban, and routing an ambiguous key means taking the wrong action against a person.
type Provider ¶
Provider is what a platform supplies. Actor is nil when Config.ReadOnly was set; optional capabilities are found by type-asserting Actor.
type Reader ¶
type Reader interface {
// Connect establishes a session and returns once it is usable.
Connect(ctx context.Context) error
// Messages yields inbound messages until the session ends. Implementations
// MUST NOT emit messages from channels outside the configured allowlist,
// and MUST NOT block the platform's read loop when the consumer is slow.
Messages() <-chan Message
// State reports the current connection state.
State() ConnState
// Close releases the session. It must be safe to call more than once, and
// safe on a Reader that never connected.
Close() error
}
Reader observes a platform. A Reader can see and nothing else.
Separating this from Actor is what makes an observe-only deployment structural: build with Config.ReadOnly and there is no Actor to misuse.
type Ref ¶
Ref identifies something to act upon without exposing platform types.
ThreadID is empty to mean "the channel"; where an Actor method creates a thread, an empty ThreadID asks for one.
type ResponseToken ¶
type ResponseToken string
ResponseToken is an opaque handle for replying to an interaction.
Platforms differ in how long one stays valid and what may be done with it. Providers MUST acknowledge an interaction on receipt so the token survives long enough for a caller to do real work before responding — a caller that must retrieve documents and call a model cannot meet a three-second deadline, and no consumer should have to know one exists.