bot

package
v0.11.0 Latest Latest
Warning

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

Go to latest
Published: Aug 17, 2026 License: MIT Imports: 52 Imported by: 0

Documentation

Index

Constants

View Source
const (
	ContextSourceSystem  = "system"
	ContextSourceProfile = "profile"
	ContextSourceTopics  = "topics"
	ContextSourceSession = "session"
)

Context source types

Variables

View Source
var ErrRichMessageRejected = errors.New("rich message rejected")

ErrRichMessageRejected marks a confirmed transport-side rejection where it is safe to resend the same source through the legacy renderer. Ambiguous network failures must never be wrapped with this sentinel: retrying those as legacy could duplicate a message that the server actually accepted.

Functions

func DecActiveSessions added in v0.3.0

func DecActiveSessions()

DecActiveSessions decrements the active sessions counter by 1.

func DetectAssistantInjection added in v0.11.0

func DetectAssistantInjection(text string) bool

DetectAssistantInjection reports whether text looks like a block of instructions addressed to an AI assistant (a forwarded "profile" or "system data" block with a persist/apply command) rather than a human message. Both the assistant-target and the command signal must be present.

func IncActiveSessions added in v0.3.0

func IncActiveSessions()

IncActiveSessions increments the active sessions counter by 1.

func IncMessageTelegramRichDraftOverflow added in v0.11.0

func IncMessageTelegramRichDraftOverflow()

IncMessageTelegramRichDraftOverflow records one preview-only source cap hit.

func IncMessageTelegramRichDraftTerminalCatchup added in v0.11.0

func IncMessageTelegramRichDraftTerminalCatchup(outcome richDraftTerminalCatchupOutcome)

IncMessageTelegramRichDraftTerminalCatchup records one terminal preview outcome. Callers pass only the package's closed outcome constants.

func RecordContextTokens added in v0.3.0

func RecordContextTokens(tokens int)

RecordContextTokens records context size in tokens.

func RecordContextTokensBySource added in v0.3.3

func RecordContextTokensBySource(userID storage.ScopeID, source string, tokens int)

RecordContextTokensBySource records tokens by context source.

func RecordMessageLLM added in v0.3.8

func RecordMessageLLM(userID storage.ScopeID, totalDuration float64, callCount int)

RecordMessageLLM records total LLM duration and call count for a message.

func RecordMessageLLMFirstToken added in v0.9.0

func RecordMessageLLMFirstToken(userID storage.ScopeID, durationSeconds float64)

RecordMessageLLMFirstToken records time-to-first-content in stream mode. Only call when streaming actually produced at least one content delta; reasoning-only or empty responses should not be observed here.

func RecordMessageProcessing added in v0.3.0

func RecordMessageProcessing(userID storage.ScopeID, durationSeconds float64, success bool)

func RecordMessageReaction added in v0.3.8

func RecordMessageReaction(userID storage.ScopeID, duration float64)

RecordMessageReaction records SetMessageReaction duration.

func RecordMessageTelegram added in v0.3.8

func RecordMessageTelegram(userID storage.ScopeID, totalDuration float64, callCount int)

RecordMessageTelegram records total Telegram send duration and call count for a message.

func RecordMessageTelegramDraftCount added in v0.11.0

func RecordMessageTelegramDraftCount(userID storage.ScopeID, count int)

RecordMessageTelegramDraftCount records ephemeral Rich Message preview calls. A draft count never implies persistent delivery.

func RecordMessageTelegramEditCount added in v0.9.0

func RecordMessageTelegramEditCount(userID storage.ScopeID, count int)

RecordMessageTelegramEditCount records the number of editMessageText calls the streaming sink issued for a single message turn.

func RecordMessageTelegramRichDraftContentSnapshotCount added in v0.11.0

func RecordMessageTelegramRichDraftContentSnapshotCount(count int)

RecordMessageTelegramRichDraftContentSnapshotCount records successful draft snapshots that advanced the accepted content prefix for one turn.

func RecordMessageTools added in v0.3.8

func RecordMessageTools(userID storage.ScopeID, totalDuration float64, callCount int)

RecordMessageTools records total tool execution duration and call count for a message.

func RecordResponseFlag added in v0.10.2

func RecordResponseFlag(userID storage.ScopeID, emoji string)

RecordMessageProcessing records message processing metrics. RecordResponseFlag records one user-flagged bad reply.

func SetActiveSessions added in v0.3.0

func SetActiveSessions(count int)

SetActiveSessions sets the current number of active sessions.

Types

type Bot

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

func NewBot

func NewBot(logger *slog.Logger, api telegram.BotAPI, cfg *config.Config, userRepo storage.UserRepository, msgRepo storage.MessageRepository, statsRepo storage.StatsRepository, factRepo storage.FactRepository, factHistoryRepo storage.FactHistoryRepository, peopleRepo storage.PeopleRepository, orClient llm.Client, ragService *rag.Service, contextService *agent.ContextService, translator *i18n.Translator) (*Bot, error)

func (*Bot) API

func (b *Bot) API() telegram.BotAPI

func (*Bot) ForceCloseSession added in v0.3.0

func (b *Bot) ForceCloseSession(ctx context.Context, userID storage.ScopeID) (int, error)

ForceCloseSession immediately processes unprocessed messages for a user into topics.

func (*Bot) ForceCloseSessionWithProgress added in v0.3.0

func (b *Bot) ForceCloseSessionWithProgress(ctx context.Context, userID storage.ScopeID, onProgress rag.ProgressCallback) (*rag.ProcessingStats, error)

ForceCloseSessionWithProgress immediately processes unprocessed messages with progress reporting.

func (*Bot) GetActiveSessions added in v0.3.0

func (b *Bot) GetActiveSessions() ([]rag.ActiveSessionInfo, error)

GetActiveSessions returns information about active sessions (unprocessed messages) for all users.

func (*Bot) HandleIncoming added in v0.10.0

func (b *Bot) HandleIncoming(im IncomingMessage)

HandleIncoming is the transport-neutral ingestion entry: it resolves the internal scope id for the message and feeds it to the message grouper. Both the Telegram path (ProcessUpdate) and future transports (Mattermost WS loop) converge here.

func (*Bot) HandleReaction added in v0.10.2

func (b *Bot) HandleReaction(ir IncomingReaction)

HandleReaction records a flag when an authorized user reacts to one of the bot's replies. Telegram delivers message_reaction for any message in a DM (verified), so we filter to bot replies by resolving the reacted message id to a stored assistant reply; a miss (the user's own / a pre-feature message) is silently ignored. Only newly-added reactions flag — removals are ignored, matching the "any reaction = flag, no un-flag" product decision.

func (*Bot) HandleUpdate

func (b *Bot) HandleUpdate(ctx context.Context, rawUpdate json.RawMessage, remoteAddr string)

func (*Bot) HandleUpdateAsync added in v0.2.0

func (b *Bot) HandleUpdateAsync(ctx context.Context, rawUpdate json.RawMessage, remoteAddr string)

HandleUpdateAsync starts processing a raw update in a goroutine. It properly handles WaitGroup to ensure graceful shutdown. Used by webhook handler.

func (*Bot) IsAllowedScope added in v0.10.0

func (b *Bot) IsAllowedScope(scopeID storage.ScopeID) bool

IsAllowedScope reports whether a scope id (the partition key used in the web UI and storage) belongs to an allowlisted user on ANY transport. The download handler uses it to authorize artifact access transport-agnostically: scope ids are derived as PassthroughScopeID(transport, nativeID), so we re-derive the allowed set from each transport's own allowlist rather than assuming Telegram.

NOTE: valid while scopes are pure passthrough(transport, native). Once a principal resolver remaps scopes (Variant C), this must consult the scope/ principal store instead of the config-derived set.

func (*Bot) ProcessUpdate

func (b *Bot) ProcessUpdate(ctx context.Context, update *telegram.Update, source string)

func (*Bot) ProcessUpdateAsync added in v0.2.0

func (b *Bot) ProcessUpdateAsync(ctx context.Context, update *telegram.Update, source string)

ProcessUpdateAsync starts processing an update in a goroutine. It properly handles WaitGroup to ensure graceful shutdown.

func (*Bot) SendTestMessage added in v0.3.0

func (b *Bot) SendTestMessage(ctx context.Context, userID storage.ScopeID, text string, saveToHistory bool) (*rag.TestMessageResult, error)

SendTestMessage sends a test message through the bot pipeline without Telegram. It returns detailed metrics for debugging purposes.

func (*Bot) SetAgentLogger added in v0.4.8

func (b *Bot) SetAgentLogger(logger *agentlog.Logger)

SetAgentLogger sets the agent logger for debug logging

func (*Bot) SetArtifactRepo added in v0.6.0

func (b *Bot) SetArtifactRepo(repo storage.ArtifactRepository)

SetArtifactRepo sets the artifact repository for linking artifacts to messages

func (*Bot) SetFetcher added in v0.10.3

func (b *Bot) SetFetcher(f fetch.Fetcher)

SetFetcher wires the web-page fetcher so the read_url tool becomes operational. Unwired, the tool tells the LLM page reading is unavailable.

func (*Bot) SetFileHandler added in v0.6.0

func (b *Bot) SetFileHandler(handler files.FileSaver)

SetFileHandler sets the optional file handler for artifact saving

func (*Bot) SetFileProcessor added in v0.6.0

func (b *Bot) SetFileProcessor(processor *files.Processor)

SetFileProcessor replaces the file processor (for testing).

func (*Bot) SetFileStorage added in v0.8.0

func (b *Bot) SetFileStorage(fs files.Storage)

SetFileStorage wires the artifact blob store used by the generate_image tool to persist output PNGs and by the media-reply path to read them.

func (*Bot) SetFlagRepo added in v0.10.2

func (b *Bot) SetFlagRepo(repo storage.FlagRepository)

SetFlagRepo wires the repository for user-flagged bad replies. When unset, inbound reactions are still received but no flag is recorded.

func (*Bot) SetImageGenerator added in v0.8.0

func (b *Bot) SetImageGenerator(gen tools.ImageGenerator)

SetImageGenerator wires the image-generation agent so the generate_image tool becomes operational. No-op if the agent is nil — the tool simply returns a "not configured" error to the LLM.

func (*Bot) SetLaplaceAgent added in v0.5.0

func (b *Bot) SetLaplaceAgent(agent *laplace.Laplace)

SetLaplaceAgent sets the Laplace chat agent

func (*Bot) SetPrincipalResolver added in v0.10.0

func (b *Bot) SetPrincipalResolver(r PrincipalResolver)

SetPrincipalResolver wires principal identity resolution for the active transport's DMs. Leaving it nil keeps the passthrough behavior (Telegram, and any transport that hasn't opted into principal resolution).

func (*Bot) SetReactorAgent added in v0.10.1

func (b *Bot) SetReactorAgent(a agent.Agent)

SetReactorAgent sets the reactor agent that decides emoji reactions.

func (*Bot) SetRenderer added in v0.10.0

func (b *Bot) SetRenderer(r Renderer)

SetRenderer swaps the wire-format renderer to match the active transport.

func (*Bot) SetScopeRepository added in v0.10.0

func (b *Bot) SetScopeRepository(repo identityStore)

SetScopeRepository wires the identity store (scope/identity map + principal & channel get-or-create), required for non-Telegram transports.

func (*Bot) SetTransport added in v0.10.0

func (b *Bot) SetTransport(t Transport)

SetTransport swaps the output/identity transport (used to install the Mattermost/Time transport for the work instance).

func (*Bot) SetWebhook

func (b *Bot) SetWebhook(webhookURL, secretToken string) error

func (*Bot) StartMattermostIngestion added in v0.10.0

func (b *Bot) StartMattermostIngestion(client *mattermost.Client)

StartMattermostIngestion consumes "posted" events from the client and feeds them into the neutral pipeline. It returns when the client closes its events channel (on ctx cancellation), making shutdown deterministic.

mmShouldProcess drops the bot's own/system posts. Authorization (the SSO/ allowlist access gate) and channel reply-gating are owned by HandleIncoming, so both DMs and channel posts pass through here.

func (*Bot) Stop

func (b *Bot) Stop()

type Capabilities added in v0.10.0

type Capabilities struct {
	MaxMessageLen         int    // Telegram 4096 UTF-16; Mattermost MaxPostSize (runtime)
	ParseMode             string // "HTML" (Telegram) | "" native markdown (Mattermost)
	SupportsLatex         bool   // true when the selected output path renders LaTeX natively
	SupportsStreaming     bool   // Telegram: legacy edits or ephemeral rich drafts | Mattermost false
	SupportsRichMessages  bool   // Telegram Bot API Rich Messages (opt-in) | Mattermost false
	SupportsReactions     bool   // Telegram true | Mattermost true (by emoji name)
	SupportsMedia         bool   // can SendMedia deliver files
	MaxMediaItemsPerGroup int    // Telegram 10; Mattermost 5 (files per post)
	EmojiStyle            string // "unicode" (Telegram) | "shortcode" (Mattermost)
	MaxFileSize           int64  // 0 = unset/unlimited (files are Phase 4)
	// AvailableReactions are the reaction tokens the bot may use on this
	// transport: unicode emoji for Telegram (the fixed Bot API set), emoji
	// shortcode names for Mattermost. Empty disables reactions.
	AvailableReactions []string
}

Capabilities describes per-transport rendering and feature support. The core branches on these, never on Kind(), so transport-specific behavior stays declarative.

type Chat

type Chat struct {
	ID       int64  `json:"id"`
	Type     string `json:"type"`
	Title    string `json:"title"`
	Username string `json:"username"`
}

Chat represents a chat.

type Document

type Document struct {
	FileID       string     `json:"file_id"`
	FileUniqueID string     `json:"file_unique_id"`
	Thumbnail    *PhotoSize `json:"thumbnail,omitempty"`
	FileName     string     `json:"file_name,omitempty"`
	MimeType     string     `json:"mime_type,omitempty"`
	FileSize     int        `json:"file_size,omitempty"`
}

Document represents a general file (as opposed to photos, voice messages and audio files).

type FileHandler added in v0.6.0

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

FileHandler coordinates file saving with deduplication.

func NewFileHandler added in v0.6.0

func NewFileHandler(
	storage files.Storage,
	artifactRepo storage.ArtifactRepository,
	logger *slog.Logger,
) *FileHandler

NewFileHandler creates a new file handler.

func (*FileHandler) SaveFile added in v0.6.0

func (fh *FileHandler) SaveFile(
	ctx context.Context,
	userID storage.ScopeID,
	messageID int64,
	fileType string,
	originalName string,
	mimeType string,
	reader io.Reader,
	messageText string,
	skipExtraction bool,
) (*int64, error)

SaveFile saves a Telegram file as an artifact. Returns artifact ID (existing on deduplication, new on creation) and any error. messageText is the text content of the message (msg.Text or msg.Caption) for context (v0.6.0). skipExtraction retains the raw file (bytes + content_hash + row) for reproducibility/replay but creates the row in the 'retained' state instead of 'pending', so the Extractor never processes it and it never gets a summary or embedding — keeping trivial files (e.g. short voice notes) out of RAG while still leaving them replayable. See saveVoiceArtifact.

type ForwardInfo added in v0.10.0

type ForwardInfo struct {
	SenderID  string // forwarded sender's native id (Telegram int64 as string)
	FirstName string
	LastName  string
	Username  string
	IsBot     bool
	IsUser    bool // true only when forwarded from a user (not channel/hidden)
}

ForwardInfo carries the structured sender of a forwarded message for the People social graph. Telegram-only in v0.10 (Mattermost has no forward origin with user identity); nil for non-forwarded messages.

type IncomingMessage added in v0.10.0

type IncomingMessage struct {
	ConversationID string // Telegram chat.ID (stringified) | Mattermost channel_id
	SenderID       string // Telegram From.ID (stringified) | Mattermost 26-char user id
	MessageID      string // transport message/post id
	Text           string // user text (Telegram Text or Caption, merged)
	// DetectionText is the transport's raw visible text before typed metadata
	// projection. It is used only by deterministic safety gates; it is never
	// sent to the model or persisted instead of Text. Empty means Text is the
	// appropriate detection view (for example Mattermost or rich-only input).
	DetectionText       string
	SenderDisplay       string // human-readable sender ("Name (@handle)") for logs
	ConversationDisplay string // human-readable channel name (channel scopes); "" for DMs/Telegram
	Prefix              string // pre-built display prefix ("[Name (time)]" or forwarded-from)
	ThreadRoot          string // Telegram MessageThreadID (forum) | Mattermost root_id; "" = top level
	IsDirect            bool   // DM (Telegram private chat | Mattermost channel_type==D)
	Mention             bool   // bot mentioned (for channels); always acted on in DMs
	ReplyToBot          bool   // message replies to / quotes a bot message (channel reply-gating)
	SentAt              time.Time
	Files               []files.IncomingFile
	Forward             *ForwardInfo // Telegram-only forwarded-sender info; nil otherwise
	Ingress             *IngressMetadata
	// RichEgressEligible is set only by the Telegram adapter after it has
	// verified a plain private-chat context. Business/direct-message topics and
	// groups stay legacy until their routing identifiers are modeled end to end.
	RichEgressEligible bool
}

IncomingMessage is the transport-neutral envelope that flows through the message grouper and processing pipeline. Telegram and Mattermost/Time each map their native update into this shape at the ingestion boundary; the core never sees a transport-specific message type.

type IncomingReaction added in v0.10.2

type IncomingReaction struct {
	ConversationID string   // transport-native chat/channel id
	SenderID       string   // the reacting user's transport-native id
	MessageID      string   // transport-native id of the reacted message
	OldEmojis      []string // emoji set before the change
	NewEmojis      []string // emoji set after the change
	IsDirect       bool     // DM scope
}

IncomingReaction is the transport-neutral envelope for a reaction a user added to (or removed from) a message. Only Telegram populates it today (ProcessUpdate → incomingReactionFromTelegram); a future Mattermost WS loop can converge on HandleReaction. OldEmojis/NewEmojis are the reaction sets before/after the change, so a handler can tell additions from removals.

type IngressMetadata added in v0.11.0

type IngressMetadata struct {
	Kind           string // fixed metric value, currently "rich"
	Disposition    string // processable | partial | unsupported | invalid
	HasVisibleText bool   // excludes generated media/failure markers
	BlockCount     int
	MediaCount     int
	Unknown        bool
}

IngressMetadata carries bounded, content-free classification from a native transport decoder into the grouped-turn pipeline. It is currently populated for Telegram Rich Messages; nil keeps every legacy transport byte-compatible.

type MMRenderer added in v0.10.0

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

MMRenderer renders canonical markdown for Mattermost: split on the ###SPLIT### delimiter, fix list numbering, and hard-split oversized parts at MaxPostSize. Markdown (incl. LaTeX math) is passed through unchanged — Time renders it natively via KaTeX; the system prompt steers the model toward clean $…$.

func NewMattermostRenderer added in v0.10.0

func NewMattermostRenderer(maxPostSize int, logger *slog.Logger) *MMRenderer

NewMattermostRenderer builds the markdown pass-through renderer. maxPostSize is the server's limit; a safety margin is subtracted internally.

func (*MMRenderer) Render added in v0.10.0

func (r *MMRenderer) Render(_ context.Context, text string) ([]string, error)

func (*MMRenderer) RenderCaption added in v0.10.1

func (r *MMRenderer) RenderCaption(_ context.Context, text string) (string, string)

RenderCaption fits the start of the response into the post budget; markdown passes through unchanged (Mattermost renders it natively), so a rune split is exact — no expansion to account for.

type MMTransport added in v0.10.0

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

MMTransport adapts the Mattermost/Time client to the neutral Transport interface. Output-only: ingestion runs via startMattermostIngestion.

func NewMattermostTransport added in v0.10.0

func NewMattermostTransport(client *mattermost.Client, cfg *config.Config, logger *slog.Logger) *MMTransport

NewMattermostTransport builds the Mattermost output adapter.

func (*MMTransport) AllowlistConfigured added in v0.10.0

func (t *MMTransport) AllowlistConfigured() bool

func (*MMTransport) Capabilities added in v0.10.0

func (t *MMTransport) Capabilities() Capabilities

func (*MMTransport) IsAllowed added in v0.10.0

func (t *MMTransport) IsAllowed(nativeSenderID string) bool

IsAllowed checks the native Mattermost sender id (26-char string) against the configured allowlist. Empty allowlist rejects everyone (fail closed) — in SSO mode the caller treats an unconfigured allowlist as "all trusted senders" instead, see Bot.authorizeSender.

func (*MMTransport) Kind added in v0.10.0

func (t *MMTransport) Kind() string

func (*MMTransport) SendMedia added in v0.10.0

func (t *MMTransport) SendMedia(ctx context.Context, m OutgoingMedia) (string, error)

SendMedia uploads each item to the channel and posts them with the caption. Mattermost caps attachments at mmMaxFilesPerPost per post, so larger batches are split across posts; the caption rides the first post only. The post threads under ThreadRoot (or ReplyTo when ThreadRoot is empty), mirroring SendText. Markdown is passed through (MM renders it natively).

func (*MMTransport) SendText added in v0.10.0

func (t *MMTransport) SendText(ctx context.Context, r OutgoingResponse) (string, error)

SendText posts one rendered markdown chunk. It threads under ThreadRoot (or anchors to ReplyTo when ThreadRoot is empty) and sends a deterministic idempotency key so a retried send is collapsed server-side.

func (*MMTransport) SendTyping added in v0.10.0

func (t *MMTransport) SendTyping(ctx context.Context, conversationID string) error

func (*MMTransport) SetReaction added in v0.10.0

func (t *MMTransport) SetReaction(ctx context.Context, _, messageID, emoji string) error

SetReaction adds the given shortcode reaction to the post. The emoji must be one of mmReactions.

type MattermostPrincipalResolver added in v0.10.0

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

MattermostPrincipalResolver implements federated-passive resolution for the Mattermost/Time transport. It reads the sender's GetUser record and links only externally-authenticated accounts.

Trust gates (the highest-stakes lines — one slip merges two people's memory):

  • auth_service == "" → local account → nil (isolation). NEVER linked.
  • trustedServices set and auth_service not in it → nil (isolation).
  • link key is the lowercased auth_data (the AD login), NEVER the email. A local user can self-claim an email in a trusted-looking domain, so email is stored as a principal attribute only.

objectGUID stays empty for now and is backfilled (additively) later via a Keycloak/LDAP lookup; principal dedup falls back to ad_login until then.

func NewMattermostPrincipalResolver added in v0.10.0

func NewMattermostPrincipalResolver(client mmUserLookup, trustedServices, trustedBots []string, logger *slog.Logger) *MattermostPrincipalResolver

NewMattermostPrincipalResolver builds the resolver. trustedServices restricts which auth_service values are trusted (empty = any non-empty). trustedBots lists bot-account usernames admitted despite failing the SSO gate (empty = no bots trusted — fail-closed). Both lists are lowercased/trimmed so the comparison against Mattermost's auth_service / username is case-insensitive (config "SAML" matches the wire value "saml"; "@AlertBot" matches "alertbot").

func (*MattermostPrincipalResolver) ClassifyBot added in v0.10.2

func (r *MattermostPrincipalResolver) ClassifyBot(ctx context.Context, nativeID string) (bool, bool, error)

ClassifyBot reports whether nativeID is a bot account and, if so, whether its username is on the trusted-bots allowlist. It is consulted only after the SSO trust gate (IsTrusted) has already rejected the sender, so the GetUser here is a cache hit. An empty allowlist trusts no bots (fail-closed): slices.Contains over an empty slice is false, by design — there is deliberately no "empty = trust all bots" branch.

func (*MattermostPrincipalResolver) Invalidate added in v0.10.0

func (r *MattermostPrincipalResolver) Invalidate(nativeID string)

Invalidate drops the resolver's cached view of nativeID so the next trust check re-reads the profile from the server. The bot calls this on a denial: the denied account may have just migrated to SSO, and re-reading on its next message gives instant recovery instead of waiting out the profile-cache TTL.

func (*MattermostPrincipalResolver) IsTrusted added in v0.10.0

func (r *MattermostPrincipalResolver) IsTrusted(ctx context.Context, nativeID string) (bool, error)

IsTrusted reports whether the sender is an externally-authenticated account this resolver trusts (the access gate). It checks only auth_service — a non-empty value that, when a trustedServices allowlist is configured, is a member of it. It deliberately skips the ad_login/email checks Resolve applies: access is broader than linkability (an SSO user with an unusable auth_data still gets in, just on an isolated scope).

func (*MattermostPrincipalResolver) Resolve added in v0.10.0

Resolve returns the principal attributes for nativeID, or nil if the sender is not trusted-linkable (local account / untrusted service / no AD login).

type Message

type Message struct {
	MessageID        int            `json:"message_id"`
	From             *User          `json:"from"`
	Chat             *Chat          `json:"chat"`
	Date             int            `json:"date"`
	Text             string         `json:"text"`
	Caption          string         `json:"caption"`
	Photo            []PhotoSize    `json:"photo"`
	Document         *Document      `json:"document"`
	Voice            *Voice         `json:"voice"`
	ForwardOrigin    *MessageOrigin `json:"forward_origin"`
	IsCommand        bool           `json:"-"` // This will be determined manually
	Command          string         `json:"-"` // This will be determined manually
	CommandArguments string         `json:"-"` // This will be determined manually
}

Message represents a message.

type MessageGroup

type MessageGroup struct {
	Messages   []IncomingMessage
	Timer      *time.Timer
	CancelFunc context.CancelFunc
	UserID     storage.ScopeID
	StartedAt  time.Time // When the first message in this group was received
}

MessageGroup represents a collection of messages processed together as one turn. UserID is the resolved internal scope id (the storage partition key) — for Telegram it equals the sender id (passthrough); for a channel it is the channel's scope id, shared by all participants.

type MessageGrouper

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

MessageGrouper handles the grouping of incoming messages.

func NewMessageGrouper

func NewMessageGrouper(b *Bot, logger *slog.Logger, turnWait time.Duration, onGroupReady func(ctx context.Context, group *MessageGroup)) *MessageGrouper

NewMessageGrouper creates a new MessageGrouper.

func (*MessageGrouper) AddMessage

func (mg *MessageGrouper) AddMessage(scopeID storage.ScopeID, im IncomingMessage)

AddMessage adds a new message to its group. scopeID is the resolved internal scope id (storage partition key); the grouping key is derived from it and the message (per-sender in channels — see groupKeyFor).

func (*MessageGrouper) ForceCloseSession added in v0.3.0

func (mg *MessageGrouper) ForceCloseSession(scopeID storage.ScopeID) bool

ForceCloseSession immediately processes and closes any active groups for the given scope id. Returns true if at least one group was found and closed. In a channel a scope may have several per-sender groups; all are closed.

func (*MessageGrouper) GetActiveSessions added in v0.3.0

func (mg *MessageGrouper) GetActiveSessions() []SessionInfo

GetActiveSessions returns information about all active message grouping sessions.

func (*MessageGrouper) Stop added in v0.2.0

func (mg *MessageGrouper) Stop()

Stop processes all pending message groups and waits for completion. Should be called during graceful shutdown.

type MessageOrigin

type MessageOrigin struct {
	Type            string `json:"type"`
	Date            int    `json:"date"`
	SenderUser      *User  `json:"sender_user,omitempty"`
	SenderUserName  string `json:"sender_user_name,omitempty"`
	SenderChat      *Chat  `json:"sender_chat,omitempty"`
	AuthorSignature string `json:"author_signature,omitempty"`
	MessageID       int    `json:"message_id,omitempty"`
}

MessageOrigin is a union type that can be one of MessageOriginUser, MessageOriginHiddenUser, MessageOriginChat, or MessageOriginChannel.

func (*MessageOrigin) UnmarshalJSON

func (mo *MessageOrigin) UnmarshalJSON(data []byte) error

UnmarshalJSON is a custom unmarshaler for MessageOrigin to handle the union type.

type OutgoingMedia added in v0.10.0

type OutgoingMedia struct {
	ConversationID string
	ThreadRoot     string // keeps the media threaded
	ReplyTo        string // message id to reply to; first item only
	Caption        string // wire-format caption (may be empty)
	Items          []OutgoingMediaItem
}

OutgoingMedia is a transport-neutral media reply: one or more files sharing a single caption, threaded/replied like a text response. Caption is in the transport's wire format (HTML for Telegram, markdown for Mattermost) as produced by the transport's Renderer.RenderCaption, already fitted to the transport's caption budget; any overflow is delivered by the caller as a follow-up text message.

type OutgoingMediaItem added in v0.10.0

type OutgoingMediaItem struct {
	Data          []byte
	Filename      string
	MIME          string
	WireKind      OutgoingMediaWireKind // exact photo/document presentation; zero keeps legacy behavior
	AsDocument    bool                  // legacy document override, consulted only when WireKind is zero
	SourceOrdinal int                   // application-only 1-based generated-artifact slot; transports ignore it (zero tells planners to use item index+1)
}

OutgoingMediaItem is one file in an OutgoingMedia batch.

type OutgoingMediaWireKind added in v0.11.0

type OutgoingMediaWireKind string

OutgoingMediaWireKind selects the exact Telegram upload method for a media item. The zero value preserves the legacy size-threshold/AsDocument policy; durable delivery plans should always select Photo or Document explicitly. Transports without a photo/document distinction may ignore it.

const (
	OutgoingMediaWireKindLegacy   OutgoingMediaWireKind = ""
	OutgoingMediaWireKindPhoto    OutgoingMediaWireKind = "photo"
	OutgoingMediaWireKindDocument OutgoingMediaWireKind = "document"
)

type OutgoingResponse added in v0.10.0

type OutgoingResponse struct {
	ConversationID string
	Text           string
	ThreadRoot     string // set on every chunk to keep replies threaded
	ReplyTo        string // message id to reply to; set on the first chunk only
	Format         ResponseFormat
}

OutgoingResponse is one rendered, ready-to-send message chunk. Text is in the transport's wire format (HTML for Telegram, markdown for Mattermost) as produced by the transport's Renderer, unless Format selects another explicit representation.

type OutgoingRichMedia added in v0.11.0

type OutgoingRichMedia struct {
	ConversationID  string
	ThreadRoot      string
	ReplyTo         string
	HTMLParts       []string
	MediaGroupSizes []int
	Items           []OutgoingMediaItem
}

OutgoingRichMedia is an optional, transport-native composition of trusted media bytes and fully rendered Rich HTML parts. HTMLParts and MediaGroupSizes describe an alternating sequence:

HTMLParts[0], media group 0, HTMLParts[1], ... media group N, HTMLParts[N+1]

Items is the flattened media order and each positive MediaGroupSizes entry consumes that many consecutive items. Empty HTML parts are valid, including a media-only message. The model never supplies bytes or media references: callers resolve generated artifact IDs through user-isolated storage, and the transport injects the corresponding references.

RichMediaTransport is intentionally separate from Transport. Backends that can't embed media in a structured message keep the established SendMedia path without adding a meaningless implementation.

type PhotoSize

type PhotoSize struct {
	FileID       string `json:"file_id"`
	FileUniqueID string `json:"file_unique_id"`
	Width        int    `json:"width"`
	Height       int    `json:"height"`
	FileSize     int    `json:"file_size,omitempty"`
}

PhotoSize represents one size of a photo or a file / sticker thumbnail.

type PrincipalResolver added in v0.10.0

type PrincipalResolver interface {
	Resolve(ctx context.Context, nativeID string) (*storage.PrincipalInput, error)
	// IsTrusted reports whether the sender is an externally-authenticated (SSO)
	// account this resolver trusts — the access gate. It is intentionally looser
	// than Resolve: it does NOT require a linkable ad_login, so an SSO user with
	// an email-shaped or empty auth_data is granted access but still resolves to
	// an isolated scope (Resolve returns nil). Access and linkability are
	// distinct concerns.
	IsTrusted(ctx context.Context, nativeID string) (bool, error)
}

PrincipalResolver maps a transport-native DM sender to the principal (AD-backed person) behind it, for unified cross-transport memory. It returns nil — NOT an error — when the sender cannot be trusted-linked (a local account, no usable external subject); the caller then falls back to an isolated passthrough scope. A transport with no resolver wired never links at all (passthrough behavior).

Resolution is per-transport because the trust signal is transport-specific (Mattermost auth_service, later Talk/MAX equivalents).

type Renderer added in v0.10.0

type Renderer interface {
	Render(ctx context.Context, canonicalMarkdown string) (chunks []string, err error)
	// RenderCaption fits the start of a canonical-markdown response into the
	// transport's media caption budget (measured on the rendered wire format)
	// and returns the wire-format caption plus the remaining markdown to send
	// as follow-up text via Render.
	RenderCaption(ctx context.Context, canonicalMarkdown string) (wireCaption, overflowMarkdown string)
}

Renderer converts a canonical-markdown response into one or more wire-format chunks for a transport (HTML for Telegram, markdown for Mattermost), respecting the transport's message-length limit. The context carries the root processing span so renderers can record anomaly attributes (e.g. re-splits after HTML expansion).

type ResponseFormat added in v0.11.0

type ResponseFormat string

ResponseFormat identifies the wire format already present in OutgoingResponse.Text. The zero value keeps the established per-transport format; RichHTML is an explicit opt-in used only by Telegram Rich Messages.

const (
	ResponseFormatDefault  ResponseFormat = ""
	ResponseFormatRichHTML ResponseFormat = "telegram_rich_html"
)

type RichMediaTransport added in v0.11.0

type RichMediaTransport interface {
	SendRichMedia(ctx context.Context, m OutgoingRichMedia) (msgID string, err error)
}

RichMediaTransport is implemented by transports that can atomically persist trusted media and structured text as one message.

type SessionInfo added in v0.3.0

type SessionInfo struct {
	UserID       storage.ScopeID
	MessageCount int
	StartedAt    time.Time
	LastMessage  time.Time
}

SessionInfo represents information about an active message grouping session.

type TelegramRenderer added in v0.10.0

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

TelegramRenderer converts canonical markdown into wire-format HTML chunks: split on the ###SPLIT### delimiter, fix list numbering across parts, split oversized parts, then convert each chunk markdown -> HTML. Chunks whose rendered HTML exceeds the 4096 UTF-16 wire limit (tags and entity escaping expand the text unpredictably) are re-split with a smaller source budget; the terminal fallback is escaped plain text hard-split by UTF-16, so a reply is never lost to "message is too long".

func NewTelegramRenderer added in v0.10.0

func NewTelegramRenderer(logger *slog.Logger) *TelegramRenderer

NewTelegramRenderer builds the Telegram HTML renderer.

func (*TelegramRenderer) Render added in v0.10.0

func (r *TelegramRenderer) Render(ctx context.Context, text string) ([]string, error)

func (*TelegramRenderer) RenderCaption added in v0.10.1

func (r *TelegramRenderer) RenderCaption(ctx context.Context, text string) (string, string)

RenderCaption fits the start of the markdown response into Telegram's caption budget measured on the RENDERED HTML (markdown length is a poor proxy: tags and entity escaping expand the text). The split point shrinks by the observed expansion ratio until the HTML fits; overflow markdown is returned for the caller to send as follow-up text. The caption is always valid HTML — never raw markdown.

func (*TelegramRenderer) RenderSafeRichCaption added in v0.11.0

func (r *TelegramRenderer) RenderSafeRichCaption(ctx context.Context, text string) (string, string)

RenderSafeRichCaption keeps generated-media captions on the legacy media envelope while applying the same active-content policy as Rich Message output. Model-authored images, unsafe links and direct/bare mentions remain visible text but cannot become Telegram entities.

type TelegramTransport added in v0.10.0

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

TelegramTransport adapts the Telegram Bot API to the neutral Transport interface. It wraps the same telegram.BotAPI the bot already holds, so the Telegram send path is byte-identical to the pre-seam code.

func NewTelegramTransport added in v0.10.0

func NewTelegramTransport(api telegram.BotAPI, cfg *config.Config, translator *i18n.Translator, logger *slog.Logger) *TelegramTransport

NewTelegramTransport builds the Telegram output adapter.

func (*TelegramTransport) AllowlistConfigured added in v0.10.0

func (t *TelegramTransport) AllowlistConfigured() bool

func (*TelegramTransport) Capabilities added in v0.10.0

func (t *TelegramTransport) Capabilities() Capabilities

func (*TelegramTransport) IsAllowed added in v0.10.0

func (t *TelegramTransport) IsAllowed(nativeSenderID string) bool

func (*TelegramTransport) Kind added in v0.10.0

func (t *TelegramTransport) Kind() string

func (*TelegramTransport) SendMedia added in v0.10.0

func (t *TelegramTransport) SendMedia(ctx context.Context, m OutgoingMedia) (string, error)

SendMedia delivers a batch of files as Telegram photos and/or documents. It preserves the legacy send_media_response.go classification and caption policy for zero-kind items: payloads over the configured document threshold (or forced via AsDocument) go as documents preserving resolution. An explicit WireKind always wins over those heuristics. Photo and document kinds can't share a media group, so they're sent as separate batches. Each homogeneous batch is additionally capped at Telegram's ten-item limit. The caption (rendered to HTML) rides the first batch; the reply-to anchors that caption-bearing batch only.

func (*TelegramTransport) SendMediaPersistent added in v0.11.0

func (t *TelegramTransport) SendMediaPersistent(ctx context.Context, m OutgoingMedia) (persistentSendResult, error)

SendMediaPersistent executes exactly one Bot API media call and returns all stable IDs from that call. V2 planners must pre-split mixed photo/document sets and batches above Telegram's group limit before entering the ledger's non-idempotent sending state.

func (*TelegramTransport) SendRichMedia added in v0.11.0

func (t *TelegramTransport) SendRichMedia(ctx context.Context, m OutgoingRichMedia) (string, error)

SendRichMedia atomically uploads trusted generated-photo groups and persists them together with the complete Rich HTML response. One photo is a bare image block, 2-4 photos form a collage and 5-10 form a slideshow.

The photo block is injected after model Markdown has passed the allowlisted Rich HTML renderer. Consequently model-authored URLs can never become media side effects; only the bytes resolved from GeneratedArtifactIDs arrive here.

func (*TelegramTransport) SendText added in v0.10.0

SendText sends one rendered HTML chunk. Two last-resort recoveries keep a reply from being lost (both should be unreachable with a correct renderer, and are surfaced as bot.anomaly.* span attributes when they fire):

  • "can't parse entities": retried once as plain text (ParseMode cleared);
  • "message is too long": resent as plain text hard-split by UTF-16.

Both retries use a fresh, non-cancellable context.

func (*TelegramTransport) SendTextPersistent added in v0.11.0

func (t *TelegramTransport) SendTextPersistent(ctx context.Context, r OutgoingResponse) (string, error)

SendTextPersistent performs exactly one Bot API request and returns its stable ID. Delivery plans use it instead of SendText's compatibility retries, because a ledger operation must never conceal a second non-idempotent request.

func (*TelegramTransport) SendTyping added in v0.10.0

func (t *TelegramTransport) SendTyping(ctx context.Context, conversationID string) error

func (*TelegramTransport) SetReaction added in v0.10.0

func (t *TelegramTransport) SetReaction(ctx context.Context, conversationID, messageID, emoji string) error

SetReaction adds the given emoji reaction to the message. The emoji must be one of telegramReactionEmoji (API-exact form).

type Transport added in v0.10.0

type Transport interface {
	// SendText transmits one rendered chunk, returning the new message id.
	SendText(ctx context.Context, r OutgoingResponse) (msgID string, err error)
	// SendMedia transmits a batch of files with a shared caption, returning the
	// primary message id. Callers gate on Capabilities().SupportsMedia.
	SendMedia(ctx context.Context, m OutgoingMedia) (msgID string, err error)
	// SendTyping shows a best-effort typing indicator in the conversation.
	SendTyping(ctx context.Context, conversationID string) error
	// SetReaction adds an emoji reaction to a message (best-effort; no-op if
	// unsupported). The emoji must come from Capabilities().AvailableReactions —
	// transport-native form (unicode for Telegram, shortcode for Mattermost).
	SetReaction(ctx context.Context, conversationID, messageID, emoji string) error
	Kind() string
	Capabilities() Capabilities
	// IsAllowed reports whether the native sender id is in the static allowlist.
	IsAllowed(nativeSenderID string) bool
	// AllowlistConfigured reports whether a static allowlist is configured at all.
	// It distinguishes "empty allowlist" (IsAllowed always false, fail-closed in
	// simple mode) from "allowlist used as an optional subset filter" in SSO mode,
	// where an empty list means "all trusted senders" rather than "no one".
	AllowlistConfigured() bool
}

Transport is the output + identity surface a chat backend must implement. Ingestion is handled per-transport (each maps its native updates into IncomingMessage and feeds Bot.HandleIncoming); only sending, typing, reactions, capabilities, and the per-transport allowlist live here.

type Update

type Update struct {
	UpdateID int      `json:"update_id"`
	Message  *Message `json:"message"`
}

Update is a Telegram object that represents an incoming update.

type User

type User struct {
	ID        int64  `json:"id"`
	IsBot     bool   `json:"is_bot"`
	FirstName string `json:"first_name"`
	LastName  string `json:"last_name"`
	UserName  string `json:"username"`
}

User represents a Telegram user or bot.

type Voice

type Voice struct {
	FileID       string `json:"file_id"`
	FileUniqueID string `json:"file_unique_id"`
	Duration     int    `json:"duration"`
	MimeType     string `json:"mime_type,omitempty"`
	FileSize     int    `json:"file_size,omitempty"`
}

Voice represents a voice note.

Directories

Path Synopsis
Package tools provides tool execution for the laplaced Telegram bot.
Package tools provides tool execution for the laplaced Telegram bot.

Jump to

Keyboard shortcuts

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