chatplatform

package module
v0.4.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: MIT Imports: 6 Imported by: 0

README

chat-platform

A chat platform contract for Go — receive, reply and moderate across Discord, Slack or anything else, with no vendor SDK in the core

Go Reference Pipeline phpboyscout Go toolkit

Part of the phpboyscout Go toolkit — small, framework-free Go modules. Docs: chat-platform.go.phpboyscout.uk


Writing a bot that reads a Discord channel should not put a Discord SDK in your dependency graph, and supporting Slack later should not mean a second code path.

factory, _ := chatplatform.Lookup("discord")
p, _ := factory(chatplatform.Config{Token: tok, Space: guild, AllowedChannels: chans})

for msg := range p.Reader.Messages() {
    p.Actor.ReplyInThread(ctx, msg.Ref(), "help", answer)
}

Your code depends on chatplatform.Reader, never on a gateway client.

Design

  • No vendor SDK in the core. Every platform client lives behind a provider module boundary, so accepting a Reader — or authoring a provider — costs you nothing but this module. A depfootprint guard enforces it.
  • Reading and acting are different interfaces. A Reader observes; an Actor changes what people see. Construct read-only and there is no Actor at all, so an observe-only mode is enforced by the type system rather than by remembering to check a flag.
  • A registry, not a switch. Providers register at init() under a plain string key, so a platform this module has never heard of ships as your module with nothing contributed here.
  • Capabilities are opt-in. Moderation, member lookup, interactive components and slash commands are optional interfaces found by type assertion. A read-only bridge with none of them is a legitimate provider.
  • One provider, one space. A provider serves a single guild, workspace or network, fixed at construction — so the contract never has to name a concept that differs on every platform.
  • A conformance harness. The compiler checks your method set; it cannot check that you honour an allowlist, return the right sentinel, or refuse to build an Actor when asked for read-only. RunProviderConformance does.

Providers

Platform Module
Discord chat-platform-discord

Provider modules are thin adapters and carry no separate docs site — their documentation lives on the core site.

Install

go get gitlab.com/phpboyscout/go/chat-platform

Status

Pre-1.0. The public API may change in a minor release.

Licence

MIT — see LICENSE.

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

Constants

This section is empty.

Variables

View Source
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

func Register(name string, f Factory) error

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

type CommandOption struct {
	Name        string
	Description string
	Required    bool
}

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

func AsCommands(p *Provider) (Commands, bool)

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

func (c Config) ChannelAllowed(id ID) bool

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

func (ConnState) Healthy

func (c ConnState) Healthy() bool

Healthy reports whether the connection is usable now. It is deliberately independent of LastReconnectLostEvents: a resumed-with-loss session is healthy, and the loss is a separate signal worth alerting on in its own right.

type Factory

type Factory func(Config) (*Provider, error)

Factory builds a Provider from a Config.

func Lookup

func Lookup(name string) (Factory, bool)

Lookup returns the factory registered under name.

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 FormSpec

type FormSpec struct {
	Title  string
	Fields []FieldSpec
}

FormSpec is a small set of text inputs, opened in response to an interaction.

func (FormSpec) Validate

func (f FormSpec) Validate() error

Validate reports whether the form's values can be read back unambiguously.

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.

func (ID) String

func (i ID) String() string

String returns the identifier as a plain string.

func (ID) Valid

func (i ID) Valid() bool

Valid reports whether the identifier is populated. It says nothing about whether the platform knows it — only that it is not the zero value.

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

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

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

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

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.

func (Message) Ref

func (m Message) Ref() Ref

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

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

func AsModerator(p *Provider) (Moderator, bool)

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

type Provider struct {
	Name   string
	Reader Reader
	Actor  Actor
}

Provider is what a platform supplies. Actor is nil when Config.ReadOnly was set; optional capabilities are found by type-asserting Actor.

func New

func New(_ context.Context, name string, cfg Config) (*Provider, error)

New looks up a provider by name and builds it — the one-call path for a consumer that has a name from configuration.

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

type Ref struct {
	ChannelID ID
	MessageID ID
	ThreadID  ID
}

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.

type Status

type Status int

Status is a connection's coarse state.

const (
	// StatusDisconnected means there is no live session.
	StatusDisconnected Status = iota
	// StatusReconnecting means a session was lost and recovery is in progress.
	StatusReconnecting
	// StatusConnected means a session is live and receiving.
	StatusConnected
)

func (Status) String

func (s Status) String() string

String implements fmt.Stringer.

Directories

Path Synopsis
Package test provides a conformance harness for chat-platform providers.
Package test provides a conformance harness for chat-platform providers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL