telegram

package
v1.0.1 Latest Latest
Warning

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

Go to latest
Published: Jun 20, 2026 License: MIT Imports: 55 Imported by: 0

Documentation

Overview

Package telegram — this file is the per-turn AG-UI fanout consumer (the Phase-12 seam). It is the single biggest anti-re-implementation point of the phase (research §"Don't Hand-Roll"): it MUST consume internal/agui/fanout.go, never rebuild event distribution.

Package telegram — this file is the artifact consumer (the channel side of D-06 / UX-02). The substrate (plan 13-02) emits a channel-agnostic AG-UI CUSTOM event named agui.ArtifactEventName carrying a {path, filename, caption} descriptor; this file is the Telegram renderer that turns it into a sendDocument. Telegram auto-detects the MIME from the file, so there is no channel-side MIME plumbing. The caption is ASCII-sanitized before it reaches the Bot API (Pitfall 4 / T-13-06-CaptionInject) so a non-ASCII byte never triggers a document-caption 400.

Package telegram is the Telegram channel (Phase 13 / Slice 9b, UX-02). This file is the telebot.v4 wrapper implementing the channels.Channel lifecycle: Start constructs the bot, registers the text handler, and launches the polling goroutine; Stop drains it goleak-clean. The per-turn AG-UI fanout wiring lives in agui_subscriber.go; the two render consumers live in status_pane.go and renderer.go.

Package telegram — this file is the inbound DISPATCH: the OnText command/HITL intercept wrapper + the OnVoice/OnPhoto/OnDocument media handlers + the OnCallback/OnReply HITL resume, plus the per-channel dispatch instances they reuse. It is split out of bot.go (refactor-on-touch, CLAUDE.md ≤600 LOC) so bot.go keeps only the telebot lifecycle.

This is the integration the prior Gate-3 (13-09) assumed but did not have: the channel registered ONLY tele.OnText, so /command, voice, photo, document, an ask_user button-tap, and a send_file artifact NEVER reached their handler (UX-02/03/04 unreachable). registerHandlers wires them all.

Routing rules (plan 13-10):

  • OnText : /start <token> consumes onboarding first; then linked-account auth; then commands.dispatch intercepts (a /command never drives the LLM — T-13-10-CmdToLLM); else a pending pause → hitl.handleTextReply; else a turn.
  • OnVoice : getFile → voiceClient.Transcribe → turn driven by the transcript; on a hard STT failure send the IT copy + the 😵 reaction.
  • OnPhoto : getFile → photoClient.Describe (the single AURA_VISION_CLOUD branch) → turn driven by the description.
  • OnDocument: getFile → documentsClient.Convert; ≤5MB sync → turn on the markdown; 5-50MB async → turn when the per-request callback fires; >50MB → the refuse copy.
  • OnCallback (callbackUnique) / OnReply: hitl resolves the pause through the Runner and resumes the SAME loop, rendering the continuation through the per-turn fanout.

Package telegram — HITL dispatch: the channel-side resolve/render half of an ask_user pause. hitlHandlesText/Reply route a free-text answer to the Runner (via hitl.handleTextReply) when a pause is pending and surface a submit failure to the user; promptPendingPause renders the next FIFO pause; hitlFor builds the per-chat HITL surface whose resume drives the continuation turn through the channel fanout. Split out of bot_dispatch.go (no-god-class cap).

Package telegram — this file is the command dispatcher (UX-02 / SC#3 / SC#5). Telegram slash-commands are intercepted BEFORE any LLM dispatch (the text handler calls dispatch first; a handled command never reaches handleTurn, so a command can never drive an agent turn — T-13-06-CmdLLMBypass). /cost and /search REUSE the locked backends byte-for-byte (llm.CostUSD and conversations.SearchConversationTurns) so the Telegram output equals the CLI output for identical data (the cross-slice invariant). /cancel cancels the per-chat in-flight turn ctx (SC#3) via the cancel-func registry the channel tracks.

Package telegram — this file is the document-conversion sidecar client (UX-04). It is tiered against the markitdown /convert endpoint by payload size (T-13-08-SidecarDoS):

  • ≤5MB : SYNChronous — convert inline, return the markdown.
  • 5-50MB: ASYNChronous — convert on a goroutine the client tracks with a WaitGroup that Stop drains (goleak-clean, Pitfall 5); the result is delivered over the per-request callback passed to Convert.
  • >50MB : REFUSED — return a user-facing message, NO sidecar call.

The converted markdown becomes a text user message fed to runner.Turn (handler, 13-09). A2 caveat: the markitdown /convert request shape is ASSUMED — it is isolated to postConvert so a real-image mismatch is a one-line change.

Package telegram — this file is the HITL surface over the Runner pause backend (UX-02). It is RENDER-ONLY: an ask_user pause is rendered as an InlineKeyboard (discrete options / approval) or a ForceReply (free-text clarification); the button callback / reply answer is fed back to runner.SubmitAnswer, and a fresh runner.Turn(convID, nil) resumes the loop once nothing is unresolved. The channel NEVER writes paused_states — the Runner stays the sole writer (T-13-06-PauseHijack); HITL only reads pending via PendingFor and resolves via SubmitAnswer with the three-action accept/decline/cancel model.

Package telegram is the Telegram channel (Phase 13 / Slice 9b, UX-02). This file is the entity-aware MarkdownV2 escaper, locked in-tree by amendment #4 (the supply-chain alternative telegramify-markdown-go was rejected). The spec is binding from the spike (.claude/skills/spike-findings-Aura/references/ telegram-channel.md, "MarkdownV2 discipline", Pitfall #18), not a code analog.

Package telegram — this file is the onboarding writer (the channel side of UX-03). A Telegram deep-link "/start <token>" carries a single-use onboarding token the setup wizard minted (plan 13-07); this file consumes it through the Store's atomic ConsumeOnboarding (one db.WithTx consume-pending-then-INSERT- account, plan 13-01) and greets the now-onboarded user. A consumed / expired / unknown token writes NO account and replies with a clear "link expired or invalid" message (T-13-06-TokenReplay — the single-use chokepoint lives in the Store, this file just renders the outcome).

Package telegram — this file is the image-understanding sidecar client (UX-04). It is ONE function with ONE `if cfg.VisionCloud` branch (Pitfall 6 / #60): the switch is config-only, zero code dup. The UNCHANGED default (AURA_VISION_CLOUD=false) routes to the local aura-ocr-vl sidecar (GLM-OCR); AURA_VISION_CLOUD=true routes to OpenRouter cloud vision, using the primary model when it SupportsVision and falling back to MULTIMODAL_FALLBACK_MODEL (minimax-m3) otherwise so an image is never sent to a non-vision model (T-13-08-VisionMisroute). Both arms speak the same OpenAI /chat/completions image_url shape and return the description/OCR text, which becomes a text user message fed to runner.Turn (handler, 13-09).

Package telegram — this file is the content renderer consumer (msg #2, the streamed answer). It consumes the TEXT_MESSAGE_* / TOOL_CALL_RESULT family the AG-UI translator produces and renders them to Telegram with three guarantees:

  • HTML parse-mode sends (html.go) with a PLAIN-TEXT FALLBACK on a Bot-API 400 "can't parse entities" — the SC#2 "no can't parse entities" guarantee: a rejected parsed send is resent WITHOUT ParseMode.
  • markdown tables in the content render to a gridded PNG (tables.go) and go out via sendPhoto (caption capped at 1024).
  • content edits coalesce to the content throttle and per-chat sends are bounded by the chat rate limit; text is capped at the 4096 Bot-API ceiling.

Package telegram — this file is the shared 9c multimodal sidecar config + the thin OpenAI-compat HTTP-client ctor the four media clients (voice/tts/photo/ documents) reuse. There is ZERO Go ML here: the sidecars own the models (faster-whisper STT, Kokoro TTS, GLM-OCR vision, markitdown); Aura is a plain HTTP POST + decode + error-wrap. The client mirrors internal/llm/openai_compat (a connect-timeout on the dialer + a request ctx that carries the total timeout, DisableKeepAlives so a kept-alive conn never outlives the request and trips goleak).

Package telegram — this file is the status pane consumer (msg #1, status-pane-B). It maintains a single message edited IN PLACE as the turn progresses: a tool list (🟡 in-flight → ✅/❌ on result), a 💭 reasoning line, and a running-cost footer. Edits coalesce to the status throttle (a coalescing editor) so a fast event stream does not exceed the Bot-API edit rate.

Package telegram is the Telegram channel (Phase 13 / Slice 9a, UX-02). This file is the DB seam: a Store over aura.telegram_accounts + aura.telegram_setup_pending that copies the canonical Store pattern proved in internal/identity and reused by internal/askuser (D-A4-01): Store{pool,q} over the generated sqlc surface, SQLSTATE-based error classification via errors.As + pgErr.Code (never message matching), sentinel errors, pgtype conversion at the boundary, and db.WithTx for the atomic consume-pending-then-INSERT-account write.

Both onboarding (UX-03, the setup wizard mints pending tokens) and the channel itself (UX-02, /start consumes a token and persists the account) sit on this Store. ConsumeOnboarding is the single-use credential chokepoint (T-13-01-TokenReplay): one db.WithTx marks consumed_at and INSERTs the account; a re-consume of a spent/expired token returns ErrTokenConsumed and writes no duplicate account.

Package telegram — this file is the text-to-speech sidecar client (UX-04). It POSTs the agent's reply text to the aura-tts /v1/audio/speech endpoint (response_format=opus, Kokoro voice if_sara) and replies via sendVoice with an ASCII-clean caption (Pitfall 4 — a non-ASCII caption byte 400s a voice note).

Trigger (OQ2 — NO explicit send_voice tool this phase): the handler speaks a reply when a voice_mode preference is on OR the inbound message was a voice note (echo the user's modality). Slice 10 preferences are not shipped yet, so VoiceModePref is a stub that returns false until Phase 14.

Package telegram — this file is the speech-to-text sidecar client (UX-04). A Telegram voice note arrives as OGG/Opus; voice.go downloads the bytes and POSTs them DIRECTLY (mime/multipart) to the aura-stt /v1/audio/transcriptions endpoint. faster-whisper decodes Opus inline (PyAV) — there is NO ffmpeg pre-step (spike 027: that is exactly why faster-whisper beat whisper.cpp). The transcript text becomes a normal text user message fed to runner.Turn by the handler (plan 13-09); on a persistent sidecar failure the client hard-fails with the IT UX copy + a 😵 reaction.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrTokenConsumed = errors.New("onboarding token already consumed or expired")
	ErrTokenNotFound = errors.New("onboarding token not found")
	ErrAccountExists = errors.New("telegram account already exists")
)

Sentinel errors so callers classify failures without string matching. ErrTokenConsumed is an already-consumed OR expired onboarding token (a spent single-use credential); ErrTokenNotFound is an unknown token; ErrAccountExists is a duplicate telegram_user_id (SQLSTATE 23505 on the account INSERT).

Functions

func EscapeMarkdownV2

func EscapeMarkdownV2(s string) string

EscapeMarkdownV2 escapes Telegram MarkdownV2 reserved characters OUTSIDE intended entities only — it NEVER whole-string escapes (that would neutralize the bot's own bold/italic/code formatting; the 017 throwaway escaper is the negative example). It tracks fence state so reserved characters are handled per region:

  • Outside any fence: the full reserved set is escaped (a naked '-' / '.' / '(' etc. gets a leading backslash).
  • Inside a ```pre``` block or a `code` span: only backtick (the delimiter) and backslash are reserved; pipes, dashes, dots flow through unescaped so table payloads render intact (verified live, spike 018a/b).

The fence delimiters themselves (``` and `) pass through verbatim. The output is always a well-formed MarkdownV2 entity stream: even when the input has an unterminated fence, the in-fence content is emitted with only backtick/ backslash escaped, so the stream cannot 400. The renderer (plan 13-05) owns the plain-text fallback: if a MarkdownV2 send still 400s, it resends WITHOUT ParseMode (see PlainTextFallback for the contract helper).

func ParseMarkdownTable

func ParseMarkdownTable(s string) (grid [][]string, ok bool)

ParseMarkdownTable detects a markdown table — a '|'-delimited header row, a '|---|' separator row, then N data rows — in s and returns the parsed cell grid (header row first, separator dropped). ok is false when s is not a well-formed table (no separator, fewer than one data row, or no '|' rows). Surrounding prose is ignored: the first contiguous header+separator+data run is parsed.

func PlainTextFallback

func PlainTextFallback(original string) string

PlainTextFallback is the documented contract the renderer uses when a MarkdownV2 send 400s despite escaping: resend the ORIGINAL (un-escaped) text without ParseMode. It is the identity function here — the helper exists so the "fallback = original plain text, no escaping, no ParseMode" decision lives in this file next to the escaper it complements, rather than as an implicit rule scattered in the renderer (plan 13-05).

func PreBlockTable

func PreBlockTable(grid [][]string) string

PreBlockTable is the zero-dependency fallback: it pads each cell to its per-column rune width inside a ``` monospace fence. Readable up to ~56 char/row on the operator's device (the on-device ceiling, spike 018a); wide tables degrade here rather than failing. Used when PNG rendering is unavailable or the channel prefers text.

func RenderTablePNG

func RenderTablePNG(grid [][]string) ([]byte, error)

RenderTablePNG renders the parsed grid to a deterministic gridded PNG: a gomonobold header row, gomono data cells, per-column width from font.Drawer.MeasureString + cellPadX padding, black 1px grid lines, white background, png.Encode. The same grid yields byte-identical bytes (embedded fonts, fixed metrics — no fontconfig, no CGO). This is the pure transform; the renderer (plan 13-05) decides PNG-vs-fallback and calls sendPhoto.

func RenderTelegramHTML

func RenderTelegramHTML(s string) string

RenderTelegramHTML converts the model's Markdown-ish answer to the subset of HTML accepted by the Telegram Bot API. The converter escapes raw HTML before adding Telegram-safe entity tags, so user/model text like "<script>" remains text, not markup.

func ShouldSpeak

func ShouldSpeak(voiceMode, inboundWasVoice bool) bool

ShouldSpeak is the TTS trigger predicate (OQ2): reply with a voice note when the voice_mode preference is enabled OR the inbound message was itself a voice note (echo modality). There is deliberately no explicit send_voice tool this phase.

func VoiceModePref

func VoiceModePref(_ string) bool

VoiceModePref reads the per-conversation voice_mode preference. Slice 10 preferences are NOT shipped (Phase 14), so this is a stub returning the default (false) — wired to the real preference store when it lands. Kept as a function (not a const) so the call sites are already correct when the store arrives.

Types

type Account

type Account struct {
	TelegramUserID int64
	IdentityID     string
	Username       string
	FirstName      string
	AddedAt        time.Time
	LastSeenAt     time.Time // zero when never seen (last_seen_at SQL NULL)
}

Account is the domain projection of an aura.telegram_accounts row — plain Go types at the package boundary instead of the sqlc pgtype wrappers.

type Config

type Config struct {
	// BotToken is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, upstream
	// naming). Empty default → the channel is unconfigured; the setup wizard
	// (plan 13-07) can supply it via POST /setup/token, and the registry's
	// enable gate decides whether the channel starts at all.
	BotToken string

	// StatusThrottleMS bounds status-pane (msg #1) edits — PRD 1500ms default.
	StatusThrottleMS int
	// ContentThrottleMS bounds streamed-content (msg #2) edits — 500ms default.
	ContentThrottleMS int
	// ChatRateLimitMS bounds the per-chat_id send queue — 1000ms default.
	ChatRateLimitMS int

	// ReasoningFIFORunes caps the live CoT window (AURA_REASONING_FIFO_RUNES, default
	// 4096). The on/off master switch is AURA_SHOW_REASONING in llm.Config (propagated
	// to the channel by the composition root) — this is only the window size.
	ReasoningFIFORunes int
}

Config is the Telegram channel's own config surface (Phase 13 / Slice 9a, UX-02). Per the config.go package doc convention, per-subsystem configs live in their owning packages; this is the Telegram one. The central internal/config.Config owns the setup/vision/sidecar knobs (those cross subsystems); the bot token + the channel's own throttles live here.

Env naming (CLAUDE.md): TELEGRAM_BOT_TOKEN keeps upstream/third-party naming (it is a Telegram-issued credential, NOT an Aura knob); the throttles are Aura-native and use the AURA_<DOMAIN>_<UNIT> convention. All values use the silent-fallback contract (malformed → default, never boot-fatal).

func LoadConfig

func LoadConfig() Config

LoadConfig reads the Telegram channel config from the environment with silent-fallback defaults (mirrors config.envIntDefault: unset/empty/malformed → fallback, never fatal — a typo in a throttle tweak must not block boot).

type ConsumeParams

type ConsumeParams struct {
	OnboardingToken string
	TelegramUserID  int64
	Username        string // optional (empty → SQL NULL)
	FirstName       string // optional (empty → SQL NULL)
}

ConsumeParams carries the account fields written when an onboarding token is consumed. IdentityID is taken from the consumed pending row, not from here, so the account always FKs the identity the token was minted for.

type ConvertResult

type ConvertResult struct {
	Status   ConvertStatus
	Markdown string
	Message  string
}

ConvertResult is the immediate outcome of a Convert call. For ConvertSync it carries the Markdown; for ConvertAsync the markdown follows over the Convert callback; for ConvertRefused it carries the user-facing Message.

type ConvertStatus

type ConvertStatus int

ConvertStatus classifies a Convert call's outcome so the handler renders the right surface (return the markdown / acknowledge async / show the refuse copy).

const (
	// ConvertSync — the document was converted inline; Markdown is populated.
	ConvertSync ConvertStatus = iota
	// ConvertAsync — the document was accepted for async conversion; the markdown
	// arrives later over the Convert callback.
	ConvertAsync
	// ConvertRefused — the document exceeded the 50MB ceiling; Message is the
	// user-facing copy, no conversion was attempted.
	ConvertRefused
)

type Deps

type Deps struct {
	// Turn is the per-turn loop driver (runner.Runner.Turn). Required for live
	// turns; a nil Turn means the channel can start (poll) but a message handler
	// would have nothing to drive — wired by the composition root.
	Turn turnDriver

	// Token is TELEGRAM_BOT_TOKEN (upstream naming).
	Token string

	// Store is the onboarding/account DB seam (plan 13-01) and the live inbound
	// auth resolver. If Store (or the test-only profileAccounts override) is nil,
	// inbound Telegram handlers fail closed except for /start <token> activation.
	Store *Store

	// Profile is the per-identity Agent.md store. Nil means profile onboarding
	// degrades with a user-facing setup message instead of panicking.
	Profile *profile.Store

	// AnswerExtractor parses free-text onboarding answers (Identity/Work/Projects/
	// Social) into structured fields. Nil → those steps fall back to keyword parsing.
	AnswerExtractor profileflow.AnswerExtractor

	// Multimodal carries the 9c sidecar wiring (STT/TTS/vision/documents) the media
	// handlers (OnVoice/OnPhoto/OnDocument) + the TTS-out path read. A zero value
	// means a modality is unconfigured — the handler degrades, never panics. Built
	// by the composition root from config.Config (serve_channels.go).
	Multimodal MultimodalConfig

	// DocumentIngest optionally routes document uploads into Aura's native
	// document ingestion pipeline. Nil preserves the legacy convert-to-markdown
	// path used by existing deployments and tests.
	DocumentIngest documentIngestor

	// Assets routes Telegram media through the shared asset pipeline. Nil preserves
	// the legacy direct sidecar paths for tests and transitional deployments.
	Assets assetIngress

	// Command backends drive the bot-intercept dispatch (commands.go). Search ==
	// conversations.SearchConversationTurns (CLI parity); Cost == the cachemetrics
	// daily aggregation; Clear == conversations.Store.Delete (the /clear hard-delete);
	// Prices/Model render the /cost USD via llm.CostUSD. A nil backend degrades its
	// command to an "unavailable" reply (never a panic).
	Search searchBackend
	Cost   costBackend
	Clear  clearBackend
	Prices map[string]llm.Price
	Model  string

	// Resume is the HITL seam (hitl.go): the Runner's pause surface
	// (PendingFor/SubmitAnswer — *runner.Runner satisfies it). Nil → HITL is inert
	// (no button render, no resume) but the channel still serves plain turns. The
	// continuation turn after a pause resolves is built locally in hitlFor.
	Resume resumeRunner

	// StatusThrottle / ContentThrottle bound the two render consumers; ChatRate
	// bounds the per-chat send queue. Zero → the package defaults (see config).
	StatusThrottleMS  int
	ContentThrottleMS int
	ChatRateLimitMS   int

	// ShowReasoning surfaces the live chain-of-thought in the status pane (default
	// false → redacted lifecycle). It is the master AURA_SHOW_REASONING switch from
	// llm.Config, propagated here by the composition root so the agent's exclude flag,
	// the AG-UI translator's redaction, and this pane all honor one setting.
	// ReasoningFIFORunes caps that rolling window (zero → the package default) and
	// comes from telegram.LoadConfig.
	ShowReasoning      bool
	ReasoningFIFORunes int

	// Offline forces tele.Settings.Offline (unit tests: no getMe, no network).
	Offline bool
	// contains filtered or unexported fields
}

Deps are the Telegram channel's constructor inputs. Runner drives the loop; Store persists onboarding/accounts; the throttles come from the channel Config. Token is the Bot API credential (empty → the registry enable gate keeps the channel from starting). IDGen is injectable so tests pin deterministic AG-UI ids; nil → the production uuid generator.

type InsertPendingParams

type InsertPendingParams struct {
	OnboardingToken string
	IdentityID      string
	GeneratedBy     string    // optional source tag (empty → SQL NULL)
	ExpiresAt       time.Time // created_at + 1h at the call site
}

InsertPendingParams carries the plain fields for one new onboarding token.

type MultimodalConfig

type MultimodalConfig struct {
	// VisionCloud routes image understanding: false (default) → the local
	// aura-ocr-vl sidecar; true → OpenRouter cloud vision. One env branch, zero
	// code dup (#60 / Pitfall 6).
	VisionCloud bool

	// Model is the primary LLM id (config.Model). photo.go reads SupportsVision on
	// it to decide whether the cloud branch attaches the image to the primary turn
	// or falls back to FallbackModel.
	Model string

	// MultimodalBaseURL/Model are the local vision sidecar (aura-ocr-vl) base +
	// model id. FallbackModel is the cloud vision model used when VisionCloud is
	// true and the primary Model lacks SupportsVision.
	MultimodalBaseURL string
	MultimodalModel   string
	FallbackModel     string

	// OpenRouterBaseURL/APIKey are the cloud vision endpoint (VisionCloud=true).
	// The key is set ONLY on the Authorization header at request-build time, never
	// logged or serialized (the openai_compat D-28 discipline).
	OpenRouterBaseURL string
	OpenRouterAPIKey  string

	// STTBaseURL/Model are the speech-to-text sidecar (aura-stt, faster-whisper).
	// STTLanguage pins the transcription language ("it"); empty = whisper
	// auto-detect, which mis-detects short clips (spike-027: probe used language=it).
	STTBaseURL  string
	STTModel    string
	STTLanguage string

	// TTSBaseURL/Voice/Format are the text-to-speech sidecar (aura-tts, Kokoro).
	// TTSCaption is the (optional) ASCII-safe caption put on the voice note.
	TTSBaseURL string
	TTSVoice   string
	TTSFormat  string
	TTSCaption string

	// DocumentsBaseURL is the markitdown /convert base.
	DocumentsBaseURL string

	// RetryBackoff is the per-retry sleep schedule in MILLISECONDS (voice.go: the
	// PRD 1s/2s default when nil). Exposed so tests pin a fast schedule.
	RetryBackoff []int

	// TimeoutSec bounds each sidecar request (T-13-08-SidecarDoS, 30s default when
	// zero). The timeout rides the request ctx, not http.Client.Timeout, so a
	// healthy slow body is not aborted mid-read.
	TimeoutSec int
}

MultimodalConfig is the telegram-package projection of the central internal/config multimodal knobs (AURA_VISION_CLOUD + the upstream-named MULTIMODAL_*/STT_*/TTS_* sidecar vars). It is populated by the composition root (plan 13-09) and passed to the media clients; keeping it local frees the telegram package from an internal/config import (the established config.go pattern). Zero values are sensible: an empty base URL means the corresponding modality is unconfigured (the handler degrades, never panics).

type Store

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

Store wraps a pgx pool and the generated Queries — the canonical shape. Non-tx reads/writes use s.q; the atomic consume-and-INSERT wraps db.WithTx.

func New

func New(pool *pgxpool.Pool) *Store

New builds a Store over an open pool.

func (*Store) CleanupExpired

func (s *Store) CleanupExpired(ctx context.Context) (int64, error)

CleanupExpired deletes unconsumed onboarding tokens past their expiry and returns the number removed (the setup SSE-pump GC scan).

func (*Store) ConsumeOnboarding

func (s *Store) ConsumeOnboarding(ctx context.Context, p ConsumeParams) (Account, error)

ConsumeOnboarding is the single-use credential chokepoint (T-13-01-TokenReplay): in ONE db.WithTx it (1) atomically marks the token consumed (the SQL guards consumed_at IS NULL AND expires_at > now() make the UPDATE match no row for a spent/expired token → ErrTokenConsumed) and (2) INSERTs the telegram_accounts row FK'd to the identity the token was minted for. A duplicate account (the same telegram_user_id already onboarded) classifies via SQLSTATE 23505 → ErrAccountExists, never a message match. Any error rolls the whole tx back, so a failed account INSERT leaves the token unconsumed (no half-onboarded state).

func (*Store) CountAccounts

func (s *Store) CountAccounts(ctx context.Context) (int64, error)

CountAccounts returns the number of onboarded accounts (setup-status footer).

func (*Store) GetAccountByIdentity

func (s *Store) GetAccountByIdentity(ctx context.Context, identityID string) (Account, error)

GetAccountByIdentity fetches one account by its owning identity_id, the key the scheduler's identity-routed delivery uses (Phase 20 R3). It mirrors GetAccountByTelegramID's error classification but adds the parseUUID boundary the generated query needs (identity_id is uuid). A non-UUID identity (e.g. the CLI's 'local') can never match a real account, so a parse failure maps to a wrapped pgx.ErrNoRows — the same not-found signal as a missing row. That lets Deliver use a single errors.Is(err, pgx.ErrNoRows) branch to mean "not my user" for both the no-account case AND the 'local' case (Pitfall 6), never surfacing an error.

func (*Store) GetAccountByTelegramID

func (s *Store) GetAccountByTelegramID(ctx context.Context, telegramUserID int64) (Account, error)

GetAccountByTelegramID fetches one account by telegram_user_id, mapping a missing row to ErrTokenNotFound's sibling — here a not-found account is surfaced via pgx.ErrNoRows wrapped so callers can errors.Is it.

func (*Store) InsertPending

func (s *Store) InsertPending(ctx context.Context, p InsertPendingParams) error

InsertPending persists one pending onboarding token (UX-03 mint side).

func (*Store) ListAccounts

func (s *Store) ListAccounts(ctx context.Context) ([]Account, error)

ListAccounts returns all onboarded accounts, oldest first.

func (*Store) PendingConsumed

func (s *Store) PendingConsumed(ctx context.Context, onboardingToken string) (bool, error)

PendingConsumed reports whether a token row exists and has been consumed, mapping a missing row to ErrTokenNotFound. Used by the setup-status / SSE-poll path.

func (*Store) TouchLastSeen

func (s *Store) TouchLastSeen(ctx context.Context, telegramUserID int64) error

TouchLastSeen bumps last_seen_at for an account (best-effort activity marker).

type Telegram

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

Telegram is the Telegram channel: a telebot wrapper implementing channels.Channel. It builds a fresh AG-UI Fanout PER TURN (never one at channel start — research §1 / channel.go contract). The polling goroutine is tracked by a WaitGroup so Stop joins it (goleak-clean — the package goleak TestMain catches a leaked poller).

func NewChannel

func NewChannel(d Deps) *Telegram

NewChannel builds an unstarted Telegram channel over the supplied deps. (Named NewChannel, not New, because Store.New already owns the package's New for the DB seam — the channel is the higher-level type that holds a *Store.)

func (*Telegram) Deliver

func (t *Telegram) Deliver(ctx context.Context, identityID, text string) (bool, error)

Deliver pushes text to the 1:1 Telegram chat owned by identityID, satisfying channels.Deliverer (Phase 20 R3). It honors the tri-state contract:

(false, nil) = not my user (no account / 'local' / no bot) → caller tries next
(true,  nil) = delivered
(false, err) = owns-but-failed (resolve or send error) → caller stops, no siblings

The live bot is read under t.mu (it may be nil after Stop — Pitfall 4: never deref a racing nil bot); a nil bot or nil Store means the channel cannot push, so it returns (false, nil) and lets the route fall-back handle delivery.

func (*Telegram) IsHealthy

func (t *Telegram) IsHealthy() bool

IsHealthy reports whether the bot is constructed and polling.

func (*Telegram) Name

func (t *Telegram) Name() string

Name returns the channel name keying AURA_CHANNEL_TELEGRAM_ENABLED.

func (*Telegram) Start

func (t *Telegram) Start(ctx context.Context) error

Start constructs the telebot bot (a live getMe unless Offline), registers the text-message handler, and launches the polling goroutine. It returns once started (NOT per turn). A construction failure (bad token / getMe) returns an error the Registry fail-softs. Calling Start twice is a no-op after the first.

func (*Telegram) Stop

func (t *Telegram) Stop(ctx context.Context) error

Stop gracefully shuts the poller down and joins the polling goroutine. It is goleak-clean (Bot.Stop unblocks Bot.Start, the goroutine returns, the WaitGroup drains). Idempotent: a Stop on a never-started channel is a clean no-op.

Jump to

Keyboard shortcuts

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