detached 098a797

feat(tui): /telegram connect | disconnect | status

The Telegram bridge can now mirror into the running TUI session.
Runs inside the zot process (no daemon needed); DMs from the
paired user become prompts in the current agent, and the
assistant's final text is sent back to Telegram. You see the full
conversation in the TUI in real time and on your phone.

UI:
  - /telegram or /tg with no arg opens a picker (connect /
    disconnect / status) that reflects current state.
  - /telegram connect  starts the bridge. Refuses if bot.json
    has no token (tells you to run `zot telegram-bot setup`) or
    if the background daemon is already polling.
  - /telegram disconnect  stops the bridge cleanly.
  - /telegram status  one-liner: "connected as @botname, paired
    with user X" / "background daemon running (pid N)" /
    "not configured" / "disconnected".
  - Status bar gets a "· tg · ~/cwd" tag while the bridge is
    active, next to the "· jailed ·" tag if that's also on.

How it's wired:

  internal/agent/modes/telegram/bridge.go (new)
    A slim Bridge type that owns the long-poll loop + typing
    indicator + reply sender but delegates the agent side to a
    Host interface. Not an agent itself - just a courier that
    pushes inbound DMs at a host and relays outbound text.

  internal/agent/modes/telegram_dialog.go (new)
    Picker with connect / disconnect / status rows. Shape
    mirrors the logout dialog: arrow keys, enter, esc.

  internal/agent/modes/interactive.go
    - New SubmitOrQueue(text, images) that runs if idle or
      queues if busy. Telegram Host calls this so DMs use the
      same queuing semantics as the user's editor submit.
    - New CancelTurn() for when Telegram sends /stop.
    - telegramHost adapter wires the Interactive to the
      bridge without a cyclic import (bridge lives in
      modes/telegram, interactive in modes; the adapter is
      in modes so it's fine).
    - EvAssistantMessage handler now also forwards the final
      visible text to the bridge when active (goroutine, so
      the network call doesn't hold the event-loop lock).
    - Bridge is stopped on zot exit via a defer in Run().

  internal/tui/view.go
    StatusBarParams gains Telegram bool; the cwd line builds a
    composite "· jailed · tg · ~/cwd" when both tags apply.

  internal/agent/modes/slash_suggest.go
    /telegram added to the slash catalog.

Collision safety:
  /telegram connect refuses when the background daemon
  (telegram.IsRunning via bot.pid) is alive. Two concurrent
  long-poll consumers of the same bot always race and one
  drops half the updates; refusing up-front beats half-working
  silently. Message tells the user exactly what to do.

Attachments:
  Image attachments arriving in Telegram are downloaded and
  queued as user-prompt images (same code path as drag-drop).
  Non-image attachments are ignored for now.

Pairing:
  First Telegram user to DM /start claims the bridge; the id
  is persisted to bot.json so subsequent connects are already
  paired. Anyone else DMing the bot gets "this bot is paired
  with a different user."

README: /telegram row added to the slash-commands table.
commit
098a79743d885e0682a685b9de602925e49ddeab
author
patriceckhart · · 2026-04-20 07:18 UTC
parent
7193334
signature
unsigned
refs
tag: v0.0.54
6 files · +724 -3
M README.md +1 -0
@@ -198,6 +198,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
198198 | `/unjail` | Allow tools to touch paths outside again. |
199199 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
200200 | `/yolo` | Turn off `--no-yolo` confirmation for the rest of this session. |
201+| `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
201202 | `/clear` | Clear the chat transcript. |
202203 | `/exit` | Exit zot. |
203204
@@ -198,6 +198,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
198 | `/unjail` | Allow tools to touch paths outside again. |198 | `/unjail` | Allow tools to touch paths outside again. |
199 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |199 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
200 | `/yolo` | Turn off `--no-yolo` confirmation for the rest of this session. |200 | `/yolo` | Turn off `--no-yolo` confirmation for the rest of this session. |
201+| `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
201 | `/clear` | Clear the chat transcript. |202 | `/clear` | Clear the chat transcript. |
202 | `/exit` | Exit zot. |203 | `/exit` | Exit zot. |
203 204
M internal/agent/modes/interactive.go +296 -1
@@ -9,6 +9,7 @@ import (
99 "time"
1010
1111 "github.com/patriceckhart/zot/internal/agent/extensions"
12+ "github.com/patriceckhart/zot/internal/agent/modes/telegram"
1213 "github.com/patriceckhart/zot/internal/agent/tools"
1314 "github.com/patriceckhart/zot/internal/auth"
1415 "github.com/patriceckhart/zot/internal/core"
@@ -177,6 +178,8 @@ type Interactive struct {
177178 changelogDialog *changelogDialog
178179 confirmDialog *confirmDialog
179180 logoutDialog *logoutDialog
181+ telegramDialog *telegramDialog
182+ telegramBridge *telegram.Bridge
180183 suggest *slashSuggester
181184 spin *spinner
182185
@@ -229,6 +232,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
229232 changelogDialog: newChangelogDialog(),
230233 confirmDialog: newConfirmDialog(),
231234 logoutDialog: newLogoutDialog(),
235+ telegramDialog: newTelegramDialog(),
232236 suggest: newSlashSuggester(),
233237 spin: newSpinner(),
234238 }
@@ -248,6 +252,11 @@ func (i *Interactive) Run(ctx context.Context) error {
248252 return err
249253 }
250254 defer restore()
255+ defer func() {
256+ if i.telegramBridge != nil {
257+ i.telegramBridge.Stop()
258+ }
259+ }()
251260
252261 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
253262 _, _ = term.Write([]byte(tui.SeqAltScreenOn))
@@ -418,7 +427,7 @@ func (i *Interactive) Run(ctx context.Context) error {
418427 // and the AfterFunc-driven invalidate got dropped on a
419428 // full channel.
420429 drainPending()
421- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() {
430+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() {
422431 requestRedraw() // keep the spinner / dialog animation moving
423432 }
424433 }
@@ -580,6 +589,8 @@ func (i *Interactive) redraw() {
580589 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
581590 case i.logoutDialog.Active():
582591 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
592+ case i.telegramDialog.Active():
593+ dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
583594 }
584595
585596 // Slash-command autocomplete: popup above the status line, only
@@ -642,6 +653,7 @@ func (i *Interactive) redraw() {
642653 ContextUsed: i.lastCtxInput,
643654 ContextMax: ctxMax,
644655 AutoCompacting: i.autoCompacting,
656+ Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
645657 Cols: cols,
646658 })
647659 edLines, curR, curC := i.ed.Render(cols)
@@ -843,6 +855,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
843855 i.invalidate()
844856 return false
845857 }
858+ if i.telegramDialog.Active() {
859+ if k.Kind == tui.KeyCtrlC {
860+ i.telegramDialog.Close()
861+ i.invalidate()
862+ return false
863+ }
864+ act := i.telegramDialog.HandleKey(k)
865+ if act.Select {
866+ i.doTelegram(act.Action)
867+ }
868+ i.invalidate()
869+ return false
870+ }
846871 if i.jumpDialog.Active() {
847872 if k.Kind == tui.KeyCtrlC {
848873 i.jumpDialog.Close()
@@ -1166,6 +1191,43 @@ func (i *Interactive) Submit(text string) {
11661191 i.startTurn(i.runCtx, text)
11671192 }
11681193
1194+// SubmitOrQueue runs text immediately if the agent is idle, or
1195+// appends it to the pending queue if a turn is already in flight.
1196+// Used by the telegram bridge (and by the editor submit path) so
1197+// both input sources share the same "queue behind an active turn"
1198+// semantics. Images are ignored for now — only the text prompt is
1199+// forwarded — because the queued-prompt path is text-only; a
1200+// follow-up can expand the queue entry to carry images.
1201+func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
1202+ _ = images // reserved for future queued-with-images support
1203+ i.mu.Lock()
1204+ if i.agent == nil {
1205+ i.statusErr = "not logged in. type /login first."
1206+ i.mu.Unlock()
1207+ i.invalidate()
1208+ return
1209+ }
1210+ if i.busy {
1211+ i.queued = append(i.queued, text)
1212+ i.mu.Unlock()
1213+ i.invalidate()
1214+ return
1215+ }
1216+ i.mu.Unlock()
1217+ i.startTurn(i.runCtx, text)
1218+}
1219+
1220+// CancelTurn aborts the active turn if one is running. Used by the
1221+// telegram bridge when the paired user sends /stop.
1222+func (i *Interactive) CancelTurn() {
1223+ i.mu.Lock()
1224+ cancel := i.cancelTurn
1225+ i.mu.Unlock()
1226+ if cancel != nil {
1227+ cancel()
1228+ }
1229+}
1230+
11691231 // Insert places text at the cursor in the editor.
11701232 func (i *Interactive) Insert(text string) {
11711233 i.ed.Insert(text)
@@ -1266,6 +1328,12 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
12661328 i.runReloadExt(ctx)
12671329 case "/yolo":
12681330 i.runYoloOn()
1331+ case "/telegram", "/tg":
1332+ if len(parts) >= 2 {
1333+ i.doTelegram(parts[1])
1334+ break
1335+ }
1336+ i.openTelegramDialog()
12691337 default:
12701338 // Last-resort fallback: try the extension manager. Built-in
12711339 // cases above always win; this branch only fires for slash
@@ -1979,6 +2047,24 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
19792047 if i.cfg.OnAssistant != nil {
19802048 i.cfg.OnAssistant(e.Message)
19812049 }
2050+ // Mirror the assistant's final visible text to the telegram
2051+ // bridge when active. Only TextBlock content is forwarded;
2052+ // tool_use blocks are internal. Send on a goroutine so the
2053+ // network call doesn't hold the event loop lock.
2054+ if i.telegramBridge != nil && i.telegramBridge.Active() {
2055+ var sb strings.Builder
2056+ for _, c := range e.Message.Content {
2057+ if tb, ok := c.(provider.TextBlock); ok {
2058+ if sb.Len() > 0 {
2059+ sb.WriteString("\n")
2060+ }
2061+ sb.WriteString(tb.Text)
2062+ }
2063+ }
2064+ if text := sb.String(); strings.TrimSpace(text) != "" {
2065+ go i.telegramBridge.OnAssistantText(text)
2066+ }
2067+ }
19822068 case core.EvToolUseStart:
19832069 // Live streaming: pre-create the view so the user sees the
19842070 // tool call being composed in real time. Any subsequent
@@ -2171,3 +2257,212 @@ func (i *Interactive) runYoloOn() {
21712257 i.mu.Unlock()
21722258 i.invalidate()
21732259 }
2260+
2261+// openTelegramDialog shows the picker for `/telegram` with no arg.
2262+// Items depend on current state: disconnect + status when running,
2263+// connect + status when stopped.
2264+func (i *Interactive) openTelegramDialog() {
2265+ items := i.telegramMenuItems()
2266+ if len(items) == 0 {
2267+ i.mu.Lock()
2268+ i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
2269+ i.mu.Unlock()
2270+ i.invalidate()
2271+ return
2272+ }
2273+ i.telegramDialog.Open(items)
2274+ i.invalidate()
2275+}
2276+
2277+// telegramMenuItems builds the dialog entries for the current
2278+// bridge state. Returns empty when no bot.json exists so the
2279+// caller can show a helpful status line instead of an empty menu.
2280+func (i *Interactive) telegramMenuItems() []telegramItem {
2281+ cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2282+ if err != nil || cfg.BotToken == "" {
2283+ return nil
2284+ }
2285+ var items []telegramItem
2286+ if i.telegramBridge != nil && i.telegramBridge.Active() {
2287+ items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
2288+ st := i.telegramBridge.State()
2289+ hint := "active"
2290+ if st.Username != "" {
2291+ hint += " as @" + st.Username
2292+ }
2293+ items = append(items, telegramItem{label: "status", action: "status", hint: hint})
2294+ } else {
2295+ label := "connect"
2296+ hint := "start mirroring dms into this session"
2297+ if cfg.AllowedUserID == 0 {
2298+ hint = "awaiting pairing (send /start to the bot once connected)"
2299+ }
2300+ items = append(items, telegramItem{label: label, action: "connect", hint: hint})
2301+ items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
2302+ }
2303+ return items
2304+}
2305+
2306+// doTelegram dispatches one of the three explicit actions. Called
2307+// from /telegram <action> or after the picker selects a row.
2308+func (i *Interactive) doTelegram(action string) {
2309+ switch action {
2310+ case "connect":
2311+ i.telegramConnect()
2312+ case "disconnect":
2313+ i.telegramDisconnect()
2314+ case "status":
2315+ i.telegramStatus()
2316+ default:
2317+ i.mu.Lock()
2318+ i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
2319+ i.mu.Unlock()
2320+ i.invalidate()
2321+ }
2322+}
2323+
2324+// telegramConnect starts the bridge. Refuses if it's already
2325+// running or if the on-disk bot.json is missing a token.
2326+func (i *Interactive) telegramConnect() {
2327+ if i.telegramBridge != nil && i.telegramBridge.Active() {
2328+ i.mu.Lock()
2329+ i.statusOK = "telegram already connected"
2330+ i.statusErr = ""
2331+ i.mu.Unlock()
2332+ i.invalidate()
2333+ return
2334+ }
2335+ cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2336+ if err != nil {
2337+ i.mu.Lock()
2338+ i.statusErr = "telegram: " + err.Error()
2339+ i.mu.Unlock()
2340+ i.invalidate()
2341+ return
2342+ }
2343+ if cfg.BotToken == "" {
2344+ i.mu.Lock()
2345+ i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
2346+ i.mu.Unlock()
2347+ i.invalidate()
2348+ return
2349+ }
2350+ // Refuse to start when a background daemon is already polling
2351+ // the same bot. Two concurrent long-poll consumers race each
2352+ // update and one always loses, so DMs get half-delivered. The
2353+ // user can `zot telegram-bot stop` first, then /telegram connect.
2354+ if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2355+ i.mu.Lock()
2356+ i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
2357+ i.mu.Unlock()
2358+ i.invalidate()
2359+ return
2360+ }
2361+ i.telegramBridge = &telegram.Bridge{
2362+ Client: telegram.NewClient(cfg.BotToken),
2363+ Config: cfg,
2364+ Save: func(next telegram.Config) error {
2365+ return telegram.SaveConfig(i.cfg.ZotHome, next)
2366+ },
2367+ Host: &telegramHost{iv: i},
2368+ }
2369+ if err := i.telegramBridge.Start(i.runCtx); err != nil {
2370+ i.telegramBridge = nil
2371+ i.mu.Lock()
2372+ i.statusErr = "telegram connect failed: " + err.Error()
2373+ i.mu.Unlock()
2374+ i.invalidate()
2375+ return
2376+ }
2377+ state := i.telegramBridge.State()
2378+ label := "telegram connected"
2379+ if state.Username != "" {
2380+ label += " as @" + state.Username
2381+ }
2382+ if state.PairedID == 0 {
2383+ label += " — send /start to the bot from your phone to claim it"
2384+ }
2385+ i.mu.Lock()
2386+ i.statusOK = label
2387+ i.statusErr = ""
2388+ i.mu.Unlock()
2389+ i.invalidate()
2390+}
2391+
2392+// telegramDisconnect stops the bridge. No-op when already stopped.
2393+func (i *Interactive) telegramDisconnect() {
2394+ if i.telegramBridge == nil || !i.telegramBridge.Active() {
2395+ i.mu.Lock()
2396+ i.statusOK = "telegram already disconnected"
2397+ i.statusErr = ""
2398+ i.mu.Unlock()
2399+ i.invalidate()
2400+ return
2401+ }
2402+ i.telegramBridge.Stop()
2403+ i.mu.Lock()
2404+ i.statusOK = "telegram disconnected"
2405+ i.statusErr = ""
2406+ i.mu.Unlock()
2407+ i.invalidate()
2408+}
2409+
2410+// telegramStatus writes a one-liner describing the bridge state.
2411+// Reports on both the in-tui bridge and the background daemon so
2412+// the user isn't confused when the daemon owns the poll loop.
2413+func (i *Interactive) telegramStatus() {
2414+ var msg string
2415+ if i.telegramBridge != nil && i.telegramBridge.Active() {
2416+ s := i.telegramBridge.State()
2417+ msg = "telegram: connected (tui bridge)"
2418+ if s.Username != "" {
2419+ msg += " as @" + s.Username
2420+ }
2421+ if s.PairedID != 0 {
2422+ msg += fmt.Sprintf(" · paired with user %d", s.PairedID)
2423+ } else {
2424+ msg += " · awaiting pairing"
2425+ }
2426+ } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2427+ msg = fmt.Sprintf("telegram: background daemon running (pid %d) · /telegram connect won't work until you stop it", pid)
2428+ } else {
2429+ cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
2430+ if cfg.BotToken == "" {
2431+ msg = "telegram: not configured. run `zot telegram-bot setup` first."
2432+ } else {
2433+ msg = "telegram: disconnected"
2434+ if cfg.BotUsername != "" {
2435+ msg += " (@" + cfg.BotUsername + " ready to connect)"
2436+ }
2437+ }
2438+ }
2439+ i.mu.Lock()
2440+ i.statusOK = msg
2441+ i.statusErr = ""
2442+ i.mu.Unlock()
2443+ i.invalidate()
2444+}
2445+
2446+// telegramHost adapts *Interactive to telegram.Host so the bridge
2447+// can call back into the TUI without importing modes directly.
2448+type telegramHost struct{ iv *Interactive }
2449+
2450+func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
2451+ h.iv.SubmitOrQueue(prompt, images)
2452+}
2453+
2454+func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
2455+
2456+func (h *telegramHost) Notify(level, message string) {
2457+ h.iv.mu.Lock()
2458+ switch level {
2459+ case "error", "warn":
2460+ h.iv.statusErr = message
2461+ h.iv.statusOK = ""
2462+ default:
2463+ h.iv.statusOK = message
2464+ h.iv.statusErr = ""
2465+ }
2466+ h.iv.mu.Unlock()
2467+ h.iv.invalidate()
2468+}
@@ -9,6 +9,7 @@ import (
9 "time"9 "time"
10 10
11 "github.com/patriceckhart/zot/internal/agent/extensions"11 "github.com/patriceckhart/zot/internal/agent/extensions"
12+ "github.com/patriceckhart/zot/internal/agent/modes/telegram"
12 "github.com/patriceckhart/zot/internal/agent/tools"13 "github.com/patriceckhart/zot/internal/agent/tools"
13 "github.com/patriceckhart/zot/internal/auth"14 "github.com/patriceckhart/zot/internal/auth"
14 "github.com/patriceckhart/zot/internal/core"15 "github.com/patriceckhart/zot/internal/core"
@@ -177,6 +178,8 @@ type Interactive struct {
177 changelogDialog *changelogDialog178 changelogDialog *changelogDialog
178 confirmDialog *confirmDialog179 confirmDialog *confirmDialog
179 logoutDialog *logoutDialog180 logoutDialog *logoutDialog
181+ telegramDialog *telegramDialog
182+ telegramBridge *telegram.Bridge
180 suggest *slashSuggester183 suggest *slashSuggester
181 spin *spinner184 spin *spinner
182 185
@@ -229,6 +232,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
229 changelogDialog: newChangelogDialog(),232 changelogDialog: newChangelogDialog(),
230 confirmDialog: newConfirmDialog(),233 confirmDialog: newConfirmDialog(),
231 logoutDialog: newLogoutDialog(),234 logoutDialog: newLogoutDialog(),
235+ telegramDialog: newTelegramDialog(),
232 suggest: newSlashSuggester(),236 suggest: newSlashSuggester(),
233 spin: newSpinner(),237 spin: newSpinner(),
234 }238 }
@@ -248,6 +252,11 @@ func (i *Interactive) Run(ctx context.Context) error {
248 return err252 return err
249 }253 }
250 defer restore()254 defer restore()
255+ defer func() {
256+ if i.telegramBridge != nil {
257+ i.telegramBridge.Stop()
258+ }
259+ }()
251 260
252 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn))261 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
253 _, _ = term.Write([]byte(tui.SeqAltScreenOn))262 _, _ = term.Write([]byte(tui.SeqAltScreenOn))
@@ -418,7 +427,7 @@ func (i *Interactive) Run(ctx context.Context) error {
418 // and the AfterFunc-driven invalidate got dropped on a427 // and the AfterFunc-driven invalidate got dropped on a
419 // full channel.428 // full channel.
420 drainPending()429 drainPending()
421- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() {430+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() {
422 requestRedraw() // keep the spinner / dialog animation moving431 requestRedraw() // keep the spinner / dialog animation moving
423 }432 }
424 }433 }
@@ -580,6 +589,8 @@ func (i *Interactive) redraw() {
580 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)589 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
581 case i.logoutDialog.Active():590 case i.logoutDialog.Active():
582 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)591 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
592+ case i.telegramDialog.Active():
593+ dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
583 }594 }
584 595
585 // Slash-command autocomplete: popup above the status line, only596 // Slash-command autocomplete: popup above the status line, only
@@ -642,6 +653,7 @@ func (i *Interactive) redraw() {
642 ContextUsed: i.lastCtxInput,653 ContextUsed: i.lastCtxInput,
643 ContextMax: ctxMax,654 ContextMax: ctxMax,
644 AutoCompacting: i.autoCompacting,655 AutoCompacting: i.autoCompacting,
656+ Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
645 Cols: cols,657 Cols: cols,
646 })658 })
647 edLines, curR, curC := i.ed.Render(cols)659 edLines, curR, curC := i.ed.Render(cols)
@@ -843,6 +855,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
843 i.invalidate()855 i.invalidate()
844 return false856 return false
845 }857 }
858+ if i.telegramDialog.Active() {
859+ if k.Kind == tui.KeyCtrlC {
860+ i.telegramDialog.Close()
861+ i.invalidate()
862+ return false
863+ }
864+ act := i.telegramDialog.HandleKey(k)
865+ if act.Select {
866+ i.doTelegram(act.Action)
867+ }
868+ i.invalidate()
869+ return false
870+ }
846 if i.jumpDialog.Active() {871 if i.jumpDialog.Active() {
847 if k.Kind == tui.KeyCtrlC {872 if k.Kind == tui.KeyCtrlC {
848 i.jumpDialog.Close()873 i.jumpDialog.Close()
@@ -1166,6 +1191,43 @@ func (i *Interactive) Submit(text string) {
1166 i.startTurn(i.runCtx, text)1191 i.startTurn(i.runCtx, text)
1167 }1192 }
1168 1193
1194+// SubmitOrQueue runs text immediately if the agent is idle, or
1195+// appends it to the pending queue if a turn is already in flight.
1196+// Used by the telegram bridge (and by the editor submit path) so
1197+// both input sources share the same "queue behind an active turn"
1198+// semantics. Images are ignored for now — only the text prompt is
1199+// forwarded — because the queued-prompt path is text-only; a
1200+// follow-up can expand the queue entry to carry images.
1201+func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
1202+ _ = images // reserved for future queued-with-images support
1203+ i.mu.Lock()
1204+ if i.agent == nil {
1205+ i.statusErr = "not logged in. type /login first."
1206+ i.mu.Unlock()
1207+ i.invalidate()
1208+ return
1209+ }
1210+ if i.busy {
1211+ i.queued = append(i.queued, text)
1212+ i.mu.Unlock()
1213+ i.invalidate()
1214+ return
1215+ }
1216+ i.mu.Unlock()
1217+ i.startTurn(i.runCtx, text)
1218+}
1219+
1220+// CancelTurn aborts the active turn if one is running. Used by the
1221+// telegram bridge when the paired user sends /stop.
1222+func (i *Interactive) CancelTurn() {
1223+ i.mu.Lock()
1224+ cancel := i.cancelTurn
1225+ i.mu.Unlock()
1226+ if cancel != nil {
1227+ cancel()
1228+ }
1229+}
1230+
1169 // Insert places text at the cursor in the editor.1231 // Insert places text at the cursor in the editor.
1170 func (i *Interactive) Insert(text string) {1232 func (i *Interactive) Insert(text string) {
1171 i.ed.Insert(text)1233 i.ed.Insert(text)
@@ -1266,6 +1328,12 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1266 i.runReloadExt(ctx)1328 i.runReloadExt(ctx)
1267 case "/yolo":1329 case "/yolo":
1268 i.runYoloOn()1330 i.runYoloOn()
1331+ case "/telegram", "/tg":
1332+ if len(parts) >= 2 {
1333+ i.doTelegram(parts[1])
1334+ break
1335+ }
1336+ i.openTelegramDialog()
1269 default:1337 default:
1270 // Last-resort fallback: try the extension manager. Built-in1338 // Last-resort fallback: try the extension manager. Built-in
1271 // cases above always win; this branch only fires for slash1339 // cases above always win; this branch only fires for slash
@@ -1979,6 +2047,24 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
1979 if i.cfg.OnAssistant != nil {2047 if i.cfg.OnAssistant != nil {
1980 i.cfg.OnAssistant(e.Message)2048 i.cfg.OnAssistant(e.Message)
1981 }2049 }
2050+ // Mirror the assistant's final visible text to the telegram
2051+ // bridge when active. Only TextBlock content is forwarded;
2052+ // tool_use blocks are internal. Send on a goroutine so the
2053+ // network call doesn't hold the event loop lock.
2054+ if i.telegramBridge != nil && i.telegramBridge.Active() {
2055+ var sb strings.Builder
2056+ for _, c := range e.Message.Content {
2057+ if tb, ok := c.(provider.TextBlock); ok {
2058+ if sb.Len() > 0 {
2059+ sb.WriteString("\n")
2060+ }
2061+ sb.WriteString(tb.Text)
2062+ }
2063+ }
2064+ if text := sb.String(); strings.TrimSpace(text) != "" {
2065+ go i.telegramBridge.OnAssistantText(text)
2066+ }
2067+ }
1982 case core.EvToolUseStart:2068 case core.EvToolUseStart:
1983 // Live streaming: pre-create the view so the user sees the2069 // Live streaming: pre-create the view so the user sees the
1984 // tool call being composed in real time. Any subsequent2070 // tool call being composed in real time. Any subsequent
@@ -2171,3 +2257,212 @@ func (i *Interactive) runYoloOn() {
2171 i.mu.Unlock()2257 i.mu.Unlock()
2172 i.invalidate()2258 i.invalidate()
2173 }2259 }
2260+
2261+// openTelegramDialog shows the picker for `/telegram` with no arg.
2262+// Items depend on current state: disconnect + status when running,
2263+// connect + status when stopped.
2264+func (i *Interactive) openTelegramDialog() {
2265+ items := i.telegramMenuItems()
2266+ if len(items) == 0 {
2267+ i.mu.Lock()
2268+ i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
2269+ i.mu.Unlock()
2270+ i.invalidate()
2271+ return
2272+ }
2273+ i.telegramDialog.Open(items)
2274+ i.invalidate()
2275+}
2276+
2277+// telegramMenuItems builds the dialog entries for the current
2278+// bridge state. Returns empty when no bot.json exists so the
2279+// caller can show a helpful status line instead of an empty menu.
2280+func (i *Interactive) telegramMenuItems() []telegramItem {
2281+ cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2282+ if err != nil || cfg.BotToken == "" {
2283+ return nil
2284+ }
2285+ var items []telegramItem
2286+ if i.telegramBridge != nil && i.telegramBridge.Active() {
2287+ items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
2288+ st := i.telegramBridge.State()
2289+ hint := "active"
2290+ if st.Username != "" {
2291+ hint += " as @" + st.Username
2292+ }
2293+ items = append(items, telegramItem{label: "status", action: "status", hint: hint})
2294+ } else {
2295+ label := "connect"
2296+ hint := "start mirroring dms into this session"
2297+ if cfg.AllowedUserID == 0 {
2298+ hint = "awaiting pairing (send /start to the bot once connected)"
2299+ }
2300+ items = append(items, telegramItem{label: label, action: "connect", hint: hint})
2301+ items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
2302+ }
2303+ return items
2304+}
2305+
2306+// doTelegram dispatches one of the three explicit actions. Called
2307+// from /telegram <action> or after the picker selects a row.
2308+func (i *Interactive) doTelegram(action string) {
2309+ switch action {
2310+ case "connect":
2311+ i.telegramConnect()
2312+ case "disconnect":
2313+ i.telegramDisconnect()
2314+ case "status":
2315+ i.telegramStatus()
2316+ default:
2317+ i.mu.Lock()
2318+ i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
2319+ i.mu.Unlock()
2320+ i.invalidate()
2321+ }
2322+}
2323+
2324+// telegramConnect starts the bridge. Refuses if it's already
2325+// running or if the on-disk bot.json is missing a token.
2326+func (i *Interactive) telegramConnect() {
2327+ if i.telegramBridge != nil && i.telegramBridge.Active() {
2328+ i.mu.Lock()
2329+ i.statusOK = "telegram already connected"
2330+ i.statusErr = ""
2331+ i.mu.Unlock()
2332+ i.invalidate()
2333+ return
2334+ }
2335+ cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2336+ if err != nil {
2337+ i.mu.Lock()
2338+ i.statusErr = "telegram: " + err.Error()
2339+ i.mu.Unlock()
2340+ i.invalidate()
2341+ return
2342+ }
2343+ if cfg.BotToken == "" {
2344+ i.mu.Lock()
2345+ i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
2346+ i.mu.Unlock()
2347+ i.invalidate()
2348+ return
2349+ }
2350+ // Refuse to start when a background daemon is already polling
2351+ // the same bot. Two concurrent long-poll consumers race each
2352+ // update and one always loses, so DMs get half-delivered. The
2353+ // user can `zot telegram-bot stop` first, then /telegram connect.
2354+ if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2355+ i.mu.Lock()
2356+ i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
2357+ i.mu.Unlock()
2358+ i.invalidate()
2359+ return
2360+ }
2361+ i.telegramBridge = &telegram.Bridge{
2362+ Client: telegram.NewClient(cfg.BotToken),
2363+ Config: cfg,
2364+ Save: func(next telegram.Config) error {
2365+ return telegram.SaveConfig(i.cfg.ZotHome, next)
2366+ },
2367+ Host: &telegramHost{iv: i},
2368+ }
2369+ if err := i.telegramBridge.Start(i.runCtx); err != nil {
2370+ i.telegramBridge = nil
2371+ i.mu.Lock()
2372+ i.statusErr = "telegram connect failed: " + err.Error()
2373+ i.mu.Unlock()
2374+ i.invalidate()
2375+ return
2376+ }
2377+ state := i.telegramBridge.State()
2378+ label := "telegram connected"
2379+ if state.Username != "" {
2380+ label += " as @" + state.Username
2381+ }
2382+ if state.PairedID == 0 {
2383+ label += " — send /start to the bot from your phone to claim it"
2384+ }
2385+ i.mu.Lock()
2386+ i.statusOK = label
2387+ i.statusErr = ""
2388+ i.mu.Unlock()
2389+ i.invalidate()
2390+}
2391+
2392+// telegramDisconnect stops the bridge. No-op when already stopped.
2393+func (i *Interactive) telegramDisconnect() {
2394+ if i.telegramBridge == nil || !i.telegramBridge.Active() {
2395+ i.mu.Lock()
2396+ i.statusOK = "telegram already disconnected"
2397+ i.statusErr = ""
2398+ i.mu.Unlock()
2399+ i.invalidate()
2400+ return
2401+ }
2402+ i.telegramBridge.Stop()
2403+ i.mu.Lock()
2404+ i.statusOK = "telegram disconnected"
2405+ i.statusErr = ""
2406+ i.mu.Unlock()
2407+ i.invalidate()
2408+}
2409+
2410+// telegramStatus writes a one-liner describing the bridge state.
2411+// Reports on both the in-tui bridge and the background daemon so
2412+// the user isn't confused when the daemon owns the poll loop.
2413+func (i *Interactive) telegramStatus() {
2414+ var msg string
2415+ if i.telegramBridge != nil && i.telegramBridge.Active() {
2416+ s := i.telegramBridge.State()
2417+ msg = "telegram: connected (tui bridge)"
2418+ if s.Username != "" {
2419+ msg += " as @" + s.Username
2420+ }
2421+ if s.PairedID != 0 {
2422+ msg += fmt.Sprintf(" · paired with user %d", s.PairedID)
2423+ } else {
2424+ msg += " · awaiting pairing"
2425+ }
2426+ } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2427+ msg = fmt.Sprintf("telegram: background daemon running (pid %d) · /telegram connect won't work until you stop it", pid)
2428+ } else {
2429+ cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
2430+ if cfg.BotToken == "" {
2431+ msg = "telegram: not configured. run `zot telegram-bot setup` first."
2432+ } else {
2433+ msg = "telegram: disconnected"
2434+ if cfg.BotUsername != "" {
2435+ msg += " (@" + cfg.BotUsername + " ready to connect)"
2436+ }
2437+ }
2438+ }
2439+ i.mu.Lock()
2440+ i.statusOK = msg
2441+ i.statusErr = ""
2442+ i.mu.Unlock()
2443+ i.invalidate()
2444+}
2445+
2446+// telegramHost adapts *Interactive to telegram.Host so the bridge
2447+// can call back into the TUI without importing modes directly.
2448+type telegramHost struct{ iv *Interactive }
2449+
2450+func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
2451+ h.iv.SubmitOrQueue(prompt, images)
2452+}
2453+
2454+func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
2455+
2456+func (h *telegramHost) Notify(level, message string) {
2457+ h.iv.mu.Lock()
2458+ switch level {
2459+ case "error", "warn":
2460+ h.iv.statusErr = message
2461+ h.iv.statusOK = ""
2462+ default:
2463+ h.iv.statusOK = message
2464+ h.iv.statusErr = ""
2465+ }
2466+ h.iv.mu.Unlock()
2467+ h.iv.invalidate()
2468+}
M internal/agent/modes/slash_suggest.go +1 -0
@@ -45,6 +45,7 @@ var slashCatalog = []slashCommand{
4545 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},
4646 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
4747 {Name: "/yolo", Desc: "turn off --no-yolo confirmation for the rest of this session"},
48+ {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
4849 {Name: "/clear", Desc: "clear the chat transcript"},
4950 {Name: "/exit", Desc: "exit zot"},
5051 }
@@ -45,6 +45,7 @@ var slashCatalog = []slashCommand{
45 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},45 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},
46 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},46 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
47 {Name: "/yolo", Desc: "turn off --no-yolo confirmation for the rest of this session"},47 {Name: "/yolo", Desc: "turn off --no-yolo confirmation for the rest of this session"},
48+ {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
48 {Name: "/clear", Desc: "clear the chat transcript"},49 {Name: "/clear", Desc: "clear the chat transcript"},
49 {Name: "/exit", Desc: "exit zot"},50 {Name: "/exit", Desc: "exit zot"},
50 }51 }
A internal/agent/modes/telegram/bridge.go +312 -0
@@ -0,0 +1,312 @@
1+package telegram
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/internal/provider"
11+)
12+
13+// Host is the small interface the Bridge calls back into the TUI
14+// through. Decouples bridge plumbing from the Interactive type.
15+type Host interface {
16+ // SubmitOrQueue feeds a user prompt into the running agent.
17+ // Runs now if the agent is idle, queues behind any in-flight
18+ // turn otherwise.
19+ SubmitOrQueue(prompt string, images []provider.ImageBlock)
20+
21+ // CancelTurn aborts the active turn (if any). Called when the
22+ // paired Telegram user sends /stop.
23+ CancelTurn()
24+
25+ // Notify pushes a one-shot status line into the chat. Used to
26+ // surface bridge events ("connected as @bot", "paired with
27+ // user X", etc.) in the user's local transcript.
28+ Notify(level, message string)
29+}
30+
31+// Bridge polls Telegram for updates and forwards them into the
32+// Host's running agent, then mirrors the agent's final assistant
33+// text back to the paired Telegram user. One bridge per Interactive
34+// instance; created on /telegram connect, stopped on /telegram
35+// disconnect or zot exit.
36+type Bridge struct {
37+ Client *Client
38+ Config Config
39+ Save func(Config) error
40+ Host Host
41+
42+ mu sync.Mutex
43+ running bool
44+ cancel context.CancelFunc
45+ me *User
46+ chatID int64 // populated after first DM from the paired user
47+ replyBuf strings.Builder
48+}
49+
50+// State is the snapshot /telegram status reports.
51+type State struct {
52+ Running bool
53+ Username string // bot username, e.g. "zotbot"
54+ PairedID int64 // 0 when no user has claimed the bot yet
55+}
56+
57+// Active reports whether the bridge is currently polling.
58+func (b *Bridge) Active() bool {
59+ if b == nil {
60+ return false
61+ }
62+ b.mu.Lock()
63+ defer b.mu.Unlock()
64+ return b.running
65+}
66+
67+// State returns a snapshot of the bridge for /telegram status.
68+func (b *Bridge) State() State {
69+ if b == nil {
70+ return State{}
71+ }
72+ b.mu.Lock()
73+ defer b.mu.Unlock()
74+ s := State{Running: b.running, PairedID: b.Config.AllowedUserID}
75+ if b.me != nil {
76+ s.Username = b.me.Username
77+ }
78+ return s
79+}
80+
81+// Start kicks off the polling loop. Idempotent: calling twice
82+// returns nil the second time and leaves the existing loop alone.
83+// Verifies the bot token with GetMe before starting the loop so
84+// obvious configuration errors surface immediately.
85+func (b *Bridge) Start(parent context.Context) error {
86+ b.mu.Lock()
87+ if b.running {
88+ b.mu.Unlock()
89+ return nil
90+ }
91+ if b.Config.BotToken == "" {
92+ b.mu.Unlock()
93+ return fmt.Errorf("no bot token configured; run `zot telegram-bot setup` first")
94+ }
95+ b.mu.Unlock()
96+
97+ ctx, cancel := context.WithCancel(parent)
98+ // Quick handshake before committing the loop.
99+ me, err := b.Client.GetMe(ctx)
100+ if err != nil {
101+ cancel()
102+ return fmt.Errorf("getMe: %w", err)
103+ }
104+
105+ b.mu.Lock()
106+ b.running = true
107+ b.cancel = cancel
108+ b.me = me
109+ if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {
110+ b.Config.BotID = me.ID
111+ b.Config.BotUsername = me.Username
112+ _ = b.Save(b.Config)
113+ }
114+ b.mu.Unlock()
115+
116+ go b.pollLoop(ctx)
117+ return nil
118+}
119+
120+// Stop halts the polling loop. Safe to call when not running.
121+func (b *Bridge) Stop() {
122+ b.mu.Lock()
123+ cancel := b.cancel
124+ b.running = false
125+ b.cancel = nil
126+ b.mu.Unlock()
127+ if cancel != nil {
128+ cancel()
129+ }
130+}
131+
132+// OnAssistantText should be called by the TUI with the assistant's
133+// final visible text for each turn. The bridge forwards it to the
134+// paired chat in message-sized chunks. Safe to call from any
135+// goroutine; a no-op when the bridge is stopped or no chat is
136+// known yet.
137+func (b *Bridge) OnAssistantText(text string) {
138+ b.mu.Lock()
139+ chatID := b.chatID
140+ running := b.running
141+ b.mu.Unlock()
142+ if !running || chatID == 0 {
143+ return
144+ }
145+ text = strings.TrimSpace(text)
146+ if text == "" {
147+ return
148+ }
149+ // Telegram caps at 4096 chars; chunk to be safe.
150+ for _, chunk := range chunkMessage(text, 4000) {
151+ if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
152+ fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
153+ return
154+ }
155+ }
156+}
157+
158+// pollLoop long-polls Telegram and dispatches each update. Runs
159+// until ctx cancels.
160+func (b *Bridge) pollLoop(ctx context.Context) {
161+ backoff := time.Second
162+ for {
163+ if err := ctx.Err(); err != nil {
164+ return
165+ }
166+ updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30)
167+ if err != nil {
168+ if ctx.Err() != nil {
169+ return
170+ }
171+ b.Host.Notify("warn", fmt.Sprintf("telegram: getUpdates: %v", err))
172+ select {
173+ case <-ctx.Done():
174+ return
175+ case <-time.After(backoff):
176+ }
177+ if backoff < 30*time.Second {
178+ backoff *= 2
179+ }
180+ continue
181+ }
182+ backoff = time.Second
183+ for _, u := range updates {
184+ b.handleUpdate(ctx, u)
185+ b.mu.Lock()
186+ b.Config.LastUpdateID = u.UpdateID
187+ _ = b.Save(b.Config)
188+ b.mu.Unlock()
189+ }
190+ }
191+}
192+
193+// handleUpdate applies pairing, gates on the allowed user, decodes
194+// the interesting bits (text, caption, image attachments), and
195+// forwards them to the Host. Built-in slash commands (/start,
196+// /help, /status, /stop) are handled inline without touching the
197+// agent.
198+func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
199+ msg := u.Message
200+ if msg == nil {
201+ msg = u.Edited
202+ }
203+ if msg == nil || msg.From == nil || msg.From.IsBot {
204+ return
205+ }
206+ if msg.Chat.Type != "private" {
207+ return
208+ }
209+
210+ text := strings.TrimSpace(msg.Text)
211+
212+ // Pairing: first user who sends /start claims the bridge.
213+ b.mu.Lock()
214+ paired := b.Config.AllowedUserID
215+ b.mu.Unlock()
216+ if paired == 0 {
217+ if strings.HasPrefix(text, "/start") {
218+ b.mu.Lock()
219+ b.Config.AllowedUserID = msg.From.ID
220+ b.chatID = msg.Chat.ID
221+ _ = b.Save(b.Config)
222+ b.mu.Unlock()
223+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
224+ fmt.Sprintf("paired with @%s. messages you send here now mirror into the zot tui.", msg.From.Username),
225+ msg.MessageID)
226+ b.Host.Notify("success", fmt.Sprintf("telegram paired with user %d (@%s)", msg.From.ID, msg.From.Username))
227+ return
228+ }
229+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
230+ "this bot isn't paired yet. send /start to claim it.",
231+ msg.MessageID)
232+ return
233+ }
234+
235+ if msg.From.ID != paired {
236+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
237+ "this bot is paired with a different user.",
238+ msg.MessageID)
239+ return
240+ }
241+
242+ // Remember the chat id so replies can go out without waiting
243+ // for another update round-trip.
244+ b.mu.Lock()
245+ b.chatID = msg.Chat.ID
246+ b.mu.Unlock()
247+
248+ // Built-in commands that bypass the agent.
249+ switch text {
250+ case "/start", "/help":
251+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
252+ "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop.",
253+ msg.MessageID)
254+ return
255+ case "/status":
256+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
257+ fmt.Sprintf("mirror active. paired user: %d.", paired),
258+ msg.MessageID)
259+ return
260+ case "/stop":
261+ b.Host.CancelTurn()
262+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
263+ "cancelled the current turn.", msg.MessageID)
264+ return
265+ }
266+
267+ // Build the prompt: text + caption; download image attachments.
268+ prompt := strings.TrimSpace(msg.Text)
269+ if msg.Caption != "" {
270+ if prompt != "" {
271+ prompt += "\n"
272+ }
273+ prompt += msg.Caption
274+ }
275+
276+ var images []provider.ImageBlock
277+ if len(msg.Photo) > 0 {
278+ largest := msg.Photo[len(msg.Photo)-1]
279+ if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil {
280+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
281+ }
282+ }
283+ if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
284+ if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
285+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
286+ }
287+ }
288+
289+ if prompt == "" && len(images) == 0 {
290+ return
291+ }
292+
293+ b.Host.SubmitOrQueue(prompt, images)
294+}
295+
296+// download fetches the file referenced by fileID and returns its
297+// bytes + mime type. Mime overrides the detected value when non-empty.
298+func (b *Bridge) download(ctx context.Context, fileID, mimeOverride string) ([]byte, string, error) {
299+ f, err := b.Client.GetFile(ctx, fileID)
300+ if err != nil {
301+ return nil, "", err
302+ }
303+ data, err := b.Client.DownloadFile(ctx, f.FilePath)
304+ if err != nil {
305+ return nil, "", err
306+ }
307+ mime := mimeOverride
308+ if mime == "" {
309+ mime = guessImageMIME(f.FilePath)
310+ }
311+ return data, mime, nil
312+}
@@ -0,0 +1,312 @@
1+package telegram
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/internal/provider"
11+)
12+
13+// Host is the small interface the Bridge calls back into the TUI
14+// through. Decouples bridge plumbing from the Interactive type.
15+type Host interface {
16+ // SubmitOrQueue feeds a user prompt into the running agent.
17+ // Runs now if the agent is idle, queues behind any in-flight
18+ // turn otherwise.
19+ SubmitOrQueue(prompt string, images []provider.ImageBlock)
20+
21+ // CancelTurn aborts the active turn (if any). Called when the
22+ // paired Telegram user sends /stop.
23+ CancelTurn()
24+
25+ // Notify pushes a one-shot status line into the chat. Used to
26+ // surface bridge events ("connected as @bot", "paired with
27+ // user X", etc.) in the user's local transcript.
28+ Notify(level, message string)
29+}
30+
31+// Bridge polls Telegram for updates and forwards them into the
32+// Host's running agent, then mirrors the agent's final assistant
33+// text back to the paired Telegram user. One bridge per Interactive
34+// instance; created on /telegram connect, stopped on /telegram
35+// disconnect or zot exit.
36+type Bridge struct {
37+ Client *Client
38+ Config Config
39+ Save func(Config) error
40+ Host Host
41+
42+ mu sync.Mutex
43+ running bool
44+ cancel context.CancelFunc
45+ me *User
46+ chatID int64 // populated after first DM from the paired user
47+ replyBuf strings.Builder
48+}
49+
50+// State is the snapshot /telegram status reports.
51+type State struct {
52+ Running bool
53+ Username string // bot username, e.g. "zotbot"
54+ PairedID int64 // 0 when no user has claimed the bot yet
55+}
56+
57+// Active reports whether the bridge is currently polling.
58+func (b *Bridge) Active() bool {
59+ if b == nil {
60+ return false
61+ }
62+ b.mu.Lock()
63+ defer b.mu.Unlock()
64+ return b.running
65+}
66+
67+// State returns a snapshot of the bridge for /telegram status.
68+func (b *Bridge) State() State {
69+ if b == nil {
70+ return State{}
71+ }
72+ b.mu.Lock()
73+ defer b.mu.Unlock()
74+ s := State{Running: b.running, PairedID: b.Config.AllowedUserID}
75+ if b.me != nil {
76+ s.Username = b.me.Username
77+ }
78+ return s
79+}
80+
81+// Start kicks off the polling loop. Idempotent: calling twice
82+// returns nil the second time and leaves the existing loop alone.
83+// Verifies the bot token with GetMe before starting the loop so
84+// obvious configuration errors surface immediately.
85+func (b *Bridge) Start(parent context.Context) error {
86+ b.mu.Lock()
87+ if b.running {
88+ b.mu.Unlock()
89+ return nil
90+ }
91+ if b.Config.BotToken == "" {
92+ b.mu.Unlock()
93+ return fmt.Errorf("no bot token configured; run `zot telegram-bot setup` first")
94+ }
95+ b.mu.Unlock()
96+
97+ ctx, cancel := context.WithCancel(parent)
98+ // Quick handshake before committing the loop.
99+ me, err := b.Client.GetMe(ctx)
100+ if err != nil {
101+ cancel()
102+ return fmt.Errorf("getMe: %w", err)
103+ }
104+
105+ b.mu.Lock()
106+ b.running = true
107+ b.cancel = cancel
108+ b.me = me
109+ if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {
110+ b.Config.BotID = me.ID
111+ b.Config.BotUsername = me.Username
112+ _ = b.Save(b.Config)
113+ }
114+ b.mu.Unlock()
115+
116+ go b.pollLoop(ctx)
117+ return nil
118+}
119+
120+// Stop halts the polling loop. Safe to call when not running.
121+func (b *Bridge) Stop() {
122+ b.mu.Lock()
123+ cancel := b.cancel
124+ b.running = false
125+ b.cancel = nil
126+ b.mu.Unlock()
127+ if cancel != nil {
128+ cancel()
129+ }
130+}
131+
132+// OnAssistantText should be called by the TUI with the assistant's
133+// final visible text for each turn. The bridge forwards it to the
134+// paired chat in message-sized chunks. Safe to call from any
135+// goroutine; a no-op when the bridge is stopped or no chat is
136+// known yet.
137+func (b *Bridge) OnAssistantText(text string) {
138+ b.mu.Lock()
139+ chatID := b.chatID
140+ running := b.running
141+ b.mu.Unlock()
142+ if !running || chatID == 0 {
143+ return
144+ }
145+ text = strings.TrimSpace(text)
146+ if text == "" {
147+ return
148+ }
149+ // Telegram caps at 4096 chars; chunk to be safe.
150+ for _, chunk := range chunkMessage(text, 4000) {
151+ if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
152+ fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
153+ return
154+ }
155+ }
156+}
157+
158+// pollLoop long-polls Telegram and dispatches each update. Runs
159+// until ctx cancels.
160+func (b *Bridge) pollLoop(ctx context.Context) {
161+ backoff := time.Second
162+ for {
163+ if err := ctx.Err(); err != nil {
164+ return
165+ }
166+ updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30)
167+ if err != nil {
168+ if ctx.Err() != nil {
169+ return
170+ }
171+ b.Host.Notify("warn", fmt.Sprintf("telegram: getUpdates: %v", err))
172+ select {
173+ case <-ctx.Done():
174+ return
175+ case <-time.After(backoff):
176+ }
177+ if backoff < 30*time.Second {
178+ backoff *= 2
179+ }
180+ continue
181+ }
182+ backoff = time.Second
183+ for _, u := range updates {
184+ b.handleUpdate(ctx, u)
185+ b.mu.Lock()
186+ b.Config.LastUpdateID = u.UpdateID
187+ _ = b.Save(b.Config)
188+ b.mu.Unlock()
189+ }
190+ }
191+}
192+
193+// handleUpdate applies pairing, gates on the allowed user, decodes
194+// the interesting bits (text, caption, image attachments), and
195+// forwards them to the Host. Built-in slash commands (/start,
196+// /help, /status, /stop) are handled inline without touching the
197+// agent.
198+func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
199+ msg := u.Message
200+ if msg == nil {
201+ msg = u.Edited
202+ }
203+ if msg == nil || msg.From == nil || msg.From.IsBot {
204+ return
205+ }
206+ if msg.Chat.Type != "private" {
207+ return
208+ }
209+
210+ text := strings.TrimSpace(msg.Text)
211+
212+ // Pairing: first user who sends /start claims the bridge.
213+ b.mu.Lock()
214+ paired := b.Config.AllowedUserID
215+ b.mu.Unlock()
216+ if paired == 0 {
217+ if strings.HasPrefix(text, "/start") {
218+ b.mu.Lock()
219+ b.Config.AllowedUserID = msg.From.ID
220+ b.chatID = msg.Chat.ID
221+ _ = b.Save(b.Config)
222+ b.mu.Unlock()
223+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
224+ fmt.Sprintf("paired with @%s. messages you send here now mirror into the zot tui.", msg.From.Username),
225+ msg.MessageID)
226+ b.Host.Notify("success", fmt.Sprintf("telegram paired with user %d (@%s)", msg.From.ID, msg.From.Username))
227+ return
228+ }
229+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
230+ "this bot isn't paired yet. send /start to claim it.",
231+ msg.MessageID)
232+ return
233+ }
234+
235+ if msg.From.ID != paired {
236+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
237+ "this bot is paired with a different user.",
238+ msg.MessageID)
239+ return
240+ }
241+
242+ // Remember the chat id so replies can go out without waiting
243+ // for another update round-trip.
244+ b.mu.Lock()
245+ b.chatID = msg.Chat.ID
246+ b.mu.Unlock()
247+
248+ // Built-in commands that bypass the agent.
249+ switch text {
250+ case "/start", "/help":
251+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
252+ "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop.",
253+ msg.MessageID)
254+ return
255+ case "/status":
256+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
257+ fmt.Sprintf("mirror active. paired user: %d.", paired),
258+ msg.MessageID)
259+ return
260+ case "/stop":
261+ b.Host.CancelTurn()
262+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
263+ "cancelled the current turn.", msg.MessageID)
264+ return
265+ }
266+
267+ // Build the prompt: text + caption; download image attachments.
268+ prompt := strings.TrimSpace(msg.Text)
269+ if msg.Caption != "" {
270+ if prompt != "" {
271+ prompt += "\n"
272+ }
273+ prompt += msg.Caption
274+ }
275+
276+ var images []provider.ImageBlock
277+ if len(msg.Photo) > 0 {
278+ largest := msg.Photo[len(msg.Photo)-1]
279+ if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil {
280+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
281+ }
282+ }
283+ if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
284+ if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
285+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
286+ }
287+ }
288+
289+ if prompt == "" && len(images) == 0 {
290+ return
291+ }
292+
293+ b.Host.SubmitOrQueue(prompt, images)
294+}
295+
296+// download fetches the file referenced by fileID and returns its
297+// bytes + mime type. Mime overrides the detected value when non-empty.
298+func (b *Bridge) download(ctx context.Context, fileID, mimeOverride string) ([]byte, string, error) {
299+ f, err := b.Client.GetFile(ctx, fileID)
300+ if err != nil {
301+ return nil, "", err
302+ }
303+ data, err := b.Client.DownloadFile(ctx, f.FilePath)
304+ if err != nil {
305+ return nil, "", err
306+ }
307+ mime := mimeOverride
308+ if mime == "" {
309+ mime = guessImageMIME(f.FilePath)
310+ }
311+ return data, mime, nil
312+}
A internal/agent/modes/telegram_dialog.go +100 -0
@@ -0,0 +1,100 @@
1+package modes
2+
3+import (
4+ "github.com/patriceckhart/zot/internal/tui"
5+)
6+
7+// telegramDialog is the picker shown when the user runs `/telegram`
8+// without an argument. Lists the three actions (connect, disconnect,
9+// status) and routes the choice back to the Interactive via
10+// telegramAction.
11+//
12+// Shape mirrors logoutDialog: a tiny list of rows with the cursor
13+// moved by arrow keys and enter to pick, esc to cancel.
14+type telegramDialog struct {
15+ active bool
16+ items []telegramItem
17+ cursor int
18+}
19+
20+type telegramItem struct {
21+ label string
22+ action string // "connect" | "disconnect" | "status"
23+ hint string // muted text shown after the label (e.g. "not configured", "active")
24+}
25+
26+type telegramAction struct {
27+ Select bool
28+ Action string
29+ Close bool
30+}
31+
32+func newTelegramDialog() *telegramDialog { return &telegramDialog{} }
33+
34+// Open shows the picker with items describing the current state.
35+// items is rendered in order; the caller builds it so connect is
36+// only offered when disconnected, and vice versa.
37+func (d *telegramDialog) Open(items []telegramItem) bool {
38+ if len(items) == 0 {
39+ return false
40+ }
41+ d.items = items
42+ d.cursor = 0
43+ d.active = true
44+ return true
45+}
46+
47+// Close hides the dialog.
48+func (d *telegramDialog) Close() { d.active = false }
49+
50+// Active reports whether the dialog is consuming input.
51+func (d *telegramDialog) Active() bool { return d != nil && d.active }
52+
53+// HandleKey advances the selection or resolves the dialog.
54+func (d *telegramDialog) HandleKey(k tui.Key) telegramAction {
55+ switch k.Kind {
56+ case tui.KeyUp:
57+ if d.cursor > 0 {
58+ d.cursor--
59+ }
60+ case tui.KeyDown:
61+ if d.cursor < len(d.items)-1 {
62+ d.cursor++
63+ }
64+ case tui.KeyEsc:
65+ d.Close()
66+ return telegramAction{Close: true}
67+ case tui.KeyEnter:
68+ if len(d.items) == 0 {
69+ d.Close()
70+ return telegramAction{Close: true}
71+ }
72+ it := d.items[d.cursor]
73+ d.Close()
74+ return telegramAction{Select: true, Action: it.action}
75+ }
76+ return telegramAction{}
77+}
78+
79+// Render returns the dialog lines.
80+func (d *telegramDialog) Render(th tui.Theme, width int) []string {
81+ if !d.Active() {
82+ return nil
83+ }
84+ var lines []string
85+ lines = append(lines, frameHeader(th, "telegram", width))
86+ lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
87+ for i, it := range d.items {
88+ plain := " " + it.label
89+ if it.hint != "" {
90+ plain += " " + th.FG256(th.Muted, "("+it.hint+")")
91+ }
92+ if i == d.cursor {
93+ lines = append(lines, th.PadHighlight(plain, width))
94+ } else {
95+ lines = append(lines, th.FG256(th.Muted, plain))
96+ }
97+ }
98+ lines = append(lines, frameRule(th, width))
99+ return lines
100+}
@@ -0,0 +1,100 @@
1+package modes
2+
3+import (
4+ "github.com/patriceckhart/zot/internal/tui"
5+)
6+
7+// telegramDialog is the picker shown when the user runs `/telegram`
8+// without an argument. Lists the three actions (connect, disconnect,
9+// status) and routes the choice back to the Interactive via
10+// telegramAction.
11+//
12+// Shape mirrors logoutDialog: a tiny list of rows with the cursor
13+// moved by arrow keys and enter to pick, esc to cancel.
14+type telegramDialog struct {
15+ active bool
16+ items []telegramItem
17+ cursor int
18+}
19+
20+type telegramItem struct {
21+ label string
22+ action string // "connect" | "disconnect" | "status"
23+ hint string // muted text shown after the label (e.g. "not configured", "active")
24+}
25+
26+type telegramAction struct {
27+ Select bool
28+ Action string
29+ Close bool
30+}
31+
32+func newTelegramDialog() *telegramDialog { return &telegramDialog{} }
33+
34+// Open shows the picker with items describing the current state.
35+// items is rendered in order; the caller builds it so connect is
36+// only offered when disconnected, and vice versa.
37+func (d *telegramDialog) Open(items []telegramItem) bool {
38+ if len(items) == 0 {
39+ return false
40+ }
41+ d.items = items
42+ d.cursor = 0
43+ d.active = true
44+ return true
45+}
46+
47+// Close hides the dialog.
48+func (d *telegramDialog) Close() { d.active = false }
49+
50+// Active reports whether the dialog is consuming input.
51+func (d *telegramDialog) Active() bool { return d != nil && d.active }
52+
53+// HandleKey advances the selection or resolves the dialog.
54+func (d *telegramDialog) HandleKey(k tui.Key) telegramAction {
55+ switch k.Kind {
56+ case tui.KeyUp:
57+ if d.cursor > 0 {
58+ d.cursor--
59+ }
60+ case tui.KeyDown:
61+ if d.cursor < len(d.items)-1 {
62+ d.cursor++
63+ }
64+ case tui.KeyEsc:
65+ d.Close()
66+ return telegramAction{Close: true}
67+ case tui.KeyEnter:
68+ if len(d.items) == 0 {
69+ d.Close()
70+ return telegramAction{Close: true}
71+ }
72+ it := d.items[d.cursor]
73+ d.Close()
74+ return telegramAction{Select: true, Action: it.action}
75+ }
76+ return telegramAction{}
77+}
78+
79+// Render returns the dialog lines.
80+func (d *telegramDialog) Render(th tui.Theme, width int) []string {
81+ if !d.Active() {
82+ return nil
83+ }
84+ var lines []string
85+ lines = append(lines, frameHeader(th, "telegram", width))
86+ lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
87+ for i, it := range d.items {
88+ plain := " " + it.label
89+ if it.hint != "" {
90+ plain += " " + th.FG256(th.Muted, "("+it.hint+")")
91+ }
92+ if i == d.cursor {
93+ lines = append(lines, th.PadHighlight(plain, width))
94+ } else {
95+ lines = append(lines, th.FG256(th.Muted, plain))
96+ }
97+ }
98+ lines = append(lines, frameRule(th, width))
99+ return lines
100+}
M internal/tui/view.go +14 -2
@@ -1045,6 +1045,11 @@ type StatusBarParams struct {
10451045 // context percentage so it's clear where the spinner is coming from.
10461046 AutoCompacting bool
10471047
1048+ // Telegram true when the telegram bridge is connected. Adds a
1049+ // small "· tg ·" tag to the cwd line so the user can tell at a
1050+ // glance that dms are being mirrored into this session.
1051+ Telegram bool
1052+
10481053 Cols int // terminal width; drives right-alignment of cwd
10491054 }
10501055
@@ -1133,8 +1138,15 @@ func StatusBar(p StatusBarParams) []string {
11331138 }
11341139
11351140 cwd := shortenHome(p.CWD)
1136- if p.Locked && cwd != "" {
1137- cwd = "· jailed · " + cwd
1141+ tags := ""
1142+ if p.Locked {
1143+ tags += "· jailed "
1144+ }
1145+ if p.Telegram {
1146+ tags += "· tg "
1147+ }
1148+ if tags != "" && cwd != "" {
1149+ cwd = tags + "· " + cwd
11381150 }
11391151
11401152 primary := leftBuilder.String()
@@ -1045,6 +1045,11 @@ type StatusBarParams struct {
1045 // context percentage so it's clear where the spinner is coming from.1045 // context percentage so it's clear where the spinner is coming from.
1046 AutoCompacting bool1046 AutoCompacting bool
1047 1047
1048+ // Telegram true when the telegram bridge is connected. Adds a
1049+ // small "· tg ·" tag to the cwd line so the user can tell at a
1050+ // glance that dms are being mirrored into this session.
1051+ Telegram bool
1052+
1048 Cols int // terminal width; drives right-alignment of cwd1053 Cols int // terminal width; drives right-alignment of cwd
1049 }1054 }
1050 1055
@@ -1133,8 +1138,15 @@ func StatusBar(p StatusBarParams) []string {
1133 }1138 }
1134 1139
1135 cwd := shortenHome(p.CWD)1140 cwd := shortenHome(p.CWD)
1136- if p.Locked && cwd != "" {1141+ tags := ""
1137- cwd = "· jailed · " + cwd1142+ if p.Locked {
1143+ tags += "· jailed "
1144+ }
1145+ if p.Telegram {
1146+ tags += "· tg "
1147+ }
1148+ if tags != "" && cwd != "" {
1149+ cwd = tags + "· " + cwd
1138 }1150 }
1139 1151
1140 primary := leftBuilder.String()1152 primary := leftBuilder.String()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close