bridge

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 21, 2026 License: MIT Imports: 32 Imported by: 0

README

muster-bridge-go

The Go library for writing a bridge between Muster and a messenger it does not support natively: the library serves Muster's bridge protocol (api/bridge.yaml) — tokens, validation, idempotency, events and signed push — and the bridge author writes only the calls to their messenger. MIT licensed.

A bridge is a small HTTP service that Muster calls to post, edit and reply to messages, and that reports what people do in the messenger — button presses, messages in Threads, Account link tokens — as events. Why the protocol exists and how it is versioned is Muster's ADR-0017; the protocol itself is api/bridge.yaml, of which this repository keeps a byte-for-byte copy.

Install

go get github.com/muster-io/muster-bridge-go@latest

Go 1.26 or newer. The package is bridge. Versions stay 0.x until Muster 1.0. Every 0.x release speaks protocol version 1; a breaking protocol change would be a new protocol version, not a new library version.

Quick start

examples/minimal/main.go is a whole bridge in one file: its messenger lives in memory and writes every message the bot posts to the log. Run it with a token, the one a Bridge Connection in Muster would hold:

printf example-token > /tmp/bt
go run ./examples/minimal -token-file /tmp/bt

It listens on 127.0.0.1:18095 (-listen), knows the channels alerts and ops (-channels) and the people alice and bob (-users), and offers every feature of the protocol, or only those that BRIDGE_FEATURES lists: buttons, thread_messages, direct_messages (which also turns on user_lookup login), private_answers, native_mentions, quiet_messages, status_color, deletion_events and delete. With -push-url and -push-secret-file it pushes its events as well. In another terminal:

curl -s -o /dev/null -w '%{http_code}\n' 127.0.0.1:18095/v1/info          # 401: no token
curl -s -H 'Authorization: Bearer example-token' 127.0.0.1:18095/v1/info \
  | jq -c '{protocol_version, user_lookup}'                                 # {"protocol_version":"1","user_lookup":"login"}

Then let Muster's conformance check run every step of the protocol against it, from Muster's development image. The check reaches the example on the host's loopback, which --network host gives only on Linux:

docker run --rm --network host -v /tmp/bt:/token:ro ghcr.io/muster-io/muster:edge bridge check \
  http://127.0.0.1:18095 --token-file /token --channel alerts --forbidden-channel nobot --handle alice \
  --no-interactive
PASS info
PASS channel_check
PASS post
PASS replayed_key
PASS buttons
PASS edit
PASS thread_reply
PASS thread_ref
PASS forbidden_channel
PASS user_lookup
PASS direct_message
PASS delete
PASS poll
SKIP press: --no-interactive
SKIP push: --no-interactive
PASS answer

The heart of the example is a bridge.Config, a messenger and the handler:

cfg := bridge.Config{
	Name:          "muster-bridge-go minimal example",
	Version:       "0.0.0",
	MessengerName: "Minimal example",
	Bots:          []bridge.Bot{{Name: "main", Token: token}},
	Limits:        bridge.Limits{MaxBodyLength: 4000},
	Capabilities: bridge.Capabilities{
		Buttons: &bridge.Capabilities_Buttons{MaxPerMessage: 5, MaxPerRow: 5, DataMaxBytes: 64,
			Styles: []bridge.ButtonStyle{bridge.ButtonStylePrimary, bridge.ButtonStyleSuccess,
				bridge.ButtonStyleNeutral, bridge.ButtonStyleDanger}},
		ThreadMessages: new(bridge.CapabilitiesThreadMessagesAll),
		DirectMessages: &bridge.Capabilities_DirectMessages{BotCanStart: true},
		Delete:         &bridge.Capabilities_Delete{},
	},
	UserLookup: bridge.InfoUserLookupLogin,
}
b, err := bridge.New(cfg, c) // c implements Messenger, UserFinder, DirectMessenger and Deleter

mux := http.NewServeMux()
mux.Handle("/v1/", b.Handler())
// Outside the protocol: press the first button of a message as a person, for scripts.
mux.HandleFunc("POST /demo/press", func(w http.ResponseWriter, r *http.Request) { c.demoPress(w, r, b) })

POST /demo/press {"message_id": "wl7ffy-m2", "user": "alice"} presses the first button of message wl7ffy-m2 as alice, so that a script can play the person of an interactive check. It has no authentication of its own: keep it on loopback. Message ids start with a prefix that is random for each start of the example, so that a restarted example never reuses an id that Muster still holds.

Writing a bridge

  1. Implement bridge.Messenger: PostMessage, EditMessage, ReplyInThread, CheckChannel and AnswerEvent, each given the name of the bot whose token Muster sent. For the features you declare, also UserFinder (a UserLookup other than none), DirectMessenger (Capabilities.DirectMessages) and Deleter (Capabilities.Delete); bridge.New refuses a configuration whose features lack their interface, or whose info would not match the protocol, and names the reason.
  2. Answer the messenger's failures with bridge.TransientError, RateLimitedError (with the wait and the scope), GoneError (with bridge.ReasonEditWindowClosed for an edit the messenger refuses only for the message's age), ForbiddenError or InvalidError. Any other error, and a panic, is answered as transient with a generic detail and logged without the token.
  3. Mount b.Handler() at the bridge's base URL, under a path prefix with http.StripPrefix. Serve it with no WriteTimeout, or one above 75 seconds: pollEvents holds a request for up to 60 seconds, and a shorter WriteTimeout cuts such a poll off without an answer, so that its events reach Muster late.
  4. Report what the messenger tells you: b.Press, b.ThreadMessage, b.MessageDeleted and b.DirectMessage. Each returns at once, so you confirm a press to your messenger within its deadline whatever Muster does; give the messenger's own update id, so that an update it delivers twice is queued once. A direct message that is an Account link token becomes an account_link event; any other direct message is ignored.
  5. Turn Mention markers into native mentions or plain names with bridge.ReplaceMentions.
  6. For pushes, give a bot a PushTarget — the callback URL and the push secret that its Bridge Connection in Muster shows — in Config.Bots or later with b.SetPush; b.Close stops the pushers.
  7. bridge.NewMemoryStore, the default Store, forgets Idempotency-Keys and queued events on a restart, and holds a day of events and answers in memory; implement Store over your database to keep them. Long polls and pushes are woken in the process that queued an event, so with several processes over one store, run each bot in one process only.

The package documentation, on pkg.go.dev and in doc.go, describes what the library does for every operation; example_test.go has runnable examples.

Checking a bridge

muster bridge check <url> --token-file <file> --channel <id> runs every step of the protocol against a bridge and prints PASS, FAIL or SKIP for each; --forbidden-channel and --handle enable the steps that need them. Without --no-interactive it also waits for a person to press a button (press) and, for a bridge that pushes, receives the push (push): it prints PUSH <url> once its listener (--push-listen) is up — point the bridge's push target there, with the secret of --push-secret-file — and WAIT press: …, message_id <id> before it waits. make conformance plays that person through the minimal example's POST /demo/press and the chat example's POST /chat/api/press; do the same in your bridge's tests with your messenger's own means.

The example bridge

examples/chat is a bridge with a messenger of its own: a small chat in memory with a web page — channels, Threads, buttons in their style hints, direct chats between a person and the bot, a picker of who you are, and an event log — served beside the Bridge protocol on one port. It offers every feature of the protocol, so that Muster can be tried, and tested, without a real messenger. Its image is published for linux/amd64 and linux/arm64, as vX.Y.Z for each release and as edge and sha-<7 hex digits> for each commit on master:

docker run --rm -p 8095:8095 -e BRIDGE_TOKEN=example-token ghcr.io/muster-io/muster-example-bridge:<version>

The page is then at http://127.0.0.1:8095/. In Muster, create a Bridge Connection with the base URL at which Muster reaches the example, such as http://127.0.0.1:8095, and the same token, and a Destination on it for the channel alerts. With the event delivery Polling, Muster polls the example for its events. For Push, start the example with BRIDGE_PUSH_URL set to the callback address that the Bridge Connection shows, and BRIDGE_PUSH_SECRET (or -push-secret-file) set to its push secret. BRIDGE_FEATURES limits the features it offers, comma-separated: with BRIDGE_FEATURES=thread_messages, for one, it shows no buttons and people answer with Text commands in Threads.

The page and its JSON API under /chat/api/ have no authentication of their own: whoever reaches them reads every message and acts as any person. Put them behind some, such as an authenticating proxy, before the example is reachable from beyond your machine. examples/chat/README.md shows how to run it next to muster dev, link accounts, and drive the page from scripts.

The protocol copy

api/bridge.yaml is a copy of Muster's, never edited here: the library embeds it to validate requests and answers, and models.gen.go is generated from it. make spec-check compares it with the one on Muster's master byte for byte, every day in CI; make spec-sync takes the new one and regenerates the models.

Development

The Makefile is the only entry point; make help lists the targets.

Target Does
make fmt formats the code (gofmt and goimports through golangci-lint)
make lint checks the licence headers, go.mod and go.sum, and the code with golangci-lint
make test runs the tests of the package and the examples with the race detector, and the coverage gate of 90 % of the package (models.gen.go excluded)
make generate, make generate-check generates models.gen.go from api/bridge.yaml; fails when it is stale
make spec-check, make spec-sync compares api/bridge.yaml with Muster's; replaces it and regenerates
make licenses fails on a linked dependency outside MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, 0BSD, Unlicense and CC0-1.0
make vulncheck finds vulnerable Go code that is called (govulncheck)
make build builds the package, bin/minimal and bin/chat
make ci lint, generate-check, spec-check, licenses, test and build, as a pull request runs them
make image builds the example bridge's image for this machine (IMAGE, muster-example-bridge:dev by default) and prints its -help; needs Docker
make conformance runs muster bridge check from ghcr.io/muster-io/muster:edge (MUSTER_IMAGE) against each example three times: every feature, only thread_messages, and interactively with push; Linux with Docker only
make clean removes bin/: the examples, the coverage profiles and the installed tools

The pinned tools (golangci-lint, go-licenses, govulncheck, oapi-codegen) are built into bin/tools/ on first use.

Contributing, security and licence

CONTRIBUTING.md says how to contribute, AGENTS.md is the guide for everyone who changes the code, and SECURITY.md how to report a vulnerability privately. The library is under the MIT licence; contributions are accepted under it, without a CLA.

Documentation

Overview

Package bridge serves Muster's Bridge protocol, so that a bridge between Muster and a messenger that Muster does not support natively is only the calls to that messenger.

A bridge is a small HTTP service. Muster calls it to post, edit and delete messages, reply in their Threads, check channels, look up people, send direct messages and answer people; the bridge reports what people do in the messenger — button presses, messages in Threads, deleted messages, Account link tokens — as events that Muster polls or that the bridge pushes. The protocol is api/bridge.yaml of Muster's repository; this package embeds a byte-for-byte copy of it, generates its Go types from it (Info, Capabilities, Limits, MessageInput, MessageContent, ThreadReplyInput, Person, Problem, the events and the rest) and validates every request and answer against it.

The library does the protocol: the bots' tokens, request validation and the bridge's limits, Idempotency-Keys, problem answers, the event queue, long polling and signed pushes. The bridge author writes a Messenger and reports events:

b, err := bridge.New(bridge.Config{
	Name: "my-bridge", Version: "1.0.0", MessengerName: "My messenger",
	Bots:   []bridge.Bot{{Name: "main", Token: token}},
	Limits: bridge.Limits{MaxBodyLength: 4000},
	Capabilities: bridge.Capabilities{Buttons: &bridge.Capabilities_Buttons{
		MaxPerMessage: 5, MaxPerRow: 5, DataMaxBytes: 64,
		Styles: []bridge.ButtonStyle{bridge.ButtonStylePrimary, bridge.ButtonStyleDanger}}},
}, myMessenger)
if err != nil {
	return err
}
defer b.Close(ctx)
http.Handle("/", b.Handler())

// When a person presses a button in the messenger:
err = b.Press(ctx, "main", bridge.Press{UpdateID: callbackID, MessageID: messageID, User: person, Data: data})

examples/minimal in the repository is a complete bridge whose messenger lives in memory.

Operations

Bridge.Handler serves every operation under /v1/; mount it under a path prefix with net/http.StripPrefix. Serve it with an net/http.Server that has no WriteTimeout, or one above 75 seconds: pollEvents holds a request for up to 60 seconds (Muster asks for 25 and waits 15 more), and a shorter WriteTimeout cuts such a poll off without an answer, so that its events reach Muster only on a later poll. ReadHeaderTimeout and IdleTimeout are safe to set. Each request carries one bot's token, compared in constant time with every bot's, and is that bot's: the Messenger is called with the bot's name, and its Idempotency-Keys and events are its own. A missing or wrong token is 401.

  • getInfo answers the info that New built from the Config.
  • postMessage, editMessage, replyInThread, checkChannel and answerEvent call the Messenger.
  • findUsers calls UserFinder, served when Config.UserLookup is not InfoUserLookupNone.
  • sendDirectMessage calls DirectMessenger, served with Capabilities.DirectMessages.
  • deleteMessage calls Deleter, served with Capabilities.Delete. A message that is gone is deleted: 204.
  • pollEvents answers the queued events, served when Config.EventDelivery has poll (the default).

An operation the bridge does not offer answers 404 invalid. New refuses a configuration whose features need an interface the Messenger lacks.

Before it calls the Messenger, the library refuses as 400 invalid a request that does not match the specification, a request body over 1 MiB, a body longer than Limits.MaxBodyLength characters, buttons sent to a bridge without buttons, more buttons than MaxPerMessage and button data longer than DataMaxBytes. It checks every answer of the Messenger against the specification too; one that does not match is logged and answered as transient.

Idempotency

postMessage, replyInThread, sendDirectMessage and answerEvent carry an Idempotency-Key. The first successful answer of a key is kept for 24 hours per bot and answered again, with replayed true, without calling the Messenger, whatever the repeated body. A request whose key is still running is transient; a failed request keeps nothing, so its retry runs again. A request holds its key for two minutes; a Messenger method should return when its ctx ends. If one runs longer, a retry may claim the key and run as well, but the late request neither frees nor overwrites the retry's claim. IdempotencyKeyFrom gives the key to a Messenger whose messenger has idempotency of its own.

Errors

A Messenger method returns an *Error to answer a class of the protocol: TransientError, RateLimitedError (with the wait, sent as Retry-After, and the scope), GoneError (optionally with ReasonEditWindowClosed), ForbiddenError and InvalidError, each with a detail for the Admin. Classes are answered with 500, 429, 404, 403 and 400. A class the operation does not declare is mapped to one it does: gone from deleteMessage is a success; gone from postMessage, checkChannel and sendDirectMessage, and invalid from checkChannel, are forbidden; any other is transient, logged as a mistake of the bridge. Any other error, and a panic, is transient with the detail "The bridge could not reach its messenger.", and logged at level error. The library never logs a token, a push secret or an Account link token, and replaces the bots' tokens and push secrets in every detail and log line.

Events

Bridge.Press, Bridge.ThreadMessage, Bridge.MessageDeleted and Bridge.DirectMessage queue an event for the bot and return at once, so that a bridge confirms an update to its messenger within its deadline independently of Muster. An event's id is derived from the bot, the event type and the messenger's own id of the update when the bridge gives one, so that an update the messenger delivers twice is queued once; it is random otherwise. A direct message becomes an account_link event only when its whole text, trimmed and after an optional "/start ", is 43 characters of base64url; any other is ignored. Events are kept at least 24 hours. pollEvents answers the events after the cursor at once, or waits up to wait seconds (25 by default, at most 60) for one; a cursor the bridge did not give is invalid. answerEvent is gone for an event the bridge does not hold, and otherwise gives the Messenger the ReportedEvent it answers, with the Chat the bridge reported.

Push

With a PushTarget for a bot, in Config.Bots or later with Bridge.SetPush, its events are posted to the callback URL one at a time and in order, signed as Standard Webhooks specifies with the push secret: webhook-id (the event's id), webhook-timestamp and webhook-signature. A 2xx answer acknowledges an event and 400 drops it; any other answer, a timeout of 10 seconds or a network error is retried with exponential back-off from 1 second up to 5 minutes under the same webhook-id, for an hour, after which the event stays queued for polling only. Redirects are not followed. Bridge.Close stops the pushers.

Store

A Store keeps the Idempotency-Keys and the queued events; NewMemoryStore, the default, keeps them in memory, so a restart forgets them and Muster polls again without a cursor. A bridge that must keep them across restarts implements Store over its own database. Long polls and pushers are woken by the process that queued the event: with several processes over one shared Store, run each bot in one process only.

Example

A bridge is a Config, a Messenger and the handler that serves them to Muster.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"

	bridge "github.com/muster-io/muster-bridge-go"
)

// notebook is a messenger that keeps the bot's messages in memory: a whole Messenger in a few lines.
type notebook struct {
	mu       sync.Mutex
	messages []bridge.MessageInput
}

func (n *notebook) PostMessage(_ context.Context, _ string, in bridge.MessageInput) (bridge.MessagePosted, error) {
	if in.ChannelId != "alerts" {
		return bridge.MessagePosted{}, bridge.ForbiddenError("The bot has not joined this channel.")
	}
	n.mu.Lock()
	defer n.mu.Unlock()
	n.messages = append(n.messages, in)
	return bridge.MessagePosted{MessageId: "n" + strconv.Itoa(len(n.messages))}, nil
}

func (n *notebook) EditMessage(_ context.Context, _, id string, in bridge.MessageContent) (bridge.MessageEdited,
	error) {
	n.mu.Lock()
	defer n.mu.Unlock()
	i, err := strconv.Atoi(strings.TrimPrefix(id, "n"))
	if err != nil || i < 1 || i > len(n.messages) {
		return bridge.MessageEdited{}, bridge.GoneError("That message is gone.")
	}
	n.messages[i-1].Title, n.messages[i-1].Body = in.Title, in.Body
	return bridge.MessageEdited{}, nil
}

func (n *notebook) ReplyInThread(_ context.Context, _, id string, _ bridge.ThreadReplyInput) (bridge.ThreadReplyPosted,
	error) {
	return bridge.ThreadReplyPosted{MessageId: id + "-reply", ThreadRef: id}, nil
}

func (n *notebook) CheckChannel(_ context.Context, _, channelID string) (string, error) {
	if channelID != "alerts" {
		return "", bridge.ForbiddenError("The bot has not joined this channel.")
	}
	return "Alerts", nil
}

func (n *notebook) AnswerEvent(context.Context, string, bridge.ReportedEvent, string) (bridge.AnswerShownShownAs,
	error) {
	return bridge.AnswerShownShownAsThread, nil
}

// newNotebookBridge is a bridge with one bot, main, whose token is example-token.
func newNotebookBridge(m bridge.Messenger) *bridge.Bridge {
	b, err := bridge.New(bridge.Config{
		Name:          "notebook-bridge",
		Version:       "1.0.0",
		MessengerName: "Notebook",
		Bots:          []bridge.Bot{{Name: "main", Token: "example-token"}},
		Limits:        bridge.Limits{MaxBodyLength: 4000},
		Capabilities: bridge.Capabilities{Buttons: &bridge.Capabilities_Buttons{MaxPerMessage: 5, MaxPerRow: 5,
			DataMaxBytes: 64, Styles: []bridge.ButtonStyle{bridge.ButtonStylePrimary}}},
		Logger: slog.New(slog.DiscardHandler),
	}, m)
	if err != nil {
		panic(err)
	}
	return b
}

// get is the answer of the handler to a GET of path with the token, as Muster sends it.
func get(h http.Handler, path, token string) *http.Response {
	r := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)
	if token != "" {
		r.Header.Set("Authorization", "Bearer "+token)
	}
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)
	return w.Result()
}

func main() {
	b := newNotebookBridge(&notebook{})
	defer func() { _ = b.Close(context.Background()) }()

	res := get(b.Handler(), "/v1/info", "example-token")
	defer res.Body.Close()
	var info bridge.Info
	if err := json.NewDecoder(res.Body).Decode(&info); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, info.ProtocolVersion, info.UserLookup, info.EventDelivery)
}
Output:
200 1 none [poll push]

Index

Examples

Constants

View Source
const (
	// ClassTransient is a temporary failure, such as a messenger that is down or a timeout; Muster retries.
	ClassTransient = Class(ErrorClassTransient)
	// ClassRateLimited means the messenger or the bridge throttles; Muster waits RetryAfter.
	ClassRateLimited = Class(ErrorClassRateLimited)
	// ClassGone means the message, its Thread or the event no longer exists.
	ClassGone = Class(ErrorClassGone)
	// ClassForbidden means the channel does not exist, the bot lacks the rights there, or a person blocked the bot.
	ClassForbidden = Class(ErrorClassForbidden)
	// ClassInvalid means the request cannot succeed as sent.
	ClassInvalid = Class(ErrorClassInvalid)
)

The classes of the protocol, the values of ErrorClass in api/bridge.yaml.

View Source
const ReasonEditWindowClosed = ProblemReasonEditWindowClosed

ReasonEditWindowClosed tells Muster that an edit failed as gone because the messenger allows no edits to a message that old, while the message itself still exists; Muster then posts a new Root message.

Variables

View Source
var (
	// ErrUnknownBot is the error of a report or of SetPush for a bot that is not in Config.Bots.
	ErrUnknownBot = errors.New("bridge: no bot of that name")
	// ErrClosed is the error of SetPush after Close.
	ErrClosed = errors.New("bridge: closed")
)
View Source
var (
	// ErrNotOffered is the error of a report whose event needs a feature the bridge does not offer.
	ErrNotOffered = errors.New("bridge: the bridge does not offer the feature this event needs")
	// ErrInvalidEvent is the error of a report whose event does not match api/bridge.yaml.
	ErrInvalidEvent = errors.New("bridge: the event does not match api/bridge.yaml")
)
View Source
var ErrUnknownCursor = errors.New("bridge: unknown cursor")

ErrUnknownCursor is the error of Store.EventsAfter for a cursor the store did not give for the bot.

Functions

func IdempotencyKeyFrom

func IdempotencyKeyFrom(ctx context.Context) string

IdempotencyKeyFrom is the Idempotency-Key of the request that a Messenger call serves, "" for an operation without one, so that a messenger with an idempotency mechanism of its own can be given it. Keys are per bot.

func ReplaceMentions

func ReplaceMentions(body string, mentions []Mention, render func(Mention) string) string

ReplaceMentions replaces each Mention marker [@name](muster-mention:ref) of body whose ref names an entry of mentions by what render makes of that entry: the messenger's own mention for a bridge with native_mentions, or the plain name. A marker whose ref names no entry, or every marker when render is nil, becomes its link text, the @name as the body escapes it. The rest of body is unchanged.

Example
package main

import (
	"fmt"

	bridge "github.com/muster-io/muster-bridge-go"
)

func main() {
	body := `[@Alice Smith](muster-mention:a1) acknowledged; [@Bob \[ops\]](muster-mention:b2) is next.`
	mentions := []bridge.Mention{{Ref: "a1", ExternalId: "U1", DisplayName: "Alice Smith"}}

	// A bridge with native_mentions writes the messenger's own mention.
	fmt.Println(bridge.ReplaceMentions(body, mentions, func(m bridge.Mention) string { return "@" + m.ExternalId }))
}
Output:
@U1 acknowledged; @Bob \[ops\] is next.

Types

type AccountLinkEvent

type AccountLinkEvent struct {
	// Id Stable across retries and polls; Muster handles each id once. Printable ASCII without spaces, so that it can travel as the `webhook-id` header.
	Id string `json:"id"`

	// OccurredAt When it happened in the messenger, or when the bridge learned of it.
	OccurredAt time.Time   `json:"occurred_at"`
	Token      string      `json:"token"`
	Type       interface{} `json:"type"`
	User       Person      `json:"user"`
}

AccountLinkEvent defines model for AccountLinkEvent.

type AnswerEventJSONRequestBody

type AnswerEventJSONRequestBody = AnswerInput

AnswerEventJSONRequestBody defines body for AnswerEvent for application/json ContentType.

type AnswerEventParams

type AnswerEventParams struct {
	// IdempotencyKey Identifies the request across retries; a repeated key with the same token returns the first result without creating anything again. Keys are scoped per token.
	IdempotencyKey IdempotencyKey `json:"Idempotency-Key"`
}

AnswerEventParams defines parameters for AnswerEvent.

type AnswerInput

type AnswerInput struct {
	EventId string `json:"event_id"`

	// Text Plain text; a pop-up may shorten it.
	Text string `json:"text"`
}

AnswerInput defines model for AnswerInput.

type AnswerShown

type AnswerShown struct {
	Replayed bool `json:"replayed"`

	// ShownAs Where the person sees the answer.
	ShownAs AnswerShownShownAs `json:"shown_as"`
}

AnswerShown defines model for AnswerShown.

type AnswerShownShownAs

type AnswerShownShownAs string

AnswerShownShownAs Where the person sees the answer.

const (
	AnswerShownShownAsDirect    AnswerShownShownAs = "direct"
	AnswerShownShownAsEphemeral AnswerShownShownAs = "ephemeral"
	AnswerShownShownAsPopup     AnswerShownShownAs = "popup"
	AnswerShownShownAsThread    AnswerShownShownAs = "thread"
)

Defines values for AnswerShownShownAs.

func (AnswerShownShownAs) Valid

func (e AnswerShownShownAs) Valid() bool

Valid indicates whether the value is a known member of the AnswerShownShownAs enum.

type Bot

type Bot struct {
	// Name names the bot in the Messenger calls and the reports, such as "main".
	Name string
	// Token is the token Muster sends for this bot, as saved on its Bridge Connection.
	Token string
	// Push is where the bot's events are pushed; nil for none.
	Push *PushTarget
}

Bot is one bot of the bridge.

type Bridge

type Bridge struct {
	// contains filtered or unexported fields
}

Bridge serves the Bridge protocol for the bots of its Config and calls the Messenger.

func New

func New(cfg Config, m Messenger) (*Bridge, error)

New checks cfg and returns the bridge that serves it with m. It names every problem it finds: a bot without a name or a token of its own, a feature whose interface m lacks, a push target that is not valid, and the info that cfg makes when it does not match api/bridge.yaml. Bots with a push target start pushing at once.

func (*Bridge) Close

func (b *Bridge) Close(ctx context.Context) error

Close stops the pushers and waits for them until ctx ends. The handler and the reports keep working; no event is pushed any more.

func (*Bridge) DirectMessage

func (b *Bridge) DirectMessage(ctx context.Context, bot string, m DirectMessage) error

DirectMessage reports a direct message to the bot. A message whose whole text, trimmed and after an optional "/start ", is 43 characters of base64url is an Account link token and becomes an account_link event; any other is ignored, and DirectMessage returns nil. Every bridge takes Account link tokens.

Example

A direct message is an Account link token when its whole text is one, after an optional /start; any other is ignored.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"

	bridge "github.com/muster-io/muster-bridge-go"
)

// notebook is a messenger that keeps the bot's messages in memory: a whole Messenger in a few lines.
type notebook struct {
	mu       sync.Mutex
	messages []bridge.MessageInput
}

func (n *notebook) PostMessage(_ context.Context, _ string, in bridge.MessageInput) (bridge.MessagePosted, error) {
	if in.ChannelId != "alerts" {
		return bridge.MessagePosted{}, bridge.ForbiddenError("The bot has not joined this channel.")
	}
	n.mu.Lock()
	defer n.mu.Unlock()
	n.messages = append(n.messages, in)
	return bridge.MessagePosted{MessageId: "n" + strconv.Itoa(len(n.messages))}, nil
}

func (n *notebook) EditMessage(_ context.Context, _, id string, in bridge.MessageContent) (bridge.MessageEdited,
	error) {
	n.mu.Lock()
	defer n.mu.Unlock()
	i, err := strconv.Atoi(strings.TrimPrefix(id, "n"))
	if err != nil || i < 1 || i > len(n.messages) {
		return bridge.MessageEdited{}, bridge.GoneError("That message is gone.")
	}
	n.messages[i-1].Title, n.messages[i-1].Body = in.Title, in.Body
	return bridge.MessageEdited{}, nil
}

func (n *notebook) ReplyInThread(_ context.Context, _, id string, _ bridge.ThreadReplyInput) (bridge.ThreadReplyPosted,
	error) {
	return bridge.ThreadReplyPosted{MessageId: id + "-reply", ThreadRef: id}, nil
}

func (n *notebook) CheckChannel(_ context.Context, _, channelID string) (string, error) {
	if channelID != "alerts" {
		return "", bridge.ForbiddenError("The bot has not joined this channel.")
	}
	return "Alerts", nil
}

func (n *notebook) AnswerEvent(context.Context, string, bridge.ReportedEvent, string) (bridge.AnswerShownShownAs,
	error) {
	return bridge.AnswerShownShownAsThread, nil
}

// newNotebookBridge is a bridge with one bot, main, whose token is example-token.
func newNotebookBridge(m bridge.Messenger) *bridge.Bridge {
	b, err := bridge.New(bridge.Config{
		Name:          "notebook-bridge",
		Version:       "1.0.0",
		MessengerName: "Notebook",
		Bots:          []bridge.Bot{{Name: "main", Token: "example-token"}},
		Limits:        bridge.Limits{MaxBodyLength: 4000},
		Capabilities: bridge.Capabilities{Buttons: &bridge.Capabilities_Buttons{MaxPerMessage: 5, MaxPerRow: 5,
			DataMaxBytes: 64, Styles: []bridge.ButtonStyle{bridge.ButtonStylePrimary}}},
		Logger: slog.New(slog.DiscardHandler),
	}, m)
	if err != nil {
		panic(err)
	}
	return b
}

// get is the answer of the handler to a GET of path with the token, as Muster sends it.
func get(h http.Handler, path, token string) *http.Response {
	r := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)
	if token != "" {
		r.Header.Set("Authorization", "Bearer "+token)
	}
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)
	return w.Result()
}

// pollTypes polls the events of the bot main and returns their types.
func pollTypes(b *bridge.Bridge) []string {
	res := get(b.Handler(), "/v1/events?wait=0", "example-token")
	defer res.Body.Close()
	var batch struct {
		Events []struct {
			Type      string `json:"type"`
			MessageID string `json:"message_id"`
		} `json:"events"`
	}
	if err := json.NewDecoder(res.Body).Decode(&batch); err != nil {
		panic(err)
	}
	types := []string{}
	for _, ev := range batch.Events {
		types = append(types, strings.TrimSuffix(ev.Type+" "+ev.MessageID, " "))
	}
	return types
}

func main() {
	b := newNotebookBridge(&notebook{})
	defer func() { _ = b.Close(context.Background()) }()

	alice := bridge.Person{ExternalId: "U1", DisplayName: "Alice Smith"}
	for _, text := range []string{"/start " + strings.Repeat("A", 43), "hello"} {
		if err := b.DirectMessage(context.Background(), "main", bridge.DirectMessage{User: alice, Text: text}); err != nil {
			panic(err)
		}
	}
	fmt.Println(pollTypes(b))
}
Output:
[account_link]

func (*Bridge) Handler

func (b *Bridge) Handler() http.Handler

Handler serves the protocol: every operation under /v1/. Mount it at the bridge's base URL, under a path prefix with http.StripPrefix.

Example

The handler mounted under a path prefix: Muster's Bridge Connection then has the base URL https://bridge.example.org/bridge.

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"

	bridge "github.com/muster-io/muster-bridge-go"
)

// notebook is a messenger that keeps the bot's messages in memory: a whole Messenger in a few lines.
type notebook struct {
	mu       sync.Mutex
	messages []bridge.MessageInput
}

func (n *notebook) PostMessage(_ context.Context, _ string, in bridge.MessageInput) (bridge.MessagePosted, error) {
	if in.ChannelId != "alerts" {
		return bridge.MessagePosted{}, bridge.ForbiddenError("The bot has not joined this channel.")
	}
	n.mu.Lock()
	defer n.mu.Unlock()
	n.messages = append(n.messages, in)
	return bridge.MessagePosted{MessageId: "n" + strconv.Itoa(len(n.messages))}, nil
}

func (n *notebook) EditMessage(_ context.Context, _, id string, in bridge.MessageContent) (bridge.MessageEdited,
	error) {
	n.mu.Lock()
	defer n.mu.Unlock()
	i, err := strconv.Atoi(strings.TrimPrefix(id, "n"))
	if err != nil || i < 1 || i > len(n.messages) {
		return bridge.MessageEdited{}, bridge.GoneError("That message is gone.")
	}
	n.messages[i-1].Title, n.messages[i-1].Body = in.Title, in.Body
	return bridge.MessageEdited{}, nil
}

func (n *notebook) ReplyInThread(_ context.Context, _, id string, _ bridge.ThreadReplyInput) (bridge.ThreadReplyPosted,
	error) {
	return bridge.ThreadReplyPosted{MessageId: id + "-reply", ThreadRef: id}, nil
}

func (n *notebook) CheckChannel(_ context.Context, _, channelID string) (string, error) {
	if channelID != "alerts" {
		return "", bridge.ForbiddenError("The bot has not joined this channel.")
	}
	return "Alerts", nil
}

func (n *notebook) AnswerEvent(context.Context, string, bridge.ReportedEvent, string) (bridge.AnswerShownShownAs,
	error) {
	return bridge.AnswerShownShownAsThread, nil
}

// newNotebookBridge is a bridge with one bot, main, whose token is example-token.
func newNotebookBridge(m bridge.Messenger) *bridge.Bridge {
	b, err := bridge.New(bridge.Config{
		Name:          "notebook-bridge",
		Version:       "1.0.0",
		MessengerName: "Notebook",
		Bots:          []bridge.Bot{{Name: "main", Token: "example-token"}},
		Limits:        bridge.Limits{MaxBodyLength: 4000},
		Capabilities: bridge.Capabilities{Buttons: &bridge.Capabilities_Buttons{MaxPerMessage: 5, MaxPerRow: 5,
			DataMaxBytes: 64, Styles: []bridge.ButtonStyle{bridge.ButtonStylePrimary}}},
		Logger: slog.New(slog.DiscardHandler),
	}, m)
	if err != nil {
		panic(err)
	}
	return b
}

// get is the answer of the handler to a GET of path with the token, as Muster sends it.
func get(h http.Handler, path, token string) *http.Response {
	r := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)
	if token != "" {
		r.Header.Set("Authorization", "Bearer "+token)
	}
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)
	return w.Result()
}

func main() {
	b := newNotebookBridge(&notebook{})
	defer func() { _ = b.Close(context.Background()) }()

	mux := http.NewServeMux()
	mux.Handle("/bridge/", http.StripPrefix("/bridge", b.Handler()))

	for _, token := range []string{"example-token", "wrong-token"} {
		res := get(mux, "/bridge/v1/info", token)
		_ = res.Body.Close()
		fmt.Println(token, res.StatusCode)
	}
}
Output:
example-token 200
wrong-token 401

func (*Bridge) MessageDeleted

func (b *Bridge) MessageDeleted(ctx context.Context, bot string, m MessageDeleted) error

MessageDeleted reports the deletion of a message of the bot. The bridge must offer deletion_events.

func (*Bridge) Press

func (b *Bridge) Press(ctx context.Context, bot string, p Press) error

Press reports a press. It returns once the event is queued; pushes happen in the background, so a bridge confirms the press to its messenger within its deadline independently of Muster. The bridge must offer buttons.

Example

A press is reported once the messenger has told the bridge about it; Muster then polls it, or the bridge pushes it.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"

	bridge "github.com/muster-io/muster-bridge-go"
)

// notebook is a messenger that keeps the bot's messages in memory: a whole Messenger in a few lines.
type notebook struct {
	mu       sync.Mutex
	messages []bridge.MessageInput
}

func (n *notebook) PostMessage(_ context.Context, _ string, in bridge.MessageInput) (bridge.MessagePosted, error) {
	if in.ChannelId != "alerts" {
		return bridge.MessagePosted{}, bridge.ForbiddenError("The bot has not joined this channel.")
	}
	n.mu.Lock()
	defer n.mu.Unlock()
	n.messages = append(n.messages, in)
	return bridge.MessagePosted{MessageId: "n" + strconv.Itoa(len(n.messages))}, nil
}

func (n *notebook) EditMessage(_ context.Context, _, id string, in bridge.MessageContent) (bridge.MessageEdited,
	error) {
	n.mu.Lock()
	defer n.mu.Unlock()
	i, err := strconv.Atoi(strings.TrimPrefix(id, "n"))
	if err != nil || i < 1 || i > len(n.messages) {
		return bridge.MessageEdited{}, bridge.GoneError("That message is gone.")
	}
	n.messages[i-1].Title, n.messages[i-1].Body = in.Title, in.Body
	return bridge.MessageEdited{}, nil
}

func (n *notebook) ReplyInThread(_ context.Context, _, id string, _ bridge.ThreadReplyInput) (bridge.ThreadReplyPosted,
	error) {
	return bridge.ThreadReplyPosted{MessageId: id + "-reply", ThreadRef: id}, nil
}

func (n *notebook) CheckChannel(_ context.Context, _, channelID string) (string, error) {
	if channelID != "alerts" {
		return "", bridge.ForbiddenError("The bot has not joined this channel.")
	}
	return "Alerts", nil
}

func (n *notebook) AnswerEvent(context.Context, string, bridge.ReportedEvent, string) (bridge.AnswerShownShownAs,
	error) {
	return bridge.AnswerShownShownAsThread, nil
}

// newNotebookBridge is a bridge with one bot, main, whose token is example-token.
func newNotebookBridge(m bridge.Messenger) *bridge.Bridge {
	b, err := bridge.New(bridge.Config{
		Name:          "notebook-bridge",
		Version:       "1.0.0",
		MessengerName: "Notebook",
		Bots:          []bridge.Bot{{Name: "main", Token: "example-token"}},
		Limits:        bridge.Limits{MaxBodyLength: 4000},
		Capabilities: bridge.Capabilities{Buttons: &bridge.Capabilities_Buttons{MaxPerMessage: 5, MaxPerRow: 5,
			DataMaxBytes: 64, Styles: []bridge.ButtonStyle{bridge.ButtonStylePrimary}}},
		Logger: slog.New(slog.DiscardHandler),
	}, m)
	if err != nil {
		panic(err)
	}
	return b
}

// get is the answer of the handler to a GET of path with the token, as Muster sends it.
func get(h http.Handler, path, token string) *http.Response {
	r := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)
	if token != "" {
		r.Header.Set("Authorization", "Bearer "+token)
	}
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)
	return w.Result()
}

// pollTypes polls the events of the bot main and returns their types.
func pollTypes(b *bridge.Bridge) []string {
	res := get(b.Handler(), "/v1/events?wait=0", "example-token")
	defer res.Body.Close()
	var batch struct {
		Events []struct {
			Type      string `json:"type"`
			MessageID string `json:"message_id"`
		} `json:"events"`
	}
	if err := json.NewDecoder(res.Body).Decode(&batch); err != nil {
		panic(err)
	}
	types := []string{}
	for _, ev := range batch.Events {
		types = append(types, strings.TrimSuffix(ev.Type+" "+ev.MessageID, " "))
	}
	return types
}

func main() {
	b := newNotebookBridge(&notebook{})
	defer func() { _ = b.Close(context.Background()) }()

	alice := bridge.Person{ExternalId: "U1", DisplayName: "Alice Smith"}
	press := bridge.Press{UpdateID: "callback-17", MessageID: "n1", User: alice, Data: "ack"}
	for range 2 {
		// The messenger delivered the update twice; it is queued once.
		if err := b.Press(context.Background(), "main", press); err != nil {
			panic(err)
		}
	}
	fmt.Println(pollTypes(b))
}
Output:
[press n1]

func (*Bridge) SetPush

func (b *Bridge) SetPush(bot string, target *PushTarget) error

SetPush sets where the events of the bot are pushed; nil stops pushing. A pusher that this call starts begins with the oldest event kept; a pusher that already runs continues where it was, now towards the new target.

func (*Bridge) ThreadMessage

func (b *Bridge) ThreadMessage(ctx context.Context, bot string, m ThreadMessage) error

ThreadMessage reports a person's message in a Thread. The bridge must offer thread_messages.

type BridgeEventJSONRequestBody

type BridgeEventJSONRequestBody = Event

BridgeEventJSONRequestBody defines body for BridgeEvent for application/json ContentType.

type BridgeEventParams

type BridgeEventParams struct {
	// WebhookId The event's `id`, kept across retries.
	WebhookId string `json:"webhook-id"`

	// WebhookTimestamp Unix time of this attempt, in seconds.
	WebhookTimestamp string `json:"webhook-timestamp"`

	// WebhookSignature `v1,<base64 HMAC-SHA256>`; several signatures separated by spaces are allowed while a secret is rotated.
	WebhookSignature string `json:"webhook-signature"`
}

BridgeEventParams defines parameters for BridgeEvent.

type Button

type Button struct {
	// Data Opaque, signed by Muster; return it unchanged with the press.
	Data  string `json:"data"`
	Label string `json:"label"`

	// Style A hint; the bridge maps it to the closest style its messenger has, or shows a plain button.
	Style ButtonStyle `json:"style"`
}

Button defines model for Button.

type ButtonStyle

type ButtonStyle string

ButtonStyle A hint; the bridge maps it to the closest style its messenger has, or shows a plain button.

const (
	ButtonStyleDanger  ButtonStyle = "danger"
	ButtonStyleNeutral ButtonStyle = "neutral"
	ButtonStylePrimary ButtonStyle = "primary"
	ButtonStyleSuccess ButtonStyle = "success"
)

Defines values for ButtonStyle.

func (ButtonStyle) Valid

func (e ButtonStyle) Valid() bool

Valid indicates whether the value is a known member of the ButtonStyle enum.

type Capabilities

type Capabilities struct {
	// Buttons The bridge shows buttons and reports presses on them.
	Buttons *Capabilities_Buttons `json:"buttons,omitempty"`

	// Delete The bot can delete its own messages (unused by Muster L1).
	Delete *Capabilities_Delete `json:"delete,omitempty"`

	// DeletionEvents The bridge reports `message_deleted` when a message of the bot is deleted in the messenger.
	DeletionEvents *bool `json:"deletion_events,omitempty"`

	// DirectMessages The bot can send direct messages, which Account links by code need.
	DirectMessages *Capabilities_DirectMessages `json:"direct_messages,omitempty"`

	// NativeMentions Mention markers become the messenger's own mentions, which notify the person.
	NativeMentions *bool `json:"native_mentions,omitempty"`

	// PrivateAnswers How the bridge can show an answer to one person only: an ephemeral message, a pop-up (short plain text), or both.
	PrivateAnswers *[]CapabilitiesPrivateAnswers `json:"private_answers,omitempty"`

	// QuietMessages The messenger can post a new message without notifying anyone (`notify` false).
	QuietMessages *bool `json:"quiet_messages,omitempty"`

	// StatusColor The messenger can colour a message by `color`; otherwise the bridge shows `emoji`.
	StatusColor *bool `json:"status_color,omitempty"`

	// ThreadMessages The bridge reports people's messages in Threads of the bot's messages — every one, or only those that mention the bot. Text commands need it.
	ThreadMessages *CapabilitiesThreadMessages `json:"thread_messages,omitempty"`
}

Capabilities The optional features of the bridge. A missing member means the feature is not offered.

type CapabilitiesPrivateAnswers

type CapabilitiesPrivateAnswers string

CapabilitiesPrivateAnswers defines model for Capabilities.PrivateAnswers.

const (
	CapabilitiesPrivateAnswersEphemeral CapabilitiesPrivateAnswers = "ephemeral"
	CapabilitiesPrivateAnswersPopup     CapabilitiesPrivateAnswers = "popup"
)

Defines values for CapabilitiesPrivateAnswers.

func (CapabilitiesPrivateAnswers) Valid

func (e CapabilitiesPrivateAnswers) Valid() bool

Valid indicates whether the value is a known member of the CapabilitiesPrivateAnswers enum.

type CapabilitiesThreadMessages

type CapabilitiesThreadMessages string

CapabilitiesThreadMessages The bridge reports people's messages in Threads of the bot's messages — every one, or only those that mention the bot. Text commands need it.

const (
	CapabilitiesThreadMessagesAll          CapabilitiesThreadMessages = "all"
	CapabilitiesThreadMessagesMentionsOnly CapabilitiesThreadMessages = "mentions_only"
)

Defines values for CapabilitiesThreadMessages.

func (CapabilitiesThreadMessages) Valid

func (e CapabilitiesThreadMessages) Valid() bool

Valid indicates whether the value is a known member of the CapabilitiesThreadMessages enum.

type Capabilities_Buttons

type Capabilities_Buttons struct {
	DataMaxBytes int `json:"data_max_bytes"`

	// MaxPerMessage Muster sends at most this many, dropping Snooze buttons first: the Snooze presets, then the durations from the longest.
	MaxPerMessage int `json:"max_per_message"`
	MaxPerRow     int `json:"max_per_row"`

	// Styles The style hints the messenger can show as such; the bridge maps the others to the closest one, or shows a plain button.
	Styles []ButtonStyle `json:"styles"`
}

Capabilities_Buttons The bridge shows buttons and reports presses on them.

type Capabilities_Delete

type Capabilities_Delete struct {
	MaxAgeSeconds *int `json:"max_age_seconds"`
}

Capabilities_Delete The bot can delete its own messages (unused by Muster L1).

type Capabilities_DirectMessages

type Capabilities_DirectMessages struct {
	// BotCanStart Whether the bot can write to a person who never wrote to it.
	BotCanStart bool `json:"bot_can_start"`
}

Capabilities_DirectMessages The bot can send direct messages, which Account links by code need.

type ChannelCheck

type ChannelCheck struct {
	ChannelId string `json:"channel_id"`

	// Name The channel's name as people see it; Muster shows it on the Destination.
	Name string `json:"name"`
}

ChannelCheck defines model for ChannelCheck.

type ChannelId

type ChannelId = string

ChannelId defines model for ChannelId.

type Class

type Class string

Class is the class of an error, which tells Muster what to do about it.

type Config

type Config struct {
	// Name and Version are the bridge's, as info shows them: 1 to 100 characters each.
	Name    string
	Version string
	// MessengerName is the messenger's name as people know it: 1 to 100 characters.
	MessengerName string
	// Bots are the bots the bridge serves, at least one; each is a Bridge Connection in Muster.
	Bots []Bot
	// Capabilities are the optional features; a nil member is a feature the bridge does not offer.
	Capabilities Capabilities
	// Limits are the bridge's limits; MaxBodyLength from 500 to 100000.
	Limits Limits
	// UserLookup is what findUsers searches by; "" is InfoUserLookupNone.
	UserLookup InfoUserLookup
	// EventDelivery are the event delivery modes the bridge offers; nil is both, poll and push.
	EventDelivery []InfoEventDelivery
	// LinkURLTemplate is an http or https link with {token}, which opens the bot with an Account link token; "" for none.
	LinkURLTemplate string
	// Store keeps the Idempotency-Keys and the events; nil is NewMemoryStore().
	Store Store
	// Logger receives the library's logs; nil is slog.Default().
	Logger *slog.Logger
	// contains filtered or unexported fields
}

Config is a bridge: what it tells Muster in info, its bots and where it keeps its state.

type Deleter

type Deleter interface {
	// DeleteMessage deletes a message of the bot. A message that no longer exists is a success, also when this
	// returns a gone *Error.
	DeleteMessage(ctx context.Context, bot, messageID string) error
}

Deleter is the Messenger's deletion of the bot's messages, which Capabilities.Delete needs.

type DirectMessage

type DirectMessage struct {
	// UpdateID is the messenger's own id of the update, "" when it has none.
	UpdateID string
	// User is the person who wrote.
	User Person
	// Text is the whole text the person sent.
	Text string
	// At is when it happened; zero is now.
	At time.Time
	// Chat is the direct chat, so that AnswerEvent answers there.
	Chat string
}

DirectMessage is a private message from a person to the bot.

type DirectMessageInput

type DirectMessageInput struct {
	// Body CommonMark.
	Body           string `json:"body"`
	UserExternalId string `json:"user_external_id"`
}

DirectMessageInput defines model for DirectMessageInput.

type DirectMessageSent

type DirectMessageSent struct {
	MessageId string `json:"message_id"`
	Replayed  bool   `json:"replayed"`
}

DirectMessageSent defines model for DirectMessageSent.

type DirectMessenger

type DirectMessenger interface {
	// SendDirectMessage sends a direct message from the bot, opening the direct chat if needed.
	SendDirectMessage(ctx context.Context, bot string, in DirectMessageInput) (DirectMessageSent, error)
}

DirectMessenger is the Messenger's direct messages, which Capabilities.DirectMessages needs.

type EditMessageJSONRequestBody

type EditMessageJSONRequestBody = MessageContent

EditMessageJSONRequestBody defines body for EditMessage for application/json ContentType.

type Error

type Error struct {
	// Class is the class Muster acts on.
	Class Class
	// Detail is shown to the Admin as the reason, as untrusted text. It must not carry a secret; the library replaces
	// the bridge's tokens and push secrets in it anyway, and cuts it to 1000 characters.
	Detail string
	// RetryAfter is how long Muster waits, for ClassRateLimited; it is sent in whole seconds, rounded up.
	RetryAfter time.Duration
	// Scope is what the wait of ClassRateLimited holds: ProblemScopeChannel (the default) or ProblemScopeBot.
	Scope ProblemScope
	// Reason is ReasonEditWindowClosed on a ClassGone answer to an edit when the message is too old to edit; empty
	// otherwise. The library sends no other reason, and logs one as a mistake of the bridge.
	Reason ProblemReason
}

Error is an answer of the messenger that the bridge classifies for Muster. A Messenger method returns one to answer its class; any other error is answered as transient, with a generic detail.

func ForbiddenError

func ForbiddenError(detail string) *Error

ForbiddenError says that the channel does not exist, the bot lacks the rights there, or the person blocked the bot. A channel that does not exist is forbidden, never gone.

func GoneError

func GoneError(detail string, reason ...ProblemReason) *Error

GoneError says that the message or its Thread no longer exists. With ReasonEditWindowClosed it says instead that the message is past the messenger's edit window. Only the first reason is used.

Example

A Messenger answers a class of the protocol with an *Error; Muster then posts a new Root message instead of editing.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"

	bridge "github.com/muster-io/muster-bridge-go"
)

// notebook is a messenger that keeps the bot's messages in memory: a whole Messenger in a few lines.
type notebook struct {
	mu       sync.Mutex
	messages []bridge.MessageInput
}

func (n *notebook) PostMessage(_ context.Context, _ string, in bridge.MessageInput) (bridge.MessagePosted, error) {
	if in.ChannelId != "alerts" {
		return bridge.MessagePosted{}, bridge.ForbiddenError("The bot has not joined this channel.")
	}
	n.mu.Lock()
	defer n.mu.Unlock()
	n.messages = append(n.messages, in)
	return bridge.MessagePosted{MessageId: "n" + strconv.Itoa(len(n.messages))}, nil
}

func (n *notebook) EditMessage(_ context.Context, _, id string, in bridge.MessageContent) (bridge.MessageEdited,
	error) {
	n.mu.Lock()
	defer n.mu.Unlock()
	i, err := strconv.Atoi(strings.TrimPrefix(id, "n"))
	if err != nil || i < 1 || i > len(n.messages) {
		return bridge.MessageEdited{}, bridge.GoneError("That message is gone.")
	}
	n.messages[i-1].Title, n.messages[i-1].Body = in.Title, in.Body
	return bridge.MessageEdited{}, nil
}

func (n *notebook) ReplyInThread(_ context.Context, _, id string, _ bridge.ThreadReplyInput) (bridge.ThreadReplyPosted,
	error) {
	return bridge.ThreadReplyPosted{MessageId: id + "-reply", ThreadRef: id}, nil
}

func (n *notebook) CheckChannel(_ context.Context, _, channelID string) (string, error) {
	if channelID != "alerts" {
		return "", bridge.ForbiddenError("The bot has not joined this channel.")
	}
	return "Alerts", nil
}

func (n *notebook) AnswerEvent(context.Context, string, bridge.ReportedEvent, string) (bridge.AnswerShownShownAs,
	error) {
	return bridge.AnswerShownShownAsThread, nil
}

// newNotebookBridge is a bridge with one bot, main, whose token is example-token.
func newNotebookBridge(m bridge.Messenger) *bridge.Bridge {
	b, err := bridge.New(bridge.Config{
		Name:          "notebook-bridge",
		Version:       "1.0.0",
		MessengerName: "Notebook",
		Bots:          []bridge.Bot{{Name: "main", Token: "example-token"}},
		Limits:        bridge.Limits{MaxBodyLength: 4000},
		Capabilities: bridge.Capabilities{Buttons: &bridge.Capabilities_Buttons{MaxPerMessage: 5, MaxPerRow: 5,
			DataMaxBytes: 64, Styles: []bridge.ButtonStyle{bridge.ButtonStylePrimary}}},
		Logger: slog.New(slog.DiscardHandler),
	}, m)
	if err != nil {
		panic(err)
	}
	return b
}

// inkNotebook refuses every edit, as a messenger does for messages older than its edit window.
type inkNotebook struct{ *notebook }

func (inkNotebook) EditMessage(context.Context, string, string, bridge.MessageContent) (bridge.MessageEdited, error) {
	return bridge.MessageEdited{}, bridge.GoneError("Messages older than 48 hours cannot be edited.",
		bridge.ReasonEditWindowClosed)
}

func main() {
	b := newNotebookBridge(inkNotebook{&notebook{}})
	defer func() { _ = b.Close(context.Background()) }()

	body := `{"status":"acknowledged","color":"#f9a825","emoji":"👀","title":"Disk full","body":"",` +
		`"link":{"url":"https://muster.example.org/g/1","label":"Muster"},"buttons":[]}`
	r := httptest.NewRequestWithContext(context.Background(), http.MethodPatch, "/v1/messages/n1",
		strings.NewReader(body))
	r.Header.Set("Authorization", "Bearer example-token")
	r.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	b.Handler().ServeHTTP(w, r)

	var p bridge.Problem
	if err := json.NewDecoder(w.Body).Decode(&p); err != nil {
		panic(err)
	}
	fmt.Println(w.Code, *p.Class, *p.Reason, *p.Detail)
}
Output:
404 gone edit_window_closed Messages older than 48 hours cannot be edited.

func InvalidError

func InvalidError(detail string) *Error

InvalidError says that the request cannot succeed as sent: too long, malformed, an unknown id.

func RateLimitedError

func RateLimitedError(wait time.Duration, scope ProblemScope, detail string) *Error

RateLimitedError asks Muster to wait before the next request, for the channel or, with ProblemScopeBot, for every channel of the bot. An empty scope is ProblemScopeChannel.

func TransientError

func TransientError(detail string) *Error

TransientError is a temporary failure: the messenger is down, a timeout. Muster retries.

func (*Error) Error

func (e *Error) Error() string

Error returns the class and the detail.

type ErrorClass

type ErrorClass string

ErrorClass defines model for ErrorClass.

const (
	ErrorClassForbidden   ErrorClass = "forbidden"
	ErrorClassGone        ErrorClass = "gone"
	ErrorClassInvalid     ErrorClass = "invalid"
	ErrorClassRateLimited ErrorClass = "rate_limited"
	ErrorClassTransient   ErrorClass = "transient"
)

Defines values for ErrorClass.

func (ErrorClass) Valid

func (e ErrorClass) Valid() bool

Valid indicates whether the value is a known member of the ErrorClass enum.

type Event

type Event struct {
	// contains filtered or unexported fields
}

Event One event; `type` selects the variant. `account_link` events are always sent; the others only with the feature they belong to (`buttons`, `thread_messages`, `deletion_events`).

func (Event) AsAccountLinkEvent

func (t Event) AsAccountLinkEvent() (AccountLinkEvent, error)

AsAccountLinkEvent returns the union data inside the Event as a AccountLinkEvent

func (Event) AsMessageDeletedEvent

func (t Event) AsMessageDeletedEvent() (MessageDeletedEvent, error)

AsMessageDeletedEvent returns the union data inside the Event as a MessageDeletedEvent

func (Event) AsPressEvent

func (t Event) AsPressEvent() (PressEvent, error)

AsPressEvent returns the union data inside the Event as a PressEvent

func (Event) AsThreadMessageEvent

func (t Event) AsThreadMessageEvent() (ThreadMessageEvent, error)

AsThreadMessageEvent returns the union data inside the Event as a ThreadMessageEvent

func (Event) Discriminator

func (t Event) Discriminator() (string, error)

func (*Event) FromAccountLinkEvent

func (t *Event) FromAccountLinkEvent(v AccountLinkEvent) error

FromAccountLinkEvent overwrites any union data inside the Event as the provided AccountLinkEvent

func (*Event) FromMessageDeletedEvent

func (t *Event) FromMessageDeletedEvent(v MessageDeletedEvent) error

FromMessageDeletedEvent overwrites any union data inside the Event as the provided MessageDeletedEvent

func (*Event) FromPressEvent

func (t *Event) FromPressEvent(v PressEvent) error

FromPressEvent overwrites any union data inside the Event as the provided PressEvent

func (*Event) FromThreadMessageEvent

func (t *Event) FromThreadMessageEvent(v ThreadMessageEvent) error

FromThreadMessageEvent overwrites any union data inside the Event as the provided ThreadMessageEvent

func (Event) MarshalJSON

func (t Event) MarshalJSON() ([]byte, error)

func (*Event) MergeAccountLinkEvent

func (t *Event) MergeAccountLinkEvent(v AccountLinkEvent) error

MergeAccountLinkEvent performs a merge with any union data inside the Event, using the provided AccountLinkEvent

func (*Event) MergeMessageDeletedEvent

func (t *Event) MergeMessageDeletedEvent(v MessageDeletedEvent) error

MergeMessageDeletedEvent performs a merge with any union data inside the Event, using the provided MessageDeletedEvent

func (*Event) MergePressEvent

func (t *Event) MergePressEvent(v PressEvent) error

MergePressEvent performs a merge with any union data inside the Event, using the provided PressEvent

func (*Event) MergeThreadMessageEvent

func (t *Event) MergeThreadMessageEvent(v ThreadMessageEvent) error

MergeThreadMessageEvent performs a merge with any union data inside the Event, using the provided ThreadMessageEvent

func (*Event) UnmarshalJSON

func (t *Event) UnmarshalJSON(b []byte) error

func (Event) ValueByDiscriminator

func (t Event) ValueByDiscriminator() (interface{}, error)

type EventBase

type EventBase struct {
	// Id Stable across retries and polls; Muster handles each id once. Printable ASCII without spaces, so that it can travel as the `webhook-id` header.
	Id string `json:"id"`

	// OccurredAt When it happened in the messenger, or when the bridge learned of it.
	OccurredAt time.Time `json:"occurred_at"`
	Type       string    `json:"type"`
}

EventBase defines model for EventBase.

type EventBatch

type EventBatch struct {
	Events []Event `json:"events"`

	// NextCursor Send it as `cursor` with the next call; opaque to Muster.
	NextCursor string `json:"next_cursor"`
}

EventBatch defines model for EventBatch.

type EventType

type EventType string

EventType is the type of an event the bridge reports.

const (
	EventTypePress          EventType = "press"
	EventTypeThreadMessage  EventType = "thread_message"
	EventTypeAccountLink    EventType = "account_link"
	EventTypeMessageDeleted EventType = "message_deleted"
)

The event types of the protocol.

type FindUsersParams

type FindUsersParams struct {
	// Handle The handle as the person entered it, in the kind `user_lookup` declares.
	Handle string `form:"handle" json:"handle"`
}

FindUsersParams defines parameters for FindUsers.

type Forbidden

type Forbidden = Problem

Forbidden RFC 9457 problem details with the Bridge protocol's members.

type Gone

type Gone = Problem

Gone RFC 9457 problem details with the Bridge protocol's members.

type IdempotencyKey

type IdempotencyKey = string

IdempotencyKey defines model for IdempotencyKey.

type Info

type Info struct {
	Bridge Info_Bridge `json:"bridge"`

	// Capabilities The optional features of the bridge. A missing member means the feature is not offered.
	Capabilities Capabilities `json:"capabilities"`

	// EventDelivery The event delivery modes the bridge offers; each Connection chooses one.
	EventDelivery []InfoEventDelivery `json:"event_delivery"`
	Limits        Limits              `json:"limits"`

	// LinkUrlTemplate An `http` or `https` link that opens a chat with the bot and hands it the Account link token, with `{token}` where the token goes, such as `https://chat.example.org/bots/muster?start={token}`. Muster shows the link next to the token in the profile; without one it shows only the token. Another scheme makes `info` fail the Connection check.
	LinkUrlTemplate *string        `json:"link_url_template,omitempty"`
	Messenger       Info_Messenger `json:"messenger"`

	// ProtocolVersion The major version of this protocol the bridge speaks.
	ProtocolVersion InfoProtocolVersion `json:"protocol_version"`

	// UserLookup What a person enters to be found for an Account link by code, and what `findUsers` searches by. `none` offers only the token sent to the bot.
	UserLookup InfoUserLookup `json:"user_lookup"`
}

Info defines model for Info.

type InfoEventDelivery

type InfoEventDelivery string

InfoEventDelivery defines model for Info.EventDelivery.

const (
	InfoEventDeliveryPoll InfoEventDelivery = "poll"
	InfoEventDeliveryPush InfoEventDelivery = "push"
)

Defines values for InfoEventDelivery.

func (InfoEventDelivery) Valid

func (e InfoEventDelivery) Valid() bool

Valid indicates whether the value is a known member of the InfoEventDelivery enum.

type InfoProtocolVersion

type InfoProtocolVersion string

InfoProtocolVersion The major version of this protocol the bridge speaks.

const (
	InfoProtocolVersionN1 InfoProtocolVersion = "1"
)

Defines values for InfoProtocolVersion.

func (InfoProtocolVersion) Valid

func (e InfoProtocolVersion) Valid() bool

Valid indicates whether the value is a known member of the InfoProtocolVersion enum.

type InfoUserLookup

type InfoUserLookup string

InfoUserLookup What a person enters to be found for an Account link by code, and what `findUsers` searches by. `none` offers only the token sent to the bot.

const (
	InfoUserLookupEmail    InfoUserLookup = "email"
	InfoUserLookupLogin    InfoUserLookup = "login"
	InfoUserLookupNone     InfoUserLookup = "none"
	InfoUserLookupUserId   InfoUserLookup = "user_id"
	InfoUserLookupUsername InfoUserLookup = "username"
)

Defines values for InfoUserLookup.

func (InfoUserLookup) Valid

func (e InfoUserLookup) Valid() bool

Valid indicates whether the value is a known member of the InfoUserLookup enum.

type Info_Bridge

type Info_Bridge struct {
	Name    string `json:"name"`
	Version string `json:"version"`
}

Info_Bridge defines model for Info.Bridge.

type Info_Messenger

type Info_Messenger struct {
	// Name The messenger's name as people know it; Muster shows it on the Connection and the Destinations.
	Name string `json:"name"`
}

Info_Messenger defines model for Info.Messenger.

type Invalid

type Invalid = Problem

Invalid RFC 9457 problem details with the Bridge protocol's members.

type KeyClaim

type KeyClaim struct {
	State KeyState
	// Answer is the stored answer, with KeyAnswered.
	Answer StoredAnswer
	// ID names the claim, with KeyClaimed; the library gives it back to SaveKey and ReleaseKey.
	ID string
}

KeyClaim is the result of Store.ClaimKey.

type KeyState

type KeyState int

KeyState is the outcome of Store.ClaimKey.

const (
	// KeyClaimed means the key was free and the caller runs the request.
	KeyClaimed KeyState = iota
	// KeyRunning means another request with the key is still running.
	KeyRunning
	// KeyAnswered means the key has a stored answer, in KeyClaim.Answer.
	KeyAnswered
)

The outcomes of Store.ClaimKey.

type Limits

type Limits struct {
	// EditMaxAgeSeconds How long after posting the bot can still edit a message; `null` when edits never expire. Muster stops editing a minute earlier.
	EditMaxAgeSeconds *int `json:"edit_max_age_seconds"`

	// MaxBodyLength The longest `body`, in Unicode code points of the CommonMark Muster sends, that the bridge always accepts for a post and for an edit alike, after its own rendering.
	MaxBodyLength int `json:"max_body_length"`

	// SendPerSecond A hint for the Connection limiter, over all channels of the bot.
	SendPerSecond *float32 `json:"send_per_second,omitempty"`

	// SendPerSecondPerChannel A hint for the Destination limiter; `rate_limited` stays the truth.
	SendPerSecondPerChannel *float32 `json:"send_per_second_per_channel,omitempty"`
}

Limits defines model for Limits.

type Link struct {
	Label string `json:"label"`
	Url   string `json:"url"`
}

Link defines model for Link.

type Mention

type Mention struct {
	DisplayName string `json:"display_name"`

	// ExternalId The account's id in the messenger, as the bridge reported it when the account was linked.
	ExternalId string `json:"external_id"`

	// Ref Names the marker `muster-mention:<ref>` in the body.
	Ref string `json:"ref"`
}

Mention defines model for Mention.

type MessageContent

type MessageContent struct {
	// Body CommonMark, as the description of this protocol says; at most `limits.max_body_length` code points.
	Body string `json:"body"`

	// Buttons In order. Empty when the message has none, always empty without `capabilities.buttons`.
	Buttons []Button `json:"buttons"`

	// Color The colour of the status, for messengers that colour messages.
	Color string `json:"color"`

	// Emoji The status as an emoji (🔴 🟠 ⚪ 🟢 ⛈), for messengers without colours.
	Emoji string `json:"emoji"`
	Link  Link   `json:"link"`

	// Status The status of the Alert Group the message shows; `storm` for a Storm summary.
	Status Status `json:"status"`

	// Title `#N` and the Alert Group's title, plain text; shown as the heading and linked to `link`.
	Title string `json:"title"`
}

MessageContent defines model for MessageContent.

type MessageDeleted

type MessageDeleted struct {
	// UpdateID is the messenger's own id of the update, "" when it has none.
	UpdateID string
	// MessageID is the deleted message.
	MessageID string
	// At is when it happened; zero is now.
	At time.Time
}

MessageDeleted is the deletion of a message of the bot in the messenger.

type MessageDeletedEvent

type MessageDeletedEvent struct {
	// Id Stable across retries and polls; Muster handles each id once. Printable ASCII without spaces, so that it can travel as the `webhook-id` header.
	Id        string `json:"id"`
	MessageId string `json:"message_id"`

	// OccurredAt When it happened in the messenger, or when the bridge learned of it.
	OccurredAt time.Time   `json:"occurred_at"`
	Type       interface{} `json:"type"`
}

MessageDeletedEvent defines model for MessageDeletedEvent.

type MessageEdited

type MessageEdited struct {
	Truncated bool `json:"truncated"`
}

MessageEdited defines model for MessageEdited.

type MessageId

type MessageId = string

MessageId defines model for MessageId.

type MessageInput

type MessageInput struct {
	// Body CommonMark, as the description of this protocol says; at most `limits.max_body_length` code points.
	Body string `json:"body"`

	// Buttons In order. Empty when the message has none, always empty without `capabilities.buttons`.
	Buttons   []Button `json:"buttons"`
	ChannelId string   `json:"channel_id"`

	// Color The colour of the status, for messengers that colour messages.
	Color string `json:"color"`

	// Emoji The status as an emoji (🔴 🟠 ⚪ 🟢 ⛈), for messengers without colours.
	Emoji string `json:"emoji"`

	// Kind A Root message of an Alert Group, a Storm summary, or a test message sent from the Destination page.
	Kind     MessageKind `json:"kind"`
	Link     Link        `json:"link"`
	Mentions []Mention   `json:"mentions"`

	// Notify Loud (`true`) or Quiet (`false`).
	Notify bool `json:"notify"`

	// Status The status of the Alert Group the message shows; `storm` for a Storm summary.
	Status Status `json:"status"`

	// Title `#N` and the Alert Group's title, plain text; shown as the heading and linked to `link`.
	Title string `json:"title"`
}

MessageInput defines model for MessageInput.

type MessageKind

type MessageKind string

MessageKind A Root message of an Alert Group, a Storm summary, or a test message sent from the Destination page.

const (
	MessageKindRoot         MessageKind = "root"
	MessageKindStormSummary MessageKind = "storm_summary"
	MessageKindTest         MessageKind = "test"
)

Defines values for MessageKind.

func (MessageKind) Valid

func (e MessageKind) Valid() bool

Valid indicates whether the value is a known member of the MessageKind enum.

type MessagePosted

type MessagePosted struct {
	// MessageId Opaque to Muster; it encodes whatever the bridge needs to edit the message and reply under it.
	MessageId string `json:"message_id"`

	// Replayed The `Idempotency-Key` was seen before, and this is its first result.
	Replayed bool `json:"replayed"`

	// ThreadRef The message's Thread, when known at once; otherwise the first `replyInThread` returns it.
	ThreadRef *string `json:"thread_ref,omitempty"`

	// Truncated The bridge shortened the body to fit its messenger.
	Truncated bool `json:"truncated"`

	// Url A link that opens the message in the messenger, if it has one; Muster shows it on the Alert Group page and links to it from the Thread of an older Root message.
	Url *string `json:"url,omitempty"`
}

MessagePosted defines model for MessagePosted.

type Messenger

type Messenger interface {
	// PostMessage posts a message to in.ChannelId: a Root message, a Storm summary or a test message.
	PostMessage(ctx context.Context, bot string, in MessageInput) (MessagePosted, error)
	// EditMessage replaces the whole message with in; editing to the state the message already shows is a success.
	EditMessage(ctx context.Context, bot, messageID string, in MessageContent) (MessageEdited, error)
	// ReplyInThread posts a reply in the Thread of the message. in.ThreadRef is the thread_ref Muster sent, nil for
	// none; the answer's ThreadRef is the one Muster keeps and sends with the next reply.
	ReplyInThread(ctx context.Context, bot, messageID string, in ThreadReplyInput) (ThreadReplyPosted, error)
	// CheckChannel posts nothing and returns the channel's name as people see it, or a forbidden *Error when the bot
	// cannot post, edit and reply there.
	CheckChannel(ctx context.Context, bot, channelID string) (string, error)
	// AnswerEvent shows text to whoever caused the event, which the bridge reported, and returns where it was shown.
	AnswerEvent(ctx context.Context, bot string, event ReportedEvent, text string) (AnswerShownShownAs, error)
}

Messenger is what every bridge implements: the calls to its messenger. Each method is given the bot whose token Muster sent, by its name in Config.Bots, and returns the protocol's answer, or an error: an *Error to answer its class, any other error to answer transient with a generic detail. The library validates every request before it calls a method and every answer before it sends it.

type Person

type Person struct {
	DisplayName string `json:"display_name"`

	// ExternalId The account's stable id in the messenger.
	ExternalId string `json:"external_id"`

	// Handle The account's handle of the kind `user_lookup` declares, when the bridge knows it.
	Handle *string `json:"handle,omitempty"`
}

Person defines model for Person.

type PersonList

type PersonList struct {
	Items []Person `json:"items"`
}

PersonList defines model for PersonList.

type PollEventsParams

type PollEventsParams struct {
	// Cursor The `next_cursor` of the previous answer; opaque to Muster.
	Cursor *string `form:"cursor,omitempty" json:"cursor,omitempty"`

	// Wait How long to hold the request open when there are no events, in seconds.
	Wait *int `form:"wait,omitempty" json:"wait,omitempty"`
}

PollEventsParams defines parameters for PollEvents.

type PostMessageJSONRequestBody

type PostMessageJSONRequestBody = MessageInput

PostMessageJSONRequestBody defines body for PostMessage for application/json ContentType.

type PostMessageParams

type PostMessageParams struct {
	// IdempotencyKey Identifies the request across retries; a repeated key with the same token returns the first result without creating anything again. Keys are scoped per token.
	IdempotencyKey IdempotencyKey `json:"Idempotency-Key"`
}

PostMessageParams defines parameters for PostMessage.

type Press

type Press struct {
	// UpdateID is the messenger's own id of the update, "" when it has none; an update reported twice with the same
	// id is queued once.
	UpdateID string
	// MessageID is the message the button belongs to: a Root message or a Thread reply.
	MessageID string
	// User is the person who pressed.
	User Person
	// Data is the button's data, unchanged.
	Data string
	// At is when it happened; zero is now.
	At time.Time
	// Chat is anything the bridge needs to answer the person, such as a chat or callback id; the library keeps it with
	// the event, gives it back in ReportedEvent.Chat and never sends it to Muster.
	Chat string
}

Press is a press of a button of a message of the bot, already confirmed to the messenger.

type PressEvent

type PressEvent struct {
	// Data The button's `data`, unchanged.
	Data string `json:"data"`

	// Id Stable across retries and polls; Muster handles each id once. Printable ASCII without spaces, so that it can travel as the `webhook-id` header.
	Id string `json:"id"`

	// MessageId The message the button belongs to, a Root message or a Thread reply.
	MessageId string `json:"message_id"`

	// OccurredAt When it happened in the messenger, or when the bridge learned of it.
	OccurredAt time.Time   `json:"occurred_at"`
	Type       interface{} `json:"type"`
	User       Person      `json:"user"`
}

PressEvent defines model for PressEvent.

type Problem

type Problem struct {
	Class *ErrorClass `json:"class,omitempty"`

	// Detail Shown to the Admin as the reason, as untrusted text; never put a secret in it.
	Detail   *string `json:"detail,omitempty"`
	Instance *string `json:"instance,omitempty"`

	// Reason For `gone`, `edit_window_closed` when the messenger refuses the edit only because of the message's age.
	Reason *ProblemReason `json:"reason,omitempty"`

	// Scope For `rate_limited`, whether the wait holds one channel or every channel of the bot.
	Scope  *ProblemScope `json:"scope,omitempty"`
	Status *int          `json:"status,omitempty"`
	Title  *string       `json:"title,omitempty"`
	Type   *string       `json:"type,omitempty"`
}

Problem RFC 9457 problem details with the Bridge protocol's members.

type ProblemReason

type ProblemReason string

ProblemReason For `gone`, `edit_window_closed` when the messenger refuses the edit only because of the message's age.

const (
	ProblemReasonEditWindowClosed ProblemReason = "edit_window_closed"
)

Defines values for ProblemReason.

func (ProblemReason) Valid

func (e ProblemReason) Valid() bool

Valid indicates whether the value is a known member of the ProblemReason enum.

type ProblemScope

type ProblemScope string

ProblemScope For `rate_limited`, whether the wait holds one channel or every channel of the bot.

const (
	ProblemScopeBot     ProblemScope = "bot"
	ProblemScopeChannel ProblemScope = "channel"
)

Defines values for ProblemScope.

func (ProblemScope) Valid

func (e ProblemScope) Valid() bool

Valid indicates whether the value is a known member of the ProblemScope enum.

type PushTarget

type PushTarget struct {
	// URL is the callback address of the Bridge Connection, with the http or https scheme and a host.
	URL string
	// Secret is the push secret as the Bridge Connection shows it: whsec_ and the base64 of the signing key.
	Secret string
}

PushTarget is where the events of a bot are pushed, as the Bridge Connection in Muster shows it.

type RateLimited

type RateLimited = Problem

RateLimited RFC 9457 problem details with the Bridge protocol's members.

type ReplyInThreadJSONRequestBody

type ReplyInThreadJSONRequestBody = ThreadReplyInput

ReplyInThreadJSONRequestBody defines body for ReplyInThread for application/json ContentType.

type ReplyInThreadParams

type ReplyInThreadParams struct {
	// IdempotencyKey Identifies the request across retries; a repeated key with the same token returns the first result without creating anything again. Keys are scoped per token.
	IdempotencyKey IdempotencyKey `json:"Idempotency-Key"`
}

ReplyInThreadParams defines parameters for ReplyInThread.

type ReportedEvent

type ReportedEvent struct {
	ID   string
	Type EventType
	// MessageID is the message of a press or a Thread message.
	MessageID string
	// ThreadRef is the thread_ref of a Thread message, "" for none.
	ThreadRef  string
	User       Person
	Chat       string
	OccurredAt time.Time
}

ReportedEvent is an event the bridge reported, as AnswerEvent is given it.

type SendDirectMessageJSONRequestBody

type SendDirectMessageJSONRequestBody = DirectMessageInput

SendDirectMessageJSONRequestBody defines body for SendDirectMessage for application/json ContentType.

type SendDirectMessageParams

type SendDirectMessageParams struct {
	// IdempotencyKey Identifies the request across retries; a repeated key with the same token returns the first result without creating anything again. Keys are scoped per token.
	IdempotencyKey IdempotencyKey `json:"Idempotency-Key"`
}

SendDirectMessageParams defines parameters for SendDirectMessage.

type Status

type Status string

Status The status of the Alert Group the message shows; `storm` for a Storm summary.

const (
	StatusAcknowledged Status = "acknowledged"
	StatusFiring       Status = "firing"
	StatusResolved     Status = "resolved"
	StatusSnoozed      Status = "snoozed"
	StatusStorm        Status = "storm"
)

Defines values for Status.

func (Status) Valid

func (e Status) Valid() bool

Valid indicates whether the value is a known member of the Status enum.

type Store

type Store interface {
	// ClaimKey returns KeyAnswered with the answer when one is stored for (bot, key) until after now; else KeyRunning
	// when another claim holds the key until after now; else it claims the key until `until` and returns KeyClaimed
	// with an ID that tells this claim from any later claim of the same key.
	ClaimKey(ctx context.Context, bot, key string, now, until time.Time) (KeyClaim, error)
	// SaveKey stores the answer of (bot, key), kept until `until`, in place of the claim claimID. When nothing holds the
	// key, because the claim expired and was forgotten, it stores the answer all the same. When another claim or an
	// answer holds the key, SaveKey leaves it as it is: the first answer stays, and so does a newer request's claim.
	SaveKey(ctx context.Context, bot, key, claimID string, answer StoredAnswer, until time.Time) error
	// ReleaseKey removes the claim claimID of (bot, key), so that a retry with the key runs again. It removes nothing
	// else: not an answer, and not the claim of a request that took the key over after claimID expired.
	ReleaseKey(ctx context.Context, bot, key, claimID string) error
	// AppendEvent queues the event for the bot unless an event with its ID is kept, and reports whether it queued it.
	AppendEvent(ctx context.Context, bot string, event StoredEvent, now time.Time) (bool, error)
	// EventsAfter returns up to limit of the bot's kept events, oldest first, that follow cursor ("" starts before the
	// oldest), with the cursor that follows the last one returned. Returning none, it gives cursor back unchanged, or,
	// for "", the cursor at the end of the queue. A cursor that this store did not issue for the bot is
	// ErrUnknownCursor.
	EventsAfter(ctx context.Context, bot, cursor string, limit int, now time.Time) ([]StoredEvent, string, error)
	// FindEvent returns the kept event of the bot with the id, and whether there is one.
	FindEvent(ctx context.Context, bot, id string, now time.Time) (StoredEvent, bool, error)
}

Store keeps, per bot, the first answers of Idempotency-Keys and the queued events. Every method is given the time of the library's clock and must be safe for concurrent use. A bridge that must keep its keys and events across restarts implements it over its own database; NewMemoryStore keeps them in memory.

func NewMemoryStore

func NewMemoryStore() Store

NewMemoryStore returns a Store that keeps keys and events in memory, for one process: a restart forgets them, and Muster then polls again without a cursor.

Its memory is bounded by time, not by size: it holds every event of the last 24 hours, whatever Muster has already polled or acknowledged (a Thread message carries up to 4000 characters), and the answers of the Idempotency-Keys of the last 24 hours. A bridge whose day of traffic does not fit in memory implements Store over its database, which may also drop the events before the cursor that Muster last sent.

Example

A Store implementation follows the memory store: a key is claimed while its request runs, then holds its answer. Only the holder of a claim, named by its ID, saves or releases it.

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"

	bridge "github.com/muster-io/muster-bridge-go"
)

func main() {
	ctx := context.Background()
	store := bridge.NewMemoryStore()
	now := time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC)

	claim, _ := store.ClaimKey(ctx, "main", "key-1", now, now.Add(2*time.Minute))
	fmt.Println(claim.State == bridge.KeyClaimed)
	running, _ := store.ClaimKey(ctx, "main", "key-1", now, now.Add(2*time.Minute))
	fmt.Println(running.State == bridge.KeyRunning)

	answer := bridge.StoredAnswer{Status: http.StatusCreated, Body: []byte(`{"message_id":"n1","replayed":false}`)}
	_ = store.SaveKey(ctx, "main", "key-1", claim.ID, answer, now.Add(24*time.Hour))
	later, _ := store.ClaimKey(ctx, "main", "key-1", now.Add(time.Hour), now.Add(time.Hour+2*time.Minute))
	fmt.Println(later.State == bridge.KeyAnswered, later.Answer.Status)
}
Output:
true
true
true 201

type StoredAnswer

type StoredAnswer struct {
	Status int
	// Body is the JSON of the first answer, with replayed false.
	Body []byte
}

StoredAnswer is the first successful answer of an Idempotency-Key.

type StoredEvent

type StoredEvent struct {
	ID   string
	Type EventType
	// Body is the event exactly as pollEvents and the push send it.
	Body []byte
	// Chat is what the bridge gave with the report; it is never sent to Muster.
	Chat string
	// Until is the time until which the event is kept, at least.
	Until time.Time
}

StoredEvent is an event queued for a bot.

type ThreadMessage

type ThreadMessage struct {
	// UpdateID is the messenger's own id of the update, "" when it has none.
	UpdateID string
	// MessageID is the bot's message that owns the Thread, as replyInThread names it; never the id of a reply.
	MessageID string
	// ThreadRef is the Thread's thread_ref, "" for none.
	ThreadRef string
	// User is the person who wrote.
	User Person
	// Text is the message as plain text, mentions of the bot removed; it is cut to 4000 characters.
	Text string
	// At is when it happened; zero is now.
	At time.Time
	// Chat is kept with the event and given back in ReportedEvent.Chat.
	Chat string
}

ThreadMessage is what a person wrote under a message of the bot, in its Thread; the bot's own replies are not reported.

type ThreadMessageEvent

type ThreadMessageEvent struct {
	// Id Stable across retries and polls; Muster handles each id once. Printable ASCII without spaces, so that it can travel as the `webhook-id` header.
	Id string `json:"id"`

	// MessageId The message whose Thread it is in — the `message_id` in the path of `replyInThread`, usually a Root message, never the id of a reply.
	MessageId string `json:"message_id"`

	// OccurredAt When it happened in the messenger, or when the bridge learned of it.
	OccurredAt time.Time `json:"occurred_at"`

	// Text The message as plain text, mentions of the bot removed.
	Text      string      `json:"text"`
	ThreadRef *string     `json:"thread_ref"`
	Type      interface{} `json:"type"`
	User      Person      `json:"user"`
}

ThreadMessageEvent defines model for ThreadMessageEvent.

type ThreadReplyInput

type ThreadReplyInput struct {
	// Body CommonMark, at most `limits.max_body_length` code points.
	Body     string    `json:"body"`
	Buttons  []Button  `json:"buttons"`
	Mentions []Mention `json:"mentions"`

	// Notify Loud (`true`) or Quiet (`false`).
	Notify bool `json:"notify"`

	// ThreadRef The last `thread_ref` Muster received for the message — from `postMessage` or an earlier reply — or `null` when it has none.
	ThreadRef *string `json:"thread_ref"`
}

ThreadReplyInput defines model for ThreadReplyInput.

type ThreadReplyPosted

type ThreadReplyPosted struct {
	// MessageId The reply; presses on its buttons name it.
	MessageId string `json:"message_id"`
	Replayed  bool   `json:"replayed"`
	ThreadRef string `json:"thread_ref"`
	Truncated bool   `json:"truncated"`
}

ThreadReplyPosted defines model for ThreadReplyPosted.

type Transient

type Transient = Problem

Transient RFC 9457 problem details with the Bridge protocol's members.

type Unauthorized

type Unauthorized = Problem

Unauthorized RFC 9457 problem details with the Bridge protocol's members.

type UserFinder

type UserFinder interface {
	// FindUser returns the one account with the handle, of the kind Config.UserLookup declares, or nil for nobody.
	FindUser(ctx context.Context, bot, handle string) (*Person, error)
}

UserFinder is the Messenger's lookup of people, which a UserLookup other than InfoUserLookupNone needs.

Directories

Path Synopsis
examples
chat command
Command chat is the example bridge: a small chat with its own web page — channels, Threads, buttons, direct chats between a person and the bot, a picker of who the viewer is, and an event log — served on one listener beside the Bridge protocol, so that Muster can be tried, and tested, with a messenger that runs anywhere.
Command chat is the example bridge: a small chat with its own web page — channels, Threads, buttons, direct chats between a person and the bot, a picker of who the viewer is, and an event log — served on one listener beside the Bridge protocol, so that Muster can be tried, and tested, with a messenger that runs anywhere.
minimal command
Command minimal is a bridge whose messenger lives in memory and writes everything the bot posts to its log, so that Muster, muster bridge check or a person with curl can try the Bridge protocol without a real messenger.
Command minimal is a bridge whose messenger lives in memory and writes everything the bot posts to its log, so that Muster, muster bridge check or a person with curl can try the Bridge protocol without a real messenger.

Jump to

Keyboard shortcuts

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