bot

package
v0.260806.1 Latest Latest
Warning

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

Go to latest
Published: Aug 6, 2026 License: MPL-2.0 Imports: 15 Imported by: 0

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

View Source
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, …).

View Source
const ProjectHistoryCap = 20

ProjectHistoryCap bounds the per-chat MRU list so storage stays bounded and the /project list stays readable.

Variables

View Source
var (
	ErrPairCodeMissing  = security.ErrPairCodeMissing
	ErrPairCodeExpired  = security.ErrPairCodeExpired
	ErrPairCodeMismatch = security.ErrPairCodeMismatch
	ErrPairLocked       = security.ErrPairLocked
)

Error sentinels forwarded from imbot/security.

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

func PlatformDefaultsRequirePairing(platform string) bool

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

func (c *Chat) PushProjectHistory(path string)

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) IsRunning

func (m *Manager) IsRunning(uuid string) bool

IsRunning checks if a bot is running

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

func (m *Manager) SetChannelRegistry(reg *channel.Registry)

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) Start

func (m *Manager) Start(parentCtx context.Context, uuid string) error

Start starts a bot by UUID

func (*Manager) StartEnabled

func (m *Manager) StartEnabled(ctx context.Context) error

StartEnabled starts all enabled bots

func (*Manager) Stop

func (m *Manager) Stop(uuid string)

Stop stops a bot by UUID

func (*Manager) StopAll

func (m *Manager) StopAll()

StopAll stops all running bots

func (*Manager) Sync

func (m *Manager) Sync(ctx context.Context) error

Sync ensures the running bots match the enabled settings in the store. It starts bots that are enabled but not running, and stops bots that are running but disabled.

func (*Manager) WaitForStop

func (m *Manager) WaitForStop(uuid string, timeout time.Duration) bool

WaitForStop waits for a bot to finish stopping (with timeout)

type OnMessage

type OnMessage func(msg imbot.Message, platform imbot.Platform, botUUID string) bool

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

Jump to

Keyboard shortcuts

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