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: | |||
| 198 | 198 | | `/unjail` | Allow tools to touch paths outside again. | | |
| 199 | 199 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| 200 | 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 | 202 | | `/clear` | Clear the chat transcript. | | |
| 202 | 203 | | `/exit` | Exit zot. | | |
| 203 | 204 | ||
| @@ -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 ( | |||
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/patriceckhart/zot/internal/agent/extensions" | |
| 12 | + | "github.com/patriceckhart/zot/internal/agent/modes/telegram" | |
| 12 | 13 | "github.com/patriceckhart/zot/internal/agent/tools" | |
| 13 | 14 | "github.com/patriceckhart/zot/internal/auth" | |
| 14 | 15 | "github.com/patriceckhart/zot/internal/core" | |
| @@ -177,6 +178,8 @@ type Interactive struct { | |||
| 177 | 178 | changelogDialog *changelogDialog | |
| 178 | 179 | confirmDialog *confirmDialog | |
| 179 | 180 | logoutDialog *logoutDialog | |
| 181 | + | telegramDialog *telegramDialog | |
| 182 | + | telegramBridge *telegram.Bridge | |
| 180 | 183 | suggest *slashSuggester | |
| 181 | 184 | spin *spinner | |
| 182 | 185 | ||
| @@ -229,6 +232,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 229 | 232 | changelogDialog: newChangelogDialog(), | |
| 230 | 233 | confirmDialog: newConfirmDialog(), | |
| 231 | 234 | logoutDialog: newLogoutDialog(), | |
| 235 | + | telegramDialog: newTelegramDialog(), | |
| 232 | 236 | suggest: newSlashSuggester(), | |
| 233 | 237 | spin: newSpinner(), | |
| 234 | 238 | } | |
| @@ -248,6 +252,11 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 248 | 252 | return err | |
| 249 | 253 | } | |
| 250 | 254 | defer restore() | |
| 255 | + | defer func() { | |
| 256 | + | if i.telegramBridge != nil { | |
| 257 | + | i.telegramBridge.Stop() | |
| 258 | + | } | |
| 259 | + | }() | |
| 251 | 260 | ||
| 252 | 261 | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn)) | |
| 253 | 262 | _, _ = term.Write([]byte(tui.SeqAltScreenOn)) | |
| @@ -418,7 +427,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 418 | 427 | // and the AfterFunc-driven invalidate got dropped on a | |
| 419 | 428 | // full channel. | |
| 420 | 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 | 431 | requestRedraw() // keep the spinner / dialog animation moving | |
| 423 | 432 | } | |
| 424 | 433 | } | |
| @@ -580,6 +589,8 @@ func (i *Interactive) redraw() { | |||
| 580 | 589 | dialog = i.confirmDialog.Render(i.cfg.Theme, cols) | |
| 581 | 590 | case i.logoutDialog.Active(): | |
| 582 | 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 | 596 | // Slash-command autocomplete: popup above the status line, only | |
| @@ -642,6 +653,7 @@ func (i *Interactive) redraw() { | |||
| 642 | 653 | ContextUsed: i.lastCtxInput, | |
| 643 | 654 | ContextMax: ctxMax, | |
| 644 | 655 | AutoCompacting: i.autoCompacting, | |
| 656 | + | Telegram: i.telegramBridge != nil && i.telegramBridge.Active(), | |
| 645 | 657 | Cols: cols, | |
| 646 | 658 | }) | |
| 647 | 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 | 855 | i.invalidate() | |
| 844 | 856 | 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 | 871 | if i.jumpDialog.Active() { | |
| 847 | 872 | if k.Kind == tui.KeyCtrlC { | |
| 848 | 873 | i.jumpDialog.Close() | |
| @@ -1166,6 +1191,43 @@ func (i *Interactive) Submit(text string) { | |||
| 1166 | 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 | 1231 | // Insert places text at the cursor in the editor. | |
| 1170 | 1232 | func (i *Interactive) Insert(text string) { | |
| 1171 | 1233 | i.ed.Insert(text) | |
| @@ -1266,6 +1328,12 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 1266 | 1328 | i.runReloadExt(ctx) | |
| 1267 | 1329 | case "/yolo": | |
| 1268 | 1330 | i.runYoloOn() | |
| 1331 | + | case "/telegram", "/tg": | |
| 1332 | + | if len(parts) >= 2 { | |
| 1333 | + | i.doTelegram(parts[1]) | |
| 1334 | + | break | |
| 1335 | + | } | |
| 1336 | + | i.openTelegramDialog() | |
| 1269 | 1337 | default: | |
| 1270 | 1338 | // Last-resort fallback: try the extension manager. Built-in | |
| 1271 | 1339 | // cases above always win; this branch only fires for slash | |
| @@ -1979,6 +2047,24 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 1979 | 2047 | if i.cfg.OnAssistant != nil { | |
| 1980 | 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 | 2068 | case core.EvToolUseStart: | |
| 1983 | 2069 | // Live streaming: pre-create the view so the user sees the | |
| 1984 | 2070 | // tool call being composed in real time. Any subsequent | |
| @@ -2171,3 +2257,212 @@ func (i *Interactive) runYoloOn() { | |||
| 2171 | 2257 | i.mu.Unlock() | |
| 2172 | 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 | + | } | |
| @@ -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 *changelogDialog | 178 | changelogDialog *changelogDialog | ||
| 178 | confirmDialog *confirmDialog | 179 | confirmDialog *confirmDialog | ||
| 179 | logoutDialog *logoutDialog | 180 | logoutDialog *logoutDialog | ||
| 181 | + | telegramDialog *telegramDialog | |||
| 182 | + | telegramBridge *telegram.Bridge | |||
| 180 | suggest *slashSuggester | 183 | suggest *slashSuggester | ||
| 181 | spin *spinner | 184 | 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 err | 252 | 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 a | 427 | // 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 moving | 431 | 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, only | 596 | // 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 false | 856 | 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-in | 1338 | // Last-resort fallback: try the extension manager. Built-in | ||
| 1271 | // cases above always win; this branch only fires for slash | 1339 | // 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 the | 2069 | // Live streaming: pre-create the view so the user sees the | ||
| 1984 | // tool call being composed in real time. Any subsequent | 2070 | // 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{ | |||
| 45 | 45 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | |
| 46 | 46 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | |
| 47 | 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 | 49 | {Name: "/clear", Desc: "clear the chat transcript"}, | |
| 49 | 50 | {Name: "/exit", Desc: "exit zot"}, | |
| 50 | 51 | } | |
| @@ -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 { | |||
| 1045 | 1045 | // context percentage so it's clear where the spinner is coming from. | |
| 1046 | 1046 | 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 | 1053 | 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 | 1140 | 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 | |
| 1138 | 1150 | } | |
| 1139 | 1151 | ||
| 1140 | 1152 | 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 bool | 1046 | 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 cwd | 1053 | 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 · " + cwd | 1142 | + | 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() | ||