Documentation
¶
Overview ¶
PairingManager and related types live in imbot/security so that any imbot application can reuse the TOFU pairing mechanism independently of the remote-control service. The aliases below keep existing code in this package unchanged.
Index ¶
- Constants
- Variables
- func ForwardReplyContext(opts *imbot.SendMessageOptions, inbound imbot.Message)
- func HandlePromptCallback(prompter *imchannel.IMPrompter, send func(string), senderID string, ...) bool
- func HandlePromptTextReply(prompter *imchannel.IMPrompter, send func(string), ...) bool
- func PlatformDefaultsRequirePairing(platform string) bool
- type AccessStore
- type Attached
- type BotSetting
- type Chat
- type ChatStoreInterface
- type Consumer
- type Manager
- func (m *Manager) ChatStore() (ChatStoreInterface, error)
- func (m *Manager) IsRunning(uuid string) bool
- func (m *Manager) PairingManager() *PairingManager
- func (m *Manager) SetAccessStore(store AccessStore)
- func (m *Manager) SetCapabilityStore(store interface{ ... })
- func (m *Manager) SetChannelRegistry(reg *channel.Registry)
- func (m *Manager) SetChatStore(store ChatStoreInterface)
- func (m *Manager) Start(parentCtx context.Context, uuid string) error
- func (m *Manager) StartEnabled(ctx context.Context) error
- func (m *Manager) Stop(uuid string)
- func (m *Manager) StopAll()
- func (m *Manager) Sync(ctx context.Context) error
- func (m *Manager) WaitForStop(uuid string, timeout time.Duration) bool
- type OnMessage
- type PairingManager
- type SettingsStore
Constants ¶
const NotifyConsumerName = "notify"
NotifyConsumerName identifies the notify purpose in logs and dispatch diagnostics. Unlike remote_agent it is not a stored mount name: notify is mounted implicitly by the bot's outbound scenario bindings (claude_code, …).
const ProjectHistoryCap = 20
ProjectHistoryCap bounds the per-chat MRU list so storage stays bounded and the /project list stays readable.
Variables ¶
var ( ErrPairCodeMissing = security.ErrPairCodeMissing ErrPairCodeExpired = security.ErrPairCodeExpired ErrPairCodeMismatch = security.ErrPairCodeMismatch ErrPairLocked = security.ErrPairLocked )
Error sentinels forwarded from imbot/security.
var ( NewPairingManager = security.NewPairingManager NewLogAuditor = security.NewLogAuditor )
Constructor helpers forwarded from imbot/security. Callers needing the tuning options (TTL, code length, …) should use imbot/security directly.
Functions ¶
func ForwardReplyContext ¶
func ForwardReplyContext(opts *imbot.SendMessageOptions, inbound imbot.Message)
ForwardReplyContext copies the inbound message's reply-context token onto outbound options. Weixin and WeCom tie each reply to the inbound message it answers, and drop or misattribute a reply that arrives without the token.
TODO(phase-4): this should not be the caller's job at all — the bot knows which inbound message it is answering.
func HandlePromptCallback ¶
func HandlePromptCallback(prompter *imchannel.IMPrompter, send func(string), senderID string, payload imbot.Payload, claimUnknown bool) bool
HandlePromptCallback routes one "perm" callback payload (segment 0 == "perm") to the prompter's pending request. send delivers user-facing feedback to the originating chat.
claimUnknown controls who answers for an expired/foreign request ID: the terminal consumer (remote agent) passes true and tells the user the request expired; a first-in-line consumer (channel) passes false so the callback falls through to the next consumer, which may own the request.
func HandlePromptTextReply ¶
func HandlePromptTextReply(prompter *imchannel.IMPrompter, send func(string), chatID, senderID, input string) bool
HandlePromptTextReply routes a plain-text reply to the prompter's most recent pending request for chatID. Returns false when nothing is pending in that chat or the text is not a recognizable answer, so the caller can hand the message to other handlers.
func PlatformDefaultsRequirePairing ¶
PlatformDefaultsRequirePairing reports whether a bot on the given platform has TOFU pairing enforced when RequirePairing is unset (nil).
The answer comes from imbot's platform descriptor table rather than a switch here: which platforms hand out full DM command access to anyone holding the bot token is a fact about the platform, and it belongs next to the rest of each platform's intrinsic metadata.
Types ¶
type AccessStore ¶
type AccessStore interface {
access.FactSource
GetCapability(context.Context, string, access.CapabilityName) (access.BotCapability, bool, error)
DiscoverDirectChat(context.Context, string, string, string) (access.DirectChat, error)
GetDirectChat(context.Context, string, string) (access.DirectChat, bool, error)
PairDirectChat(context.Context, string, string, string) error
DiscoverGroup(context.Context, string, string, string, string) (access.Group, error)
UpsertActor(context.Context, access.Actor) (access.Actor, error)
}
type Attached ¶
type Attached struct {
// OnMessage receives each inbound message via the host's dispatcher
// (after the host's own prompt-reply routing). nil = this consumer needs
// no inbound handling.
OnMessage OnMessage
// CommandRegistry drives platform menu / quick-action setup. The bot host
// applies it after the bot connects; nil skips menu setup.
CommandRegistry *imbot.CommandRegistry
// Cleanup runs when the bot stops (context cancelled / goroutine exits).
// nil = nothing to clean up.
Cleanup func()
}
Attached is the inbound wiring a Consumer hands back after binding to a freshly connected bot.
type BotSetting ¶
type BotSetting struct {
UUID string `json:"uuid,omitempty"` // UUID for bot identification
Name string `json:"name,omitempty"` // User-defined name for the bot
Platform string `json:"platform"` // Platform identifier
AuthType string `json:"auth_type"` // Auth type: token, oauth, qr
Auth map[string]string `json:"auth"` // Dynamic auth fields based on platform
ProxyURL string `json:"proxy_url,omitempty"` // Optional proxy URL
ChatIDLock string `json:"chat_id_lock,omitempty"` // Deprecated: retained for settings compatibility; access policy supersedes it.
BashAllowlist []string `json:"bash_allowlist,omitempty"` // Optional bash command allowlist
DefaultCwd string `json:"default_cwd,omitempty"` // Default working directory if no project bound
Enabled bool `json:"enabled"` // Whether this bot is enabled
Scenarios string `json:"scenarios,omitempty"` // Raw scenario/mount list (JSON, see remote/binding)
// DefaultAgent selects which agent configuration serves @cc for this bot:
// ""/"claude_code" = the main claude_code scenario, "claude_code:<id>" = a
// Claude Code profile — @cc then routes through the profiled scenario with
// the profile's unified/separate mode and env overrides, exactly like a
// local `tingly-box cc --profile <id>` launch.
DefaultAgent string `json:"default_agent,omitempty"`
// Output behavior settings
Verbose *bool `json:"verbose,omitempty"` // Send intermediate messages (nil = true default)
// SmartGuide model configuration (required for @tb agent)
SmartGuideProvider string `json:"smartguide_provider,omitempty"` // Provider UUID
SmartGuideModel string `json:"smartguide_model,omitempty"` // Model identifier
// RequirePairing enforces a TOFU pairing-code handshake before any DM is
// processed. Tri-state: explicit true/false wins; nil means "platform
// default" — enforced for token-DM platforms (telegram/discord/slack)
// where a leaked bot token alone gives full command access, and disabled
// elsewhere. Operators opt out by setting this to false explicitly.
RequirePairing *bool `json:"require_pairing,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
BotSetting represents bot configuration with platform-specific auth
func (BotSetting) IsRequirePairing ¶
func (b BotSetting) IsRequirePairing() bool
IsRequirePairing reports whether this bot requires per-chat pairing. When RequirePairing is nil, the answer depends on Platform: token-DM platforms default to enforced; OAuth/QR platforms default to off.
type Chat ¶
type Chat struct {
ChatID string `json:"chat_id"`
Platform string `json:"platform"`
ProjectPath string `json:"project_path,omitempty"`
ProjectHistory []string `json:"project_history,omitempty"` // MRU list of paths this chat has bound to
OwnerID string `json:"owner_id,omitempty"`
// Pairing (TOFU) — applies to direct messages only. Group chats continue
// to use the IsWhitelisted gate, but the operator who whitelisted the
// group must themselves be paired in DM with the same bot.
IsPaired bool `json:"is_paired,omitempty"`
PairedBotUUID string `json:"paired_bot_uuid,omitempty"`
PairedSenderID string `json:"paired_sender_id,omitempty"`
PairedAt time.Time `json:"paired_at,omitempty"`
// Group-specific
IsWhitelisted bool `json:"is_whitelisted"`
WhitelistedBy string `json:"whitelisted_by,omitempty"`
// Bash state
BashCwd string `json:"bash_cwd,omitempty"`
// CurrentAgent is which agent is driving the chat ("tingly-box" or "claude").
CurrentAgent string `json:"current_agent,omitempty"`
// Chat-level settings
Verbose *bool `json:"verbose,omitempty"` // Verbose mode: nil=use bot default, true=verbose, false=quiet
// Disabled is the inbound blocklist flag: a disabled chat's messages are
// dropped before any handler runs and the chat is excluded from the
// reachable list. Unlike deletion, the row survives auto-create paths —
// only an explicit enable clears it.
Disabled bool `json:"disabled,omitempty"`
DisabledAt time.Time `json:"disabled_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Chat is all state associated with an IM chat (direct or group): which project it is bound to, whether it is paired or whitelisted, and which agent is currently driving it.
This is the remote-owned domain type. The SQLite store in internal/data/db implements ChatStoreInterface against it directly (converting to/from its own GORM RemoteChatRecord) — same pattern as remote/session, where db imports the remote package that owns the domain type. Callers write bot.Chat everywhere.
Sessions are not part of a Chat: they are managed by SessionManager, keyed by the (ChatID, Agent, Project) binding.
func (*Chat) PushProjectHistory ¶
PushProjectHistory sets chat.ProjectPath and prepends it to ProjectHistory (deduped, capped). When the chat already had a ProjectPath that wasn't in the history yet, it is preserved one slot below so a fresh upgrade keeps the previous binding visible.
type ChatStoreInterface ¶
type ChatStoreInterface interface {
// GetChat retrieves a chat by ID
GetChat(chatID string) (*Chat, error)
// GetOrCreateChat gets a chat or creates it if not exists
GetOrCreateChat(chatID, platform string) (*Chat, error)
// UpsertChat creates or updates a chat
UpsertChat(chat *Chat) error
// UpdateChat updates specific fields of a chat
UpdateChat(chatID string, fn func(*Chat)) error
// BindProject binds a project to a chat
BindProject(chatID, platform, projectPath, ownerID string) error
// GetProjectPath retrieves the project path for a chat
GetProjectPath(chatID string) (string, bool, error)
// ListChatsByOwner lists all chats owned by a user
ListChatsByOwner(ownerID, platform string) ([]*Chat, error)
// ListChats returns the chat records this bot can reach on the given
// platform — i.e. those whose Platform field is set AND equals platform.
// Records with an empty or mismatched Platform are dropped at the source:
// the store key has no platform dimension, so an unattributed record
// cannot be proven to belong to this bot's channel and must not leak into
// its /chats list. Disabled chats are excluded unless includeDisabled is
// set. Used by the GET /bots/:bot/chats API so callers of the
// notify/interact endpoints can discover the channel-native chat_id they
// must pass in the request body.
ListChats(platform string, includeDisabled bool) ([]*Chat, error)
// ListChatProjectPaths returns the MRU project-path history for a chat.
ListChatProjectPaths(chatID string) ([]string, error)
// AddToWhitelist adds a chat to the whitelist
AddToWhitelist(chatID, platform, addedBy string) error
// RemoveFromWhitelist removes a chat from the whitelist
RemoveFromWhitelist(chatID string) error
// IsWhitelisted checks if a chat is whitelisted
IsWhitelisted(chatID string) bool
// SetBashCwd sets the bash working directory for a chat
SetBashCwd(chatID, cwd string) error
// GetBashCwd retrieves the bash working directory for a chat
GetBashCwd(chatID string) (string, bool, error)
// SetCurrentAgent sets the current agent for a chat. Creates the chat
// row if it doesn't yet exist so that @cc/@tb handoff state persists
// even on fresh chats that haven't been bound (/cd) or paired (/bind)
// yet. Pass an empty platform when the caller doesn't have one — the
// field will be filled in later by BindProject/SetPaired.
SetCurrentAgent(chatID, platform, agentType string) error
// GetCurrentAgent retrieves the current agent for a chat
GetCurrentAgent(chatID string) (string, error)
// SetPaired marks a chat as paired with a specific bot UUID and sender.
// The chat is created if it does not yet exist.
SetPaired(chatID, platform, botUUID, senderID string) error
// ClearPaired removes the pairing on a chat. Other state on the chat is
// preserved.
ClearPaired(chatID string) error
// IsChatPaired reports whether the chat is paired with the given bot UUID.
IsChatPaired(chatID, botUUID string) bool
// DeleteChat hard-deletes the chat row. All chat state (pairing,
// whitelist, project binding) is gone; a new message from the same chat
// recreates it fresh via the normal auto-create path. Sessions are
// untouched. Deleting a missing chat is a no-op.
DeleteChat(chatID string) error
// SetChatDisabled toggles the inbound blocklist flag. A disabled chat's
// messages are dropped before any handler runs and the chat is excluded
// from the reachable list. The row survives auto-create paths — only an
// explicit enable clears the flag.
SetChatDisabled(chatID string, disabled bool) error
// IsChatDisabled reports the blocklist flag. Missing chat → false.
IsChatDisabled(chatID string) bool
}
ChatStoreInterface defines the interface for chat persistence, keeping the bot package independent of where chats are actually stored.
type Consumer ¶
type Consumer interface {
// Name identifies the purpose, e.g. "remote_agent" or "notify".
Name() string
// Mounted reports whether this purpose is mounted on the given bot, based
// on its settings (typically the Scenarios mount list). The lifecycle only
// attaches mounted consumers, and a bot with no mounted consumer at all
// does not run — "no mount, no bot".
Mounted(setting BotSetting) bool
// Attach binds to a connected bot and returns its inbound wiring.
// prompter is the bot's shared channel prompter (host-owned; replies to
// its prompts are routed by the host before any consumer sees them).
Attach(
ctx context.Context,
setting BotSetting,
mgr *imbot.Manager,
prompter *imchannel.IMPrompter,
chatStore ChatStoreInterface,
pairing *PairingManager,
) (*Attached, error)
}
Consumer is a purpose that uses a bot's channel. The bot itself is a connection resource; its channel — the send/prompt surface plus the shared prompter and reply routing — is host infrastructure that exists whenever the bot runs. Consumers are the channel's users: remote_agent (control Claude Code / SmartGuide from chat) and notify (scenario notifications routed via /tingly/:scenario/notify) today; future consumers implement the same interface and are injected the same way.
Naming: the bot is the resource, a Consumer uses it — hence "consumer", not "provider" (which in this codebase already means an LLM provider).
The bot host owns the generic pieces (imbot.Manager, chat store, pairing, channel registry, the shared channel prompter) and passes them to Attach; a consumer owns only its purpose-specific dependencies (agent service, sessions, SmartGuide) captured at construction. None of Attach's parameters reference a purpose's machinery, which is what keeps the lifecycle decoupled.
func NewNotifyConsumer ¶
func NewNotifyConsumer() Consumer
NewNotifyConsumer builds the notify consumer. It has no dependencies.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager manages the lifecycle of running bot instances
func NewManager ¶
func NewManager(store SettingsStore, consumers ...Consumer) *Manager
NewManager creates a new bot manager with a settings store and the consumers that supply every bot's behavior. The consumers are the decoupling seam: swapping them changes what a bot does without touching the lifecycle here.
Order matters twice: inbound messages are dispatched (after the host's own prompt-reply routing) to each mounted consumer in this order until one claims the message, so the catch-all (remote_agent) goes last; and a bot runs only while at least one consumer reports Mounted for it.
func (*Manager) ChatStore ¶
func (m *Manager) ChatStore() (ChatStoreInterface, error)
ChatStore returns the chat store shared by every bot this manager runs.
The store is not owned here — whoever injected it (the StoreManager, in the server) closes it. Callers must not.
func (*Manager) PairingManager ¶
func (m *Manager) PairingManager() *PairingManager
PairingManager returns the manager's PairingManager instance. Used by CLI helpers that mint, rotate, or revoke pairing codes.
func (*Manager) SetAccessStore ¶
func (m *Manager) SetAccessStore(store AccessStore)
func (*Manager) SetCapabilityStore ¶
func (m *Manager) SetCapabilityStore(store interface { GetCapability(context.Context, string, access.CapabilityName) (access.BotCapability, bool, error) })
SetCapabilityStore makes explicit BotCapability rows the lifecycle source of truth. The legacy Mounted method remains only for standalone callers that have not wired the final-state persistence layer.
func (*Manager) SetChannelRegistry ¶
SetChannelRegistry wires a remote channel registry so each running bot exposes itself as a remote.channel.Channel reachable from /tingly/:scenario scenario plugins. Safe to call once at startup before any bot is started.
func (*Manager) SetChatStore ¶
func (m *Manager) SetChatStore(store ChatStoreInterface)
SetChatStore injects the chat store every bot will share. Call it before starting any bot; Start fails without one.
func (*Manager) StartEnabled ¶
StartEnabled starts all enabled bots
type OnMessage ¶
OnMessage is a consumer's inbound message callback. It returns true when the consumer consumed (claimed) the message; the bot host dispatches each inbound message through the mounted consumers in order and stops at the first claim. A catch-all consumer (remote_agent) always returns true and therefore sits last in the dispatch order.
func AuthorizationGate ¶
func AuthorizationGate(store AccessStore, authorizer access.Authorizer, legacyChats ChatStoreInterface, requirePairing bool, pendingForChat func(string) (access.CapabilityName, access.ActionName), notifyDeniedPromptReply func(msg imbot.Message, platform imbot.Platform, botUUID string)) OnMessage
authorizationGate is the one production inbound seam for text, callbacks, and files. A denied message is claimed here and cannot reach a Capability. AuthorizationGate builds the inbound authorization seam. notifyDeniedPromptReply, when non-nil, is invoked for a denied message that was answering this bot's own pending prompt (pendingForChat reported a pending action for the chat). Such a denial must not be silent: the bot itself posted the prompt into the chat, so telling the sender they cannot answer it leaks nothing and prevents a dead-end where the prompt hangs until timeout with no explanation.
func DisabledChatGate ¶
func DisabledChatGate(chatStore ChatStoreInterface) OnMessage
disabledChatGate builds the bot-host-level inbound handler that drops all traffic from a disabled chat before any other handler — including promptReplyRouter — gets a chance to claim it. It must run first: a disabled chat with an outstanding permission prompt would otherwise have its "perm" callback or text answer claimed by promptReplyRouter, which sits ahead of the per-consumer blocklist check in remoteagent.HandleMessage and would generate exactly the outbound reply disable exists to suppress. Silently: replying would give a blocked party a probe signal.
This is the managed-mode gate. remoteagent.HandleMessage keeps its own copy of the IsChatDisabled check for the standalone / host-less path (CLI + test harness), which never enters this dispatch chain — two paths into the handler, two gates. See the spec (bot-chat-lifecycle-collapse §3b).
type PairingManager ¶
type PairingManager = security.PairingManager
Type alias — fully transparent to callers.
type SettingsStore ¶
type SettingsStore interface {
// GetSettingsByUUID returns the settings record for a bot.
GetSettingsByUUID(uuid string) (BotSetting, error)
// ListEnabledSettings returns all enabled settings records.
ListEnabledSettings() ([]BotSetting, error)
}
SettingsStore is the read surface the bot lifecycle needs from the settings store. It returns remote-owned BotSetting values; the host bridges its own persistence type onto this interface (see remote/control/adapter).