gateway

package
v0.11.0-beta.2 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 2026 License: Apache-2.0, MIT Imports: 25 Imported by: 0

Documentation

Overview

Package gateway — simple PostgreSQL-backed bookmark store for session bookmarks.

Package gateway — shared helpers for gateway implementations.

Package gateway is the shared abstraction behind every remote messaging channel — Telegram, Discord, WhatsApp, and arbitrary sidecars via the HTTP bridge. The agent runs in the TUI; gateways pipe inbound messages through to it and stream replies back, so a user can step away from their terminal and keep driving the same session from their phone.

Slack predates this package and keeps its own session map for historical reasons. New channels live here.

Package gateway — shared helpers for gateway implementations.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ChunkAtRune

func ChunkAtRune(s string, max int) (head, tail string)

ChunkAtRune splits s at a rune boundary at or before max bytes, preferring the last newline within a 200-byte look-behind window so we don't cut mid-paragraph. If len(s) <= max, returns (s, "").

Unicode safety: the fallback walks backward through continuation bytes (0x80-0xBF, identified by &0xC0==0x80) to find the start of a multi-byte UTF-8 rune, ensuring both returned strings are valid UTF-8.

func NewSessionID

func NewSessionID(prefix string) string

NewSessionID mints a router-tagged session id. Channels can pass their own ids; this helper is provided for convenience.

func TruncateMessage

func TruncateMessage(msg string, maxLen int) string

TruncateMessage truncates msg to maxLen bytes, preferring clean breakpoints: first a newline, then a sentence boundary (". "), then a hard byte cut (respecting UTF-8 continuation bytes). Appends "…" when truncation occurs.

Types

type AgentSender

type AgentSender interface {
	Stream(ctx context.Context, in string) (<-chan agent.StreamEvent, error)
	SetSessionID(id string)
	SessionID() string
	EStop()
	// Interrupt cancels the currently running stream for this agent.
	// Safe to call from any goroutine. No-op if no stream is running.
	Interrupt()
	// SetQuestionFunc wires the clarify callback so the agent can ask
	// the user questions mid-execution (§8.6).
	SetQuestionFunc(fn agent.QuestionFunc)
	// Undo removes the last exchange from session history.
	Undo() (string, error)
	// Retry replays the last user message with a fresh model call.
	Retry(ctx context.Context) (string, error)
	// Steer queues a guidance message for mid-run injection into the
	// agent loop. Returns a confirmation string.
	Steer(msg string) string
	// Fork creates a new session branched from the current one with
	// an optional name. Returns the new session ID.
	Fork(name string) (string, error)
	// Snapshot creates a git-based filesystem checkpoint with the
	// given name. Returns the new commit hash.
	Snapshot(name string) (string, error)
	// Rollback rolls back to the Nth most recent checkpoint
	// (0 = latest). Returns a human-readable summary.
	Rollback(n int) (string, error)
	// Snapshots returns a human-readable listing of saved checkpoints.
	Snapshots() (string, error)
	// SetGoal sets or updates the standing goal for the current session.
	SetGoal(ctx context.Context, text string) error
	// GetGoal returns the current goal text (empty string if none set).
	GetGoal(ctx context.Context) (string, error)
	// PauseGoal pauses the current goal (keeps it but stops injecting).
	PauseGoal(ctx context.Context) error
	// ResumeGoal resumes a paused goal so it injects again.
	ResumeGoal(ctx context.Context) error
	// ClearGoal removes the goal entirely.
	ClearGoal(ctx context.Context) error
	// Compact triggers context compaction on the current session.
	// Returns the compaction result with before/after token counts and a summary.
	Compact(ctx context.Context) (*agent.CompactResult, error)
	// ExportHistory writes the current session's message history to a
	// markdown file at the given path and returns the path.
	ExportHistory(path string) (string, error)
	// SetThinkingLevel sets the extended thinking budget.
	SetThinkingLevel(level string)
	// ThinkingLevel returns the current thinking level.
	ThinkingLevel() string
	// Mode returns the current plan/build mode ("plan" or "build").
	// Plan mode means analysis-only; build mode allows execution.
	Mode() string
	// IsBusy returns true when the agent loop is actively executing Run().
	// Used by harness to guard commands that would interrupt a running agent.
	IsBusy() bool
}

AgentSender is the minimal slice of *agent.Agent that gateways call into. Kept tiny so the package never imports the cmd layer.

type Bookmark

type Bookmark struct {
	ID        int       `json:"id"`
	SessionID string    `json:"session_id"`
	Label     string    `json:"label"`
	CreatedAt time.Time `json:"created_at"`
}

Bookmark is a user-defined label pointing to a session.

type BookmarkFn

type BookmarkFn func(ctx context.Context, sessionID, label string) error

BookmarkFn is the callback invoked when a user runs /bm <label> BookmarkFn is the callback invoked when a user runs /bm <label> in any gateway. The dispatcher resolves the active session for the chat, then asks this function to persist a bookmark with that label against that session. Return non-nil error to signal failure — the reply to the user surfaces it verbatim.

Defined as a field rather than a constructor argument so the caller can wire it after construction (some bookmark backends need the dispatcher to exist first).

type BookmarkStore

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

BookmarkStore persists session bookmarks in PostgreSQL.

func NewBookmarkStore

func NewBookmarkStore(db *sql.DB) (*BookmarkStore, error)

NewBookmarkStore returns a BookmarkStore backed by db.

func (*BookmarkStore) Delete

func (s *BookmarkStore) Delete(id int) error

Delete removes a bookmark by ID.

func (*BookmarkStore) DeleteByLabel

func (s *BookmarkStore) DeleteByLabel(sessionID, label string) error

DeleteByLabel removes bookmarks matching a label and session.

func (*BookmarkStore) List

func (s *BookmarkStore) List() ([]Bookmark, error)

List returns all bookmarks, newest first.

func (*BookmarkStore) ListBySession

func (s *BookmarkStore) ListBySession(sessionID string) ([]Bookmark, error)

ListBySession returns bookmarks for a specific session.

func (*BookmarkStore) Save

func (s *BookmarkStore) Save(sessionID, label string) (*Bookmark, error)

Save creates a new bookmark entry.

type Channel

type Channel interface {
	Name() string
	Run(ctx context.Context) error
}

Channel is one running gateway. Run blocks until ctx is cancelled. Name is used for logs and as the Inbound.Channel value.

type Dispatcher

type Dispatcher struct {
	// RejectBusy disables detached per-session queues for server-owned bridges.
	RejectBusy bool
	Agent      AgentSender
	Router     *SessionRouter
	Vision     vision.Describer // optional; when set, attached images are
	// captioned and inlined into the agent prompt
	Logger      *log.Logger
	UpdateEvery time.Duration // batch reply edits; default 750ms

	// MaxTextLen caps text per inbound message. 0 means use default (100000).
	MaxTextLen int
	// MaxImageBytes caps total image bytes per message. 0 means use default (20000000).
	MaxImageBytes int
	// MaxReqsPerMinute is the rate limit per sender per minute. 0 means use default (10).
	MaxReqsPerMinute int

	// Bookmark, when set, handles the /bm <label> slash command from
	// any gateway. Nil leaves the command unwired; users see a
	// helpful error rather than a silent no-op.
	Bookmark BookmarkFn

	// SessionTitler, when set, is called once per session when the
	// first user message arrives, so the session Title is auto-set.
	// Nil disables auto-titling. Called synchronously before the
	// agent turn — should be fast (fire-and-forget is fine).
	SessionTitler func(sessionID, firstMsg string)

	// Pairing, when set, gates inbound messages from unknown senders.
	// Unapproved senders receive a challenge code; their messages are
	// not processed or persisted until the owner approves them.
	Pairing PairingGate

	// InputHistory, when set, records incoming messages to PostgreSQL
	// for context-aware reply history. Recording happens only after
	// the pairing gate passes — unapproved messages are never stored.
	InputHistory InputHistoryStore

	// MsgDelivery, when set, tracks message delivery status in Postgres.
	// Messages are recorded as 'pending' before the provider call.
	// On provider failure they're marked 'failed' — on the next Handle()
	// call for the same session, failed messages are auto-retried first.
	// On success they're marked 'delivered'. No message is ever lost.
	MsgDelivery *MessageDelivery

	// OnActivity, when set, is called every time a non-command user
	// message is accepted for processing (after rate-limit and pairing
	// gates). Cron systems wire their ActivityTracker.Record here so
	// cron output delivery can defer until the user is idle.
	OnActivity func()

	// CostTracker, when set, powers the /usage slash command and the
	// session.usage RPC endpoint. Nil disables usage tracking from
	// gateways (the command returns a helpful error message).
	CostTracker cost.Tracker
	// contains filtered or unexported fields
}

Dispatcher is the shared "message arrives → reply goes out" engine Dispatcher is the shared "message arrives → reply goes out" engine every Channel hands its inbound messages to. It owns:

  • session resolution via SessionRouter (incl. follow mode)
  • the small set of slash commands a remote user needs to manage their session from a phone (/sessions, /attach, /follow, /new, /end, /help)
  • per-session serialization so two phones blasting the same chat don't interleave turns inside the agent loop
  • sender pairing gate: unapproved senders are challenged before their messages are processed

The Channel keeps owning its Reply transport; Dispatcher just calls PostInitial / Update / Final / Error against it.

func NewDispatcher

func NewDispatcher(ag AgentSender, r *SessionRouter) *Dispatcher

NewDispatcher returns a Dispatcher with sensible defaults filled in.

func (*Dispatcher) ApplyGatewayLimits

func (d *Dispatcher) ApplyGatewayLimits(cfg GatewayLimitsCfg)

ApplyGatewayLimits sets dispatch limits from a GatewayConfig. Zero values are ignored (the built-in defaults remain).

func (*Dispatcher) Handle

func (d *Dispatcher) Handle(ctx context.Context, in Inbound, reply Reply)

Handle is called by a Channel for each Inbound it receives. Reply is the channel's own transport. Errors are logged and surfaced via reply.Error rather than returned, because gateway loops shouldn't die over one bad message.

func (*Dispatcher) SessionActive

func (d *Dispatcher) SessionActive(in Inbound) bool

SessionActive checks only an existing binding; admission never creates state.

type GatewayLimitsCfg

type GatewayLimitsCfg struct {
	MaxTextLen      int
	MaxImageBytes   int
	RateLimitPerMin int
	UpdateEveryMs   int
}

GatewayLimitsCfg is a minimal interface for passing dispatch limits without importing config.

type HealthChecker

type HealthChecker interface {
	Healthy() bool
}

HealthChecker is implemented by gateways that can report their current health. A healthy gateway can send and receive messages.

type Hub

type Hub struct {
	Channels []Channel
	Logger   *log.Logger
	// BackoffInitial is the initial backoff duration for channel
	// restart attempts. Zero falls back to 1s.
	BackoffInitial time.Duration
	// BackoffMax is the maximum backoff cap for channel restart
	// attempts. Zero falls back to 30s.
	BackoffMax time.Duration
	// contains filtered or unexported fields
}

Hub runs N channels concurrently, restarts ones that crash, and shuts them all down cleanly when ctx is cancelled.

func NewHub

func NewHub(channels ...Channel) *Hub

NewHub returns a Hub with logging defaulted to discard. Nil channels are filtered out.

func (*Hub) Add

func (h *Hub) Add(c Channel)

Add appends a channel to the hub. Must be called before Run.

func (*Hub) Run

func (h *Hub) Run(ctx context.Context) error

Run blocks until ctx is cancelled. Each channel runs in its own goroutine under a supervise loop with exponential backoff: a channel that returns (a panic or a non-ctx error) is restarted, capped at hubMaxBackoff between attempts. Old code logged the exit and let the goroutine end — one Discord/Telegram panic permanently killed that channel until the whole process was restarted.

type Inbound

type Inbound struct {
	Channel  string // "telegram", "discord", "whatsapp", "bridge:<name>"
	ChatKey  string
	Thread   string
	From     string // display name or user id, for logging only
	Text     string
	Images   []InboundImage // attached photos; describer turns into prose
	IsDirect bool           // DM/private chat vs group
}

Inbound is one user-authored message arriving from any channel. ChatKey is the gateway-stable identifier for the conversation (e.g. "telegram:12345" or "bridge:whatsapp:+15551234"). Thread is optional — channels with no thread concept leave it empty.

type InboundImage

type InboundImage struct {
	Bytes []byte
	Mime  string
}

InboundImage is one attached image. Mime is best-effort; describers sniff bytes if it's empty.

type InputHistory

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

InputHistory is a per-chat ring buffer of recent user messages stored in PostgreSQL. Each chat gets a single row holding a JSON array whose element [0] is the most recent message (latest-first ordering).

func NewInputHistory

func NewInputHistory(db *sql.DB) (*InputHistory, error)

NewInputHistory returns an InputHistory backed by db. maxEntries defaults to 100. The caller owns the DB lifecycle.

func (*InputHistory) Append

func (ih *InputHistory) Append(ctx context.Context, chatKey, text string) error

Append prepends text to the bounded history. Insert the row before locking it so simultaneous first appends serialize just like existing-chat updates. The transaction preserves existing data if reading or decoding fails.

func (*InputHistory) Get

func (ih *InputHistory) Get(ctx context.Context, chatKey string, offset int) (string, error)

Get returns the entry at the given offset. Offset 0 = latest.

func (*InputHistory) GetHistory

func (ih *InputHistory) GetHistory(ctx context.Context, chatKey string, limit int) ([]string, error)

GetHistory returns up to limit most-recent messages.

func (*InputHistory) Len

func (ih *InputHistory) Len(ctx context.Context, chatKey string) (int, error)

Len returns the number of stored entries for the given chat.

type InputHistoryStore

type InputHistoryStore interface {
	Append(ctx context.Context, chatKey, text string) error
}

InputHistoryStore persists incoming messages for context-aware reply history. Implementations wrap a Postgres-backed ring buffer (see InputHistory in input_history.go).

type MessageDelivery

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

MessageDelivery tracks whether a user message was successfully delivered to the LLM provider or not. Failed messages are automatically retried on the next Handle() call for the same session — no message is ever lost.

func NewMessageDelivery

func NewMessageDelivery(db *sql.DB) (*MessageDelivery, error)

NewMessageDelivery creates the message_delivery table and returns the tracker.

func (*MessageDelivery) GetUndelivered

func (md *MessageDelivery) GetUndelivered(ctx context.Context, sessionID string) ([]UndeliveredMessage, error)

GetUndelivered returns all failed/pending messages for a session, ordered oldest-first so retries happen in the original order.

func (*MessageDelivery) MarkDelivered

func (md *MessageDelivery) MarkDelivered(ctx context.Context, id int64) error

MarkDelivered updates a message to delivered.

func (*MessageDelivery) MarkFailed

func (md *MessageDelivery) MarkFailed(ctx context.Context, id int64, errMsg string) error

MarkFailed updates a message to failed with the error reason.

func (*MessageDelivery) RecordPending

func (md *MessageDelivery) RecordPending(ctx context.Context, sessionID, chatKey, message string) (int64, error)

RecordPending stores a message before the provider call. Returns the row ID.

func (*MessageDelivery) RetryUndelivered

func (md *MessageDelivery) RetryUndelivered(ctx context.Context, sessionID string) ([]UndeliveredMessage, error)

RetryUndelivered marks all undelivered messages for a session back to 'pending' so they get retried on the next Handle call. Returns the list of message texts, oldest first.

type Notifier

type Notifier interface {
	Notify(ctx context.Context, target string, text string) error
}

Notifier is implemented by Channel bots that can send unsolicited messages to a target (channel, chat, JID) without a prior inbound message. Used by the §7.1 Layer 6 completion-push poller and other cron/alert systems to deliver notifications through already-open gateway connections.

func NotifierBots

func NotifierBots(channels []Channel) []Notifier

NotifierBots filters a slice of Channel to those that also implement Notifier, enabling the §7.1 Layer 6 completion-push poller and other cron/alert systems to deliver notifications through already-open gateway connections.

type PairingGate

type PairingGate interface {
	IsApproved(channel, senderID string) bool
	IssueChallenge(channel, senderID string) (string, error)
}

PairingGate checks whether a sender is approved to interact with the agent. Implementations wrap a pairing store (e.g. security.PairingStore) and return true when the sender is on the allow-from list or pairing is disabled for the channel. False means the sender should receive a challenge code and their message should be discarded.

type RecentEntry

type RecentEntry struct {
	SessionID string
	Channel   string
	ChatKey   string
	Thread    string
	Updated   time.Time
	// contains filtered or unexported fields
}

RecentEntry is one row in the /sessions list.

type Reconnecter

type Reconnecter interface {
	Reconnect(ctx context.Context) error
}

Reconnecter is implemented by gateways that support explicit reconnection with backoff. Run handles automatic reconnection; Reconnect is the public hook for callers to trigger a reconnect externally (e.g. from a health monitor).

type Reply

type Reply interface {
	PostInitial(ctx context.Context, in Inbound, text string) (handle string, err error)
	Update(ctx context.Context, handle, text string) error
	Final(ctx context.Context, handle, text string) error
	Error(ctx context.Context, handle string, err error) error
	// StartTyping begins the native typing indicator if the channel supports it.
	// Returns a stop function that clears the indicator. Channels without
	// native typing support return a no-op stop function.
	StartTyping() (stop func())
}

Reply is the surface a Channel exposes to the dispatcher to render an agent reply. PostInitial returns an opaque handle the channel uses to route Update / Final / Error back to the right message.

Channels that can't edit messages in place (SMS, email) MUST still implement Update by no-op'ing — Final is what users actually see.

type SessionAgentProvider

type SessionAgentProvider interface {
	AgentForSession(context.Context, string) (AgentSender, error)
}

SessionAgentProvider optionally binds operations to an immutable session. Multi-session API adapters implement this; existing single-agent gateways retain SetSessionID behavior.

type SessionRouter

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

SessionRouter maps a (channel, chatKey, thread) tuple onto an overkill session id, persists the binding to disk so restarts don't lose in-flight conversations, and tracks a per-chat "follow" pointer the dispatcher consults each turn.

Follow mode is the cross-channel continuity primitive: a phone chat can call /follow tui to mirror whatever session id the TUI is currently using, so subsequent user messages route there even after the TUI swaps sessions.

func NewSessionRouter

func NewSessionRouter(path string) (*SessionRouter, error)

NewSessionRouter loads the persisted file. Empty path disables persistence (used by tests). Missing file is not an error.

func (*SessionRouter) Bind

func (r *SessionRouter) Bind(channel, chatKey, thread, sessionID string) error

Bind locks a chat to a specific session id and persists the binding. Touch is called on every turn to bump Updated for /sessions ordering.

func (*SessionRouter) Follow

func (r *SessionRouter) Follow(channel, chatKey, target string) error

Follow puts the chat into follow mode. target = "tui" mirrors the live TUI session id; any other non-empty value pins to that session. Empty target clears follow mode. The channel prefix on the key prevents different channels (WhatsApp vs Discord) sharing the same chatKey from colliding on follow state.

func (*SessionRouter) FollowTarget

func (r *SessionRouter) FollowTarget(channel, chatKey string) string

FollowTarget returns the current follow target for the chat ("", "tui", or a session id).

func (*SessionRouter) Recent

func (r *SessionRouter) Recent(limit int) []RecentEntry

Recent returns up to limit bindings sorted newest-first. Used by /sessions to show "you were just here, want to resume?".

func (*SessionRouter) Resolve

func (r *SessionRouter) Resolve(channel, chatKey, thread, liveTUISession string) (id string, isFollow bool)

Resolve returns the session id this chat should drive this turn. liveTUISession is the agent's current session id at call time; when a chat is in follow=tui mode we shadow it.

func (*SessionRouter) Touch

func (r *SessionRouter) Touch(channel, chatKey, thread string) error

Touch bumps Updated so /sessions can sort by recency. No-op if unbound.

type SessionStopper

type SessionStopper interface {
	InterruptSession() error
	EmergencyStop() error
}

SessionStopper lets adapters report cancellation that cannot be guaranteed. The original void AgentSender methods remain compatible with legacy agents.

type UndeliveredMessage

type UndeliveredMessage struct {
	ID        int64
	SessionID string
	Message   string
	ErrorMsg  string
	CreatedAt time.Time
}

UndeliveredMessage is a message that was sent but never received a response.

Directories

Path Synopsis
Package bridge is a one-size HTTP gateway any sidecar (Baileys for WhatsApp, discord.js, an SMS relay) can plug into.
Package bridge is a one-size HTTP gateway any sidecar (Baileys for WhatsApp, discord.js, an SMS relay) can plug into.
Package discord — Discord gateway via discordgo (Batch H).
Package discord — Discord gateway via discordgo (Batch H).
Package matrix implements a Matrix messaging gateway via raw HTTP against the Matrix Client-Server API (no heavy SDK).
Package matrix implements a Matrix messaging gateway via raw HTTP against the Matrix Client-Server API (no heavy SDK).
Package mattermost implements a Mattermost messaging gateway via the Mattermost WebSocket API (wss://server/api/v4/websocket) for real-time events and the REST API for posting messages.
Package mattermost implements a Mattermost messaging gateway via the Mattermost WebSocket API (wss://server/api/v4/websocket) for real-time events and the REST API for posting messages.
Package signal implements a Signal messaging gateway via signal-cli's REST API mode.
Package signal implements a Signal messaging gateway via signal-cli's REST API mode.
Package slack — Slack gateway via Socket Mode.
Package slack — Slack gateway via Socket Mode.
Package telegram is a minimal Bot API client used by the Telegram gateway.
Package telegram is a minimal Bot API client used by the Telegram gateway.
whatsapp
cloud
Package cloud — WhatsApp Business Cloud API gateway (Batch L, official-path backend).
Package cloud — WhatsApp Business Cloud API gateway (Batch L, official-path backend).
whatsmeow
Package whatsmeow — WhatsApp gateway via go.mau.fi/whatsmeow (Batch L, unofficial-path backend).
Package whatsmeow — WhatsApp gateway via go.mau.fi/whatsmeow (Batch L, unofficial-path backend).

Jump to

Keyboard shortcuts

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