detached d8a0cba

add telegram bot bridge

commit
d8a0cba4fc071a242713fe4eac67d596c1fe0c52
author
patriceckhart · · 2026-04-18 07:15 UTC
parent
6cced27
signature
unsigned
12 files · +1517 -29
M README.md +45 -8
@@ -2,14 +2,13 @@
22
33 yet another coding agent harness, lightweight and written (vibe-slopped) in go.
44
5-one static binary.
6-two providers atm (anthropic, openai/codex).
7-four tools (read, write, edit, bash).
8-three run modes (interactive tui, print, json).
9-no extensions.
10-no plugins.
11-no ceremony.
12-no community.
5+- one static binary.
6+- two providers atm (anthropic, openai/codex).
7+- four tools (read, write, edit, bash).
8+- three run modes (interactive tui, print, json).
9+- built-in telegram bot.
10+- no extensions atm.
11+- no community atm.
1312
1413 ## install
1514
@@ -52,6 +51,14 @@ run `zot` and type `/login`. pick one of two methods:
5251
5352 > **note on subscription login**: the oauth client ids used are the ones published in anthropic's claude code cli and openai's codex cli. reusing them from a third-party tool is against their terms of service and may be revoked at any time. use it at your own risk; the api-key flow is the safe default.
5453
54+### token refresh
55+
56+oauth access tokens are short-lived (anthropic ~8h, openai ~30d). zot refreshes them automatically:
57+
58+- at every credential lookup, zot checks the stored `expiry` and — if past it (with a 60s safety margin) — hits the provider's `oauth/token` endpoint with the stored `refresh_token`, persists the new `access_token` + `refresh_token` + `expiry` back to `auth.json`, and hands the fresh token to the client.
59+- the telegram bridge additionally refreshes once per turn so a bot that runs for days keeps working without manual intervention.
60+- if the refresh itself fails (the `refresh_token` was revoked, or the account was logged out everywhere), the error bubbles up to the caller: the tui shows it in the status line, the bot replies with it in your dm. run `/login` to get a fresh token pair.
61+
5562 all data lives under `$ZOT_HOME`:
5663
5764 ```
@@ -200,6 +207,36 @@ frames containing images are full-repainted (no differential diff) to prevent st
200207 | `pgup` / `pgdn` | scroll one page up / down |
201208 | `up` / `down` (editor empty) | scroll three lines up / down — this is how the mouse wheel reaches the scroll logic on most terminals |
202209
210+## telegram bot (bridge)
211+
212+zot can run as a telegram bot so you can dm it from your phone. it's a built-in subcommand, not a plugin:
213+
214+```bash
215+zot telegram-bot setup # paste a BotFather token, verify, save
216+zot telegram-bot run # foreground: long-poll in this terminal (ctrl+c to stop)
217+zot telegram-bot start # background: detach and return immediately
218+zot telegram-bot stop # sigterm the background bot (sigkill after 5s)
219+zot telegram-bot logs -f # tail $ZOT_HOME/logs/bot.log (omit -f to just cat)
220+zot telegram-bot status # config (token masked) + running/stopped
221+zot telegram-bot reset # forget the token + paired user
222+# short alias: `zot tg ...` is accepted for every subcommand
223+```
224+
225+the background flavor writes the child's pid to `$ZOT_HOME/bot.pid` and redirects stdout+stderr to `$ZOT_HOME/logs/bot.log`. `zot telegram-bot stop` reads that pid, sends sigterm, waits up to five seconds, then escalates to sigkill if the child is still alive. running two instances at once is refused at startup.
226+
227+> **use the installed binary for `start`.** `go run ./cmd/zot telegram-bot start` won't work — `go run` builds a binary in a temp directory and deletes it when it exits, which kills the detached child. run `make install` (or `go build`) first and invoke the installed binary.
228+
229+setup flow:
230+
231+1. talk to [@BotFather](https://t.me/BotFather) on telegram, run `/newbot`, copy the token it gives you.
232+2. run `zot bot setup` and paste the token when prompted.
233+3. run `zot bot run` in the directory you want the agent to operate in.
234+4. open your bot on telegram, send `/start`. the first user to do this claims the bridge (stored as `allowed_user_id`); every other user is rejected.
235+
236+from then on, any dm you send is forwarded to the agent as a user prompt. attached photos or image/* documents are downloaded and passed to vision-capable models. in-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). config lives in `$ZOT_HOME/bot.json` (mode 0600).
237+
238+bot mode respects the usual zot flags — `--provider`, `--model`, `--cwd`, `--reasoning`, `--continue`, `--no-session`, `--no-tools`, etc. run `zot bot run -c --model claude-opus-4-1` to resume the latest session on opus, for example.
239+
203240 ## development
204241
205242 ```bash
@@ -2,14 +2,13 @@
2 2
3 yet another coding agent harness, lightweight and written (vibe-slopped) in go.3 yet another coding agent harness, lightweight and written (vibe-slopped) in go.
4 4
5-one static binary.5+- one static binary.
6-two providers atm (anthropic, openai/codex).6+- two providers atm (anthropic, openai/codex).
7-four tools (read, write, edit, bash).7+- four tools (read, write, edit, bash).
8-three run modes (interactive tui, print, json).8+- three run modes (interactive tui, print, json).
9-no extensions.9+- built-in telegram bot.
10-no plugins.10+- no extensions atm.
11-no ceremony.11+- no community atm.
12-no community.
13 12
14 ## install13 ## install
15 14
@@ -52,6 +51,14 @@ run `zot` and type `/login`. pick one of two methods:
52 51
53 > **note on subscription login**: the oauth client ids used are the ones published in anthropic's claude code cli and openai's codex cli. reusing them from a third-party tool is against their terms of service and may be revoked at any time. use it at your own risk; the api-key flow is the safe default.52 > **note on subscription login**: the oauth client ids used are the ones published in anthropic's claude code cli and openai's codex cli. reusing them from a third-party tool is against their terms of service and may be revoked at any time. use it at your own risk; the api-key flow is the safe default.
54 53
54+### token refresh
55+
56+oauth access tokens are short-lived (anthropic ~8h, openai ~30d). zot refreshes them automatically:
57+
58+- at every credential lookup, zot checks the stored `expiry` and — if past it (with a 60s safety margin) — hits the provider's `oauth/token` endpoint with the stored `refresh_token`, persists the new `access_token` + `refresh_token` + `expiry` back to `auth.json`, and hands the fresh token to the client.
59+- the telegram bridge additionally refreshes once per turn so a bot that runs for days keeps working without manual intervention.
60+- if the refresh itself fails (the `refresh_token` was revoked, or the account was logged out everywhere), the error bubbles up to the caller: the tui shows it in the status line, the bot replies with it in your dm. run `/login` to get a fresh token pair.
61+
55 all data lives under `$ZOT_HOME`:62 all data lives under `$ZOT_HOME`:
56 63
57 ```64 ```
@@ -200,6 +207,36 @@ frames containing images are full-repainted (no differential diff) to prevent st
200 | `pgup` / `pgdn` | scroll one page up / down |207 | `pgup` / `pgdn` | scroll one page up / down |
201 | `up` / `down` (editor empty) | scroll three lines up / down — this is how the mouse wheel reaches the scroll logic on most terminals |208 | `up` / `down` (editor empty) | scroll three lines up / down — this is how the mouse wheel reaches the scroll logic on most terminals |
202 209
210+## telegram bot (bridge)
211+
212+zot can run as a telegram bot so you can dm it from your phone. it's a built-in subcommand, not a plugin:
213+
214+```bash
215+zot telegram-bot setup # paste a BotFather token, verify, save
216+zot telegram-bot run # foreground: long-poll in this terminal (ctrl+c to stop)
217+zot telegram-bot start # background: detach and return immediately
218+zot telegram-bot stop # sigterm the background bot (sigkill after 5s)
219+zot telegram-bot logs -f # tail $ZOT_HOME/logs/bot.log (omit -f to just cat)
220+zot telegram-bot status # config (token masked) + running/stopped
221+zot telegram-bot reset # forget the token + paired user
222+# short alias: `zot tg ...` is accepted for every subcommand
223+```
224+
225+the background flavor writes the child's pid to `$ZOT_HOME/bot.pid` and redirects stdout+stderr to `$ZOT_HOME/logs/bot.log`. `zot telegram-bot stop` reads that pid, sends sigterm, waits up to five seconds, then escalates to sigkill if the child is still alive. running two instances at once is refused at startup.
226+
227+> **use the installed binary for `start`.** `go run ./cmd/zot telegram-bot start` won't work — `go run` builds a binary in a temp directory and deletes it when it exits, which kills the detached child. run `make install` (or `go build`) first and invoke the installed binary.
228+
229+setup flow:
230+
231+1. talk to [@BotFather](https://t.me/BotFather) on telegram, run `/newbot`, copy the token it gives you.
232+2. run `zot bot setup` and paste the token when prompted.
233+3. run `zot bot run` in the directory you want the agent to operate in.
234+4. open your bot on telegram, send `/start`. the first user to do this claims the bridge (stored as `allowed_user_id`); every other user is rejected.
235+
236+from then on, any dm you send is forwarded to the agent as a user prompt. attached photos or image/* documents are downloaded and passed to vision-capable models. in-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). config lives in `$ZOT_HOME/bot.json` (mode 0600).
237+
238+bot mode respects the usual zot flags — `--provider`, `--model`, `--cwd`, `--reasoning`, `--continue`, `--no-session`, `--no-tools`, etc. run `zot bot run -c --model claude-opus-4-1` to resume the latest session on opus, for example.
239+
203 ## development240 ## development
204 241
205 ```bash242 ```bash
M internal/agent/args.go +8 -0
@@ -188,6 +188,14 @@ usage:
188188 zot "prompt" interactive, pre-filled prompt
189189 zot -p "prompt" print final text, exit
190190 zot --json "prompt" newline-delimited json events, exit
191+ zot telegram-bot setup configure a telegram bot (from BotFather)
192+ zot telegram-bot run foreground bridge (ctrl+c to stop)
193+ zot telegram-bot start background bridge (detached)
194+ zot telegram-bot stop stop the background bridge
195+ zot telegram-bot logs [-f] tail the background bridge's log
196+ zot telegram-bot status config + running state
197+ zot telegram-bot reset forget saved token
198+ (short alias: zot tg ...)
191199
192200 flags:
193201 --provider anthropic|openai
@@ -188,6 +188,14 @@ usage:
188 zot "prompt" interactive, pre-filled prompt188 zot "prompt" interactive, pre-filled prompt
189 zot -p "prompt" print final text, exit189 zot -p "prompt" print final text, exit
190 zot --json "prompt" newline-delimited json events, exit190 zot --json "prompt" newline-delimited json events, exit
191+ zot telegram-bot setup configure a telegram bot (from BotFather)
192+ zot telegram-bot run foreground bridge (ctrl+c to stop)
193+ zot telegram-bot start background bridge (detached)
194+ zot telegram-bot stop stop the background bridge
195+ zot telegram-bot logs [-f] tail the background bridge's log
196+ zot telegram-bot status config + running state
197+ zot telegram-bot reset forget saved token
198+ (short alias: zot tg ...)
191 199
192 flags:200 flags:
193 --provider anthropic|openai201 --provider anthropic|openai
A internal/agent/botcmd.go +433 -0
@@ -0,0 +1,433 @@
1+package agent
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "os/signal"
12+ "path/filepath"
13+ "strconv"
14+ "strings"
15+ "syscall"
16+ "time"
17+
18+ "github.com/patriceckhart/zot/internal/agent/modes/telegram"
19+ "github.com/patriceckhart/zot/internal/core"
20+)
21+
22+// runBotCommand dispatches `zot telegram-bot ...` subcommands. The
23+// short alias "tg" is also accepted. Returns true if rawArgs begins
24+// with a recognised subcommand, false otherwise.
25+func runBotCommand(rawArgs []string, version string) (handled bool, err error) {
26+ if len(rawArgs) == 0 {
27+ return false, nil
28+ }
29+ switch rawArgs[0] {
30+ case "telegram-bot", "tg":
31+ // recognised
32+ default:
33+ return false, nil
34+ }
35+ sub := ""
36+ var tail []string
37+ if len(rawArgs) >= 2 {
38+ sub = rawArgs[1]
39+ tail = rawArgs[2:]
40+ }
41+ switch sub {
42+ case "", "help", "-h", "--help":
43+ printBotHelp()
44+ return true, nil
45+ case "setup":
46+ return true, botSetup(tail)
47+ case "status":
48+ return true, botStatus()
49+ case "reset":
50+ return true, botReset()
51+ case "run":
52+ return true, botRun(tail, version)
53+ case "start":
54+ return true, botStart(tail)
55+ case "stop":
56+ return true, botStop()
57+ case "logs":
58+ return true, botLogs(tail)
59+ default:
60+ printBotHelp()
61+ return true, fmt.Errorf("unknown bot subcommand %q", sub)
62+ }
63+}
64+
65+// printBotHelp prints usage for `zot bot`.
66+func printBotHelp() {
67+ fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge
68+
69+usage:
70+ zot telegram-bot setup paste a BotFather token, verify, save
71+ zot telegram-bot status show bridge config and whether it's running
72+ zot telegram-bot run [flags] run in the foreground (ctrl+c to stop)
73+ zot telegram-bot start [flags] launch in background, detach, return immediately
74+ zot telegram-bot stop sigterm the running background bot, sigkill if needed
75+ zot telegram-bot logs [--follow] tail the background bot's log file
76+ zot telegram-bot reset forget token + allowed user
77+
78+setup flow:
79+ 1. talk to @BotFather on telegram, /newbot, copy the token
80+ 2. run "zot telegram-bot setup" and paste the token
81+ 3. run "zot telegram-bot start" (background) or "zot telegram-bot run" (foreground)
82+ 4. send /start to your bot from telegram; the first sender claims it
83+
84+while the bot is running, dm it anything and the message is forwarded
85+to the agent the same way it would be from the tui. image attachments
86+(photos or image/* documents) are passed to vision-capable models.
87+telegram commands the bot handles directly: /help, /status, /stop.
88+
89+config & state:
90+ $ZOT_HOME/bot.json # bot token + paired user (mode 0600)
91+ $ZOT_HOME/bot.pid # pid of the running bot (written by run/start)
92+ $ZOT_HOME/logs/bot.log # stdout+stderr from "zot telegram-bot start"
93+`)
94+}
95+
96+// botSetup interactively reads a bot token, verifies it via getMe, and saves it.
97+func botSetup(_ []string) error {
98+ cfg, err := telegram.LoadConfig(ZotHome())
99+ if err != nil {
100+ return err
101+ }
102+
103+ fmt.Print("telegram bot token (from @BotFather): ")
104+ reader := bufio.NewReader(os.Stdin)
105+ line, err := reader.ReadString('\n')
106+ if err != nil {
107+ return err
108+ }
109+ token := strings.TrimSpace(line)
110+ if token == "" {
111+ return fmt.Errorf("no token provided")
112+ }
113+
114+ client := telegram.NewClient(token)
115+ me, err := client.GetMe(context.Background())
116+ if err != nil {
117+ return fmt.Errorf("token rejected by telegram: %w", err)
118+ }
119+ cfg.BotToken = token
120+ cfg.BotUsername = me.Username
121+ cfg.BotID = me.ID
122+ // Any stored pairing might be for a different bot; clear it.
123+ cfg.AllowedUserID = 0
124+ cfg.LastUpdateID = 0
125+ if err := telegram.SaveConfig(ZotHome(), cfg); err != nil {
126+ return err
127+ }
128+ fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZotHome()))
129+ fmt.Println("next: run `zot telegram-bot run`, then send /start to your bot from telegram.")
130+ return nil
131+}
132+
133+// botStatus prints the current bot config without the token, plus
134+// whether the background process is alive.
135+func botStatus() error {
136+ cfg, err := telegram.LoadConfig(ZotHome())
137+ if err != nil {
138+ return err
139+ }
140+ if cfg.BotToken == "" {
141+ fmt.Println("telegram: not configured (run `zot telegram-bot setup`)")
142+ return nil
143+ }
144+ maskedTok := maskToken(cfg.BotToken)
145+ fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID)
146+ fmt.Printf("token: %s\n", maskedTok)
147+ if cfg.AllowedUserID == 0 {
148+ fmt.Println("paired with: (unpaired — send /start from telegram to claim)")
149+ } else {
150+ fmt.Printf("paired with: telegram user id %d\n", cfg.AllowedUserID)
151+ }
152+ fmt.Printf("last update: %d\n", cfg.LastUpdateID)
153+ fmt.Printf("config file: %s\n", telegram.ConfigPath(ZotHome()))
154+
155+ pid, alive, _ := telegram.IsRunning(ZotHome())
156+ switch {
157+ case alive:
158+ fmt.Printf("process: running (pid %d)\n", pid)
159+ case pid > 0:
160+ fmt.Printf("process: stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZotHome()))
161+ default:
162+ fmt.Println("process: stopped")
163+ }
164+ logPath := telegram.LogPath(ZotHome())
165+ if fi, err := os.Stat(logPath); err == nil {
166+ fmt.Printf("log file: %s (%d bytes)\n", logPath, fi.Size())
167+ }
168+ return nil
169+}
170+
171+// botReset wipes the on-disk bot.json entry.
172+func botReset() error {
173+ p := telegram.ConfigPath(ZotHome())
174+ if _, err := os.Stat(p); os.IsNotExist(err) {
175+ fmt.Println("no bot config to reset")
176+ return nil
177+ }
178+ if err := os.Remove(p); err != nil {
179+ return err
180+ }
181+ fmt.Println("removed", p)
182+ return nil
183+}
184+
185+// botStart launches `zot telegram-bot run` as a detached child process, writes
186+// its pid to $ZOT_HOME/bot.pid, and returns immediately. Stdout/stderr
187+// of the child are redirected to $ZOT_HOME/logs/bot.log.
188+func botStart(rawTail []string) error {
189+ // Refuse to start if another bot is already running.
190+ if pid, alive, _ := telegram.IsRunning(ZotHome()); alive {
191+ return fmt.Errorf("bot is already running (pid %d); use `zot telegram-bot stop` first", pid)
192+ }
193+ _ = telegram.RemovePID(ZotHome()) // clear any stale pid file
194+
195+ cfg, err := telegram.LoadConfig(ZotHome())
196+ if err != nil {
197+ return err
198+ }
199+ if cfg.BotToken == "" {
200+ return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first")
201+ }
202+
203+ self, err := os.Executable()
204+ if err != nil {
205+ return fmt.Errorf("locate zot binary: %w", err)
206+ }
207+
208+ logPath := telegram.LogPath(ZotHome())
209+ if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
210+ return err
211+ }
212+ logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
213+ if err != nil {
214+ return fmt.Errorf("open log file: %w", err)
215+ }
216+ defer logFile.Close()
217+
218+ // Refuse to start from a `go run` temp binary: Go deletes the
219+ // binary when `go run` exits, which breaks the detached child.
220+ // Users hit cryptic tls / exec errors on that path; fail clearly.
221+ if strings.Contains(self, string(os.PathSeparator)+"go-build") ||
222+ strings.Contains(self, string(os.PathSeparator)+"go-tmp") {
223+ return fmt.Errorf("detected `go run` temp binary at %s — run `make install` (or copy ./bin/zot to your PATH) and use the installed binary for `start`", self)
224+ }
225+
226+ // Child argv: same flags the user passed to `zot telegram-bot start`,
227+ // mapped to `zot telegram-bot run`. Preserves --provider, --model, --cwd, etc.
228+ args := append([]string{"telegram-bot", "run"}, rawTail...)
229+ cmd := exec.Command(self, args...)
230+ cmd.Stdout = logFile
231+ cmd.Stderr = logFile
232+ cmd.Stdin = nil
233+ // Detach: new session so terminal signals don't reach the child.
234+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
235+
236+ if err := cmd.Start(); err != nil {
237+ return fmt.Errorf("spawn: %w", err)
238+ }
239+ if err := telegram.WritePID(ZotHome(), cmd.Process.Pid); err != nil {
240+ _ = cmd.Process.Kill()
241+ return fmt.Errorf("write pid: %w", err)
242+ }
243+ // Don't wait() — detach. OS will reparent the child to init when we exit.
244+ go func() { _ = cmd.Process.Release() }()
245+
246+ fmt.Printf("started zot telegram-bot as pid %d (logs: %s)\n", cmd.Process.Pid, logPath)
247+ fmt.Println("use `zot telegram-bot logs -f` to tail, `zot telegram-bot stop` to stop.")
248+ return nil
249+}
250+
251+// botStop sends SIGTERM to the running bot (SIGKILL if it doesn't
252+// exit within 5s) and cleans up the pid file.
253+func botStop() error {
254+ pid, alive, err := telegram.IsRunning(ZotHome())
255+ if err != nil {
256+ return err
257+ }
258+ if !alive {
259+ if pid > 0 {
260+ _ = telegram.RemovePID(ZotHome())
261+ fmt.Printf("no live process; cleared stale pid %d\n", pid)
262+ return nil
263+ }
264+ fmt.Println("bot is not running")
265+ return nil
266+ }
267+ if err := telegram.StopProcess(pid, 5*time.Second); err != nil {
268+ return fmt.Errorf("stop pid %d: %w", pid, err)
269+ }
270+ _ = telegram.RemovePID(ZotHome())
271+ fmt.Printf("stopped pid %d\n", pid)
272+ return nil
273+}
274+
275+// botLogs prints (or tails with --follow) the bot log file.
276+func botLogs(rawTail []string) error {
277+ follow := false
278+ for _, a := range rawTail {
279+ if a == "-f" || a == "--follow" {
280+ follow = true
281+ }
282+ }
283+ p := telegram.LogPath(ZotHome())
284+ f, err := os.Open(p)
285+ if errors.Is(err, os.ErrNotExist) {
286+ fmt.Println("no log file at", p)
287+ return nil
288+ }
289+ if err != nil {
290+ return err
291+ }
292+ defer f.Close()
293+
294+ if _, err := io.Copy(os.Stdout, f); err != nil {
295+ return err
296+ }
297+ if !follow {
298+ return nil
299+ }
300+
301+ // Naive tail -f: poll for new bytes until ctrl+c.
302+ sigc := make(chan os.Signal, 1)
303+ signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
304+ defer signal.Stop(sigc)
305+ for {
306+ select {
307+ case <-sigc:
308+ return nil
309+ case <-time.After(500 * time.Millisecond):
310+ if _, err := io.Copy(os.Stdout, f); err != nil {
311+ return err
312+ }
313+ }
314+ }
315+}
316+
317+// botRun starts the polling loop in the foreground. Ctrl+C stops it.
318+func botRun(rawTail []string, version string) error {
319+ // Parse only a small subset of flags relevant to bot run. We reuse
320+ // the main args parser so --provider/--model/--cwd/--api-key/--reasoning
321+ // behave the same as in the tui.
322+ args, err := ParseArgs(rawTail)
323+ if err != nil {
324+ return err
325+ }
326+
327+ // Bot mode always requires credentials (can't pop a /login dialog).
328+ resolved, err := Resolve(args, true)
329+ if err != nil {
330+ return err
331+ }
332+
333+ cfg, err := telegram.LoadConfig(ZotHome())
334+ if err != nil {
335+ return err
336+ }
337+ if cfg.BotToken == "" {
338+ return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first")
339+ }
340+
341+ agent := resolved.NewAgent()
342+
343+ // Session: optional, same model as the tui. Persist so DMs build on
344+ // prior context. --no-session disables.
345+ var sess *core.Session
346+ if !args.NoSess {
347+ s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version)
348+ if serr == nil {
349+ sess = s
350+ defer sess.Close()
351+ } else {
352+ fmt.Fprintln(os.Stderr, "session:", serr)
353+ }
354+ }
355+
356+ b := &telegram.Bot{
357+ Client: telegram.NewClient(cfg.BotToken),
358+ Agent: agent,
359+ Config: cfg,
360+ ZotHome: ZotHome(),
361+ Save: func(c telegram.Config) error {
362+ return telegram.SaveConfig(ZotHome(), c)
363+ },
364+ RefreshCreds: func() error {
365+ // Re-run the same resolver the tui uses so we pick up
366+ // refreshed oauth tokens, re-logins, and model switches.
367+ // Only the provider client is swapped — tools, sandbox,
368+ // system prompt, and transcript stay with the existing agent.
369+ next, err := Resolve(args, true)
370+ if err != nil {
371+ return err
372+ }
373+ agent.Client = next.NewClient()
374+ agent.Model = next.Model
375+ return nil
376+ },
377+ }
378+
379+ // Record our pid so `zot telegram-bot status` / `zot telegram-bot stop` can find us,
380+ // regardless of whether we were started directly or via `bot start`.
381+ _ = telegram.WritePID(ZotHome(), os.Getpid())
382+ defer telegram.RemovePID(ZotHome())
383+
384+ // Translate sigterm/sigint into a context cancel so the bot's goroutines
385+ // and the currently-running turn wind down cleanly.
386+ ctx, cancel := context.WithCancel(context.Background())
387+ sigc := make(chan os.Signal, 1)
388+ signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
389+ go func() {
390+ <-sigc
391+ cancel()
392+ }()
393+ defer cancel()
394+ return b.Run(ctx)
395+}
396+
397+// openOrCreateSessionForBot reuses the same logic as interactive mode
398+// but never prompts (no TTY picker); falls back to latest or new.
399+func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, []any, error) {
400+ if args.Continue {
401+ if latest := core.LatestSession(ZotHome(), args.CWD); latest != "" {
402+ s, msgs, err := core.OpenSession(latest)
403+ if err != nil {
404+ return nil, nil, err
405+ }
406+ ag.SetMessages(msgs)
407+ return s, nil, nil
408+ }
409+ }
410+ s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
411+ return s, nil, err
412+}
413+
414+// maskToken returns "123456:ABC…xyz" so copies of zot telegram-bot status can be
415+// pasted into bug reports without leaking the full token.
416+func maskToken(tok string) string {
417+ if len(tok) <= 10 {
418+ return "<hidden>"
419+ }
420+ // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body.
421+ i := strings.IndexByte(tok, ':')
422+ if i < 0 {
423+ return tok[:4] + "…" + tok[len(tok)-4:]
424+ }
425+ body := tok[i+1:]
426+ if len(body) < 8 {
427+ return tok[:i+1] + "<hidden>"
428+ }
429+ return tok[:i+1] + body[:3] + "…" + body[len(body)-3:]
430+}
431+
432+// _ compile-time hint so the strconv import stays if we later add numeric parsing.
433+var _ = strconv.Itoa
@@ -0,0 +1,433 @@
1+package agent
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "os/signal"
12+ "path/filepath"
13+ "strconv"
14+ "strings"
15+ "syscall"
16+ "time"
17+
18+ "github.com/patriceckhart/zot/internal/agent/modes/telegram"
19+ "github.com/patriceckhart/zot/internal/core"
20+)
21+
22+// runBotCommand dispatches `zot telegram-bot ...` subcommands. The
23+// short alias "tg" is also accepted. Returns true if rawArgs begins
24+// with a recognised subcommand, false otherwise.
25+func runBotCommand(rawArgs []string, version string) (handled bool, err error) {
26+ if len(rawArgs) == 0 {
27+ return false, nil
28+ }
29+ switch rawArgs[0] {
30+ case "telegram-bot", "tg":
31+ // recognised
32+ default:
33+ return false, nil
34+ }
35+ sub := ""
36+ var tail []string
37+ if len(rawArgs) >= 2 {
38+ sub = rawArgs[1]
39+ tail = rawArgs[2:]
40+ }
41+ switch sub {
42+ case "", "help", "-h", "--help":
43+ printBotHelp()
44+ return true, nil
45+ case "setup":
46+ return true, botSetup(tail)
47+ case "status":
48+ return true, botStatus()
49+ case "reset":
50+ return true, botReset()
51+ case "run":
52+ return true, botRun(tail, version)
53+ case "start":
54+ return true, botStart(tail)
55+ case "stop":
56+ return true, botStop()
57+ case "logs":
58+ return true, botLogs(tail)
59+ default:
60+ printBotHelp()
61+ return true, fmt.Errorf("unknown bot subcommand %q", sub)
62+ }
63+}
64+
65+// printBotHelp prints usage for `zot bot`.
66+func printBotHelp() {
67+ fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge
68+
69+usage:
70+ zot telegram-bot setup paste a BotFather token, verify, save
71+ zot telegram-bot status show bridge config and whether it's running
72+ zot telegram-bot run [flags] run in the foreground (ctrl+c to stop)
73+ zot telegram-bot start [flags] launch in background, detach, return immediately
74+ zot telegram-bot stop sigterm the running background bot, sigkill if needed
75+ zot telegram-bot logs [--follow] tail the background bot's log file
76+ zot telegram-bot reset forget token + allowed user
77+
78+setup flow:
79+ 1. talk to @BotFather on telegram, /newbot, copy the token
80+ 2. run "zot telegram-bot setup" and paste the token
81+ 3. run "zot telegram-bot start" (background) or "zot telegram-bot run" (foreground)
82+ 4. send /start to your bot from telegram; the first sender claims it
83+
84+while the bot is running, dm it anything and the message is forwarded
85+to the agent the same way it would be from the tui. image attachments
86+(photos or image/* documents) are passed to vision-capable models.
87+telegram commands the bot handles directly: /help, /status, /stop.
88+
89+config & state:
90+ $ZOT_HOME/bot.json # bot token + paired user (mode 0600)
91+ $ZOT_HOME/bot.pid # pid of the running bot (written by run/start)
92+ $ZOT_HOME/logs/bot.log # stdout+stderr from "zot telegram-bot start"
93+`)
94+}
95+
96+// botSetup interactively reads a bot token, verifies it via getMe, and saves it.
97+func botSetup(_ []string) error {
98+ cfg, err := telegram.LoadConfig(ZotHome())
99+ if err != nil {
100+ return err
101+ }
102+
103+ fmt.Print("telegram bot token (from @BotFather): ")
104+ reader := bufio.NewReader(os.Stdin)
105+ line, err := reader.ReadString('\n')
106+ if err != nil {
107+ return err
108+ }
109+ token := strings.TrimSpace(line)
110+ if token == "" {
111+ return fmt.Errorf("no token provided")
112+ }
113+
114+ client := telegram.NewClient(token)
115+ me, err := client.GetMe(context.Background())
116+ if err != nil {
117+ return fmt.Errorf("token rejected by telegram: %w", err)
118+ }
119+ cfg.BotToken = token
120+ cfg.BotUsername = me.Username
121+ cfg.BotID = me.ID
122+ // Any stored pairing might be for a different bot; clear it.
123+ cfg.AllowedUserID = 0
124+ cfg.LastUpdateID = 0
125+ if err := telegram.SaveConfig(ZotHome(), cfg); err != nil {
126+ return err
127+ }
128+ fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZotHome()))
129+ fmt.Println("next: run `zot telegram-bot run`, then send /start to your bot from telegram.")
130+ return nil
131+}
132+
133+// botStatus prints the current bot config without the token, plus
134+// whether the background process is alive.
135+func botStatus() error {
136+ cfg, err := telegram.LoadConfig(ZotHome())
137+ if err != nil {
138+ return err
139+ }
140+ if cfg.BotToken == "" {
141+ fmt.Println("telegram: not configured (run `zot telegram-bot setup`)")
142+ return nil
143+ }
144+ maskedTok := maskToken(cfg.BotToken)
145+ fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID)
146+ fmt.Printf("token: %s\n", maskedTok)
147+ if cfg.AllowedUserID == 0 {
148+ fmt.Println("paired with: (unpaired — send /start from telegram to claim)")
149+ } else {
150+ fmt.Printf("paired with: telegram user id %d\n", cfg.AllowedUserID)
151+ }
152+ fmt.Printf("last update: %d\n", cfg.LastUpdateID)
153+ fmt.Printf("config file: %s\n", telegram.ConfigPath(ZotHome()))
154+
155+ pid, alive, _ := telegram.IsRunning(ZotHome())
156+ switch {
157+ case alive:
158+ fmt.Printf("process: running (pid %d)\n", pid)
159+ case pid > 0:
160+ fmt.Printf("process: stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZotHome()))
161+ default:
162+ fmt.Println("process: stopped")
163+ }
164+ logPath := telegram.LogPath(ZotHome())
165+ if fi, err := os.Stat(logPath); err == nil {
166+ fmt.Printf("log file: %s (%d bytes)\n", logPath, fi.Size())
167+ }
168+ return nil
169+}
170+
171+// botReset wipes the on-disk bot.json entry.
172+func botReset() error {
173+ p := telegram.ConfigPath(ZotHome())
174+ if _, err := os.Stat(p); os.IsNotExist(err) {
175+ fmt.Println("no bot config to reset")
176+ return nil
177+ }
178+ if err := os.Remove(p); err != nil {
179+ return err
180+ }
181+ fmt.Println("removed", p)
182+ return nil
183+}
184+
185+// botStart launches `zot telegram-bot run` as a detached child process, writes
186+// its pid to $ZOT_HOME/bot.pid, and returns immediately. Stdout/stderr
187+// of the child are redirected to $ZOT_HOME/logs/bot.log.
188+func botStart(rawTail []string) error {
189+ // Refuse to start if another bot is already running.
190+ if pid, alive, _ := telegram.IsRunning(ZotHome()); alive {
191+ return fmt.Errorf("bot is already running (pid %d); use `zot telegram-bot stop` first", pid)
192+ }
193+ _ = telegram.RemovePID(ZotHome()) // clear any stale pid file
194+
195+ cfg, err := telegram.LoadConfig(ZotHome())
196+ if err != nil {
197+ return err
198+ }
199+ if cfg.BotToken == "" {
200+ return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first")
201+ }
202+
203+ self, err := os.Executable()
204+ if err != nil {
205+ return fmt.Errorf("locate zot binary: %w", err)
206+ }
207+
208+ logPath := telegram.LogPath(ZotHome())
209+ if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
210+ return err
211+ }
212+ logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
213+ if err != nil {
214+ return fmt.Errorf("open log file: %w", err)
215+ }
216+ defer logFile.Close()
217+
218+ // Refuse to start from a `go run` temp binary: Go deletes the
219+ // binary when `go run` exits, which breaks the detached child.
220+ // Users hit cryptic tls / exec errors on that path; fail clearly.
221+ if strings.Contains(self, string(os.PathSeparator)+"go-build") ||
222+ strings.Contains(self, string(os.PathSeparator)+"go-tmp") {
223+ return fmt.Errorf("detected `go run` temp binary at %s — run `make install` (or copy ./bin/zot to your PATH) and use the installed binary for `start`", self)
224+ }
225+
226+ // Child argv: same flags the user passed to `zot telegram-bot start`,
227+ // mapped to `zot telegram-bot run`. Preserves --provider, --model, --cwd, etc.
228+ args := append([]string{"telegram-bot", "run"}, rawTail...)
229+ cmd := exec.Command(self, args...)
230+ cmd.Stdout = logFile
231+ cmd.Stderr = logFile
232+ cmd.Stdin = nil
233+ // Detach: new session so terminal signals don't reach the child.
234+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
235+
236+ if err := cmd.Start(); err != nil {
237+ return fmt.Errorf("spawn: %w", err)
238+ }
239+ if err := telegram.WritePID(ZotHome(), cmd.Process.Pid); err != nil {
240+ _ = cmd.Process.Kill()
241+ return fmt.Errorf("write pid: %w", err)
242+ }
243+ // Don't wait() — detach. OS will reparent the child to init when we exit.
244+ go func() { _ = cmd.Process.Release() }()
245+
246+ fmt.Printf("started zot telegram-bot as pid %d (logs: %s)\n", cmd.Process.Pid, logPath)
247+ fmt.Println("use `zot telegram-bot logs -f` to tail, `zot telegram-bot stop` to stop.")
248+ return nil
249+}
250+
251+// botStop sends SIGTERM to the running bot (SIGKILL if it doesn't
252+// exit within 5s) and cleans up the pid file.
253+func botStop() error {
254+ pid, alive, err := telegram.IsRunning(ZotHome())
255+ if err != nil {
256+ return err
257+ }
258+ if !alive {
259+ if pid > 0 {
260+ _ = telegram.RemovePID(ZotHome())
261+ fmt.Printf("no live process; cleared stale pid %d\n", pid)
262+ return nil
263+ }
264+ fmt.Println("bot is not running")
265+ return nil
266+ }
267+ if err := telegram.StopProcess(pid, 5*time.Second); err != nil {
268+ return fmt.Errorf("stop pid %d: %w", pid, err)
269+ }
270+ _ = telegram.RemovePID(ZotHome())
271+ fmt.Printf("stopped pid %d\n", pid)
272+ return nil
273+}
274+
275+// botLogs prints (or tails with --follow) the bot log file.
276+func botLogs(rawTail []string) error {
277+ follow := false
278+ for _, a := range rawTail {
279+ if a == "-f" || a == "--follow" {
280+ follow = true
281+ }
282+ }
283+ p := telegram.LogPath(ZotHome())
284+ f, err := os.Open(p)
285+ if errors.Is(err, os.ErrNotExist) {
286+ fmt.Println("no log file at", p)
287+ return nil
288+ }
289+ if err != nil {
290+ return err
291+ }
292+ defer f.Close()
293+
294+ if _, err := io.Copy(os.Stdout, f); err != nil {
295+ return err
296+ }
297+ if !follow {
298+ return nil
299+ }
300+
301+ // Naive tail -f: poll for new bytes until ctrl+c.
302+ sigc := make(chan os.Signal, 1)
303+ signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
304+ defer signal.Stop(sigc)
305+ for {
306+ select {
307+ case <-sigc:
308+ return nil
309+ case <-time.After(500 * time.Millisecond):
310+ if _, err := io.Copy(os.Stdout, f); err != nil {
311+ return err
312+ }
313+ }
314+ }
315+}
316+
317+// botRun starts the polling loop in the foreground. Ctrl+C stops it.
318+func botRun(rawTail []string, version string) error {
319+ // Parse only a small subset of flags relevant to bot run. We reuse
320+ // the main args parser so --provider/--model/--cwd/--api-key/--reasoning
321+ // behave the same as in the tui.
322+ args, err := ParseArgs(rawTail)
323+ if err != nil {
324+ return err
325+ }
326+
327+ // Bot mode always requires credentials (can't pop a /login dialog).
328+ resolved, err := Resolve(args, true)
329+ if err != nil {
330+ return err
331+ }
332+
333+ cfg, err := telegram.LoadConfig(ZotHome())
334+ if err != nil {
335+ return err
336+ }
337+ if cfg.BotToken == "" {
338+ return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first")
339+ }
340+
341+ agent := resolved.NewAgent()
342+
343+ // Session: optional, same model as the tui. Persist so DMs build on
344+ // prior context. --no-session disables.
345+ var sess *core.Session
346+ if !args.NoSess {
347+ s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version)
348+ if serr == nil {
349+ sess = s
350+ defer sess.Close()
351+ } else {
352+ fmt.Fprintln(os.Stderr, "session:", serr)
353+ }
354+ }
355+
356+ b := &telegram.Bot{
357+ Client: telegram.NewClient(cfg.BotToken),
358+ Agent: agent,
359+ Config: cfg,
360+ ZotHome: ZotHome(),
361+ Save: func(c telegram.Config) error {
362+ return telegram.SaveConfig(ZotHome(), c)
363+ },
364+ RefreshCreds: func() error {
365+ // Re-run the same resolver the tui uses so we pick up
366+ // refreshed oauth tokens, re-logins, and model switches.
367+ // Only the provider client is swapped — tools, sandbox,
368+ // system prompt, and transcript stay with the existing agent.
369+ next, err := Resolve(args, true)
370+ if err != nil {
371+ return err
372+ }
373+ agent.Client = next.NewClient()
374+ agent.Model = next.Model
375+ return nil
376+ },
377+ }
378+
379+ // Record our pid so `zot telegram-bot status` / `zot telegram-bot stop` can find us,
380+ // regardless of whether we were started directly or via `bot start`.
381+ _ = telegram.WritePID(ZotHome(), os.Getpid())
382+ defer telegram.RemovePID(ZotHome())
383+
384+ // Translate sigterm/sigint into a context cancel so the bot's goroutines
385+ // and the currently-running turn wind down cleanly.
386+ ctx, cancel := context.WithCancel(context.Background())
387+ sigc := make(chan os.Signal, 1)
388+ signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
389+ go func() {
390+ <-sigc
391+ cancel()
392+ }()
393+ defer cancel()
394+ return b.Run(ctx)
395+}
396+
397+// openOrCreateSessionForBot reuses the same logic as interactive mode
398+// but never prompts (no TTY picker); falls back to latest or new.
399+func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, []any, error) {
400+ if args.Continue {
401+ if latest := core.LatestSession(ZotHome(), args.CWD); latest != "" {
402+ s, msgs, err := core.OpenSession(latest)
403+ if err != nil {
404+ return nil, nil, err
405+ }
406+ ag.SetMessages(msgs)
407+ return s, nil, nil
408+ }
409+ }
410+ s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
411+ return s, nil, err
412+}
413+
414+// maskToken returns "123456:ABC…xyz" so copies of zot telegram-bot status can be
415+// pasted into bug reports without leaking the full token.
416+func maskToken(tok string) string {
417+ if len(tok) <= 10 {
418+ return "<hidden>"
419+ }
420+ // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body.
421+ i := strings.IndexByte(tok, ':')
422+ if i < 0 {
423+ return tok[:4] + "…" + tok[len(tok)-4:]
424+ }
425+ body := tok[i+1:]
426+ if len(body) < 8 {
427+ return tok[:i+1] + "<hidden>"
428+ }
429+ return tok[:i+1] + body[:3] + "…" + body[len(body)-3:]
430+}
431+
432+// _ compile-time hint so the strconv import stays if we later add numeric parsing.
433+var _ = strconv.Itoa
M internal/agent/cli.go +6 -0
@@ -17,6 +17,12 @@ import (
1717
1818 // Run is the top-level entrypoint for the zot binary.
1919 func Run(rawArgs []string, version string) error {
20+ // Subcommand router: `zot bot ...` is handled separately so the
21+ // generic flag parser doesn't reject "bot" as a positional arg.
22+ if handled, err := runBotCommand(rawArgs, version); handled {
23+ return err
24+ }
25+
2026 args, err := ParseArgs(rawArgs)
2127 if err != nil {
2228 PrintHelp(version)
@@ -17,6 +17,12 @@ import (
17 17
18 // Run is the top-level entrypoint for the zot binary.18 // Run is the top-level entrypoint for the zot binary.
19 func Run(rawArgs []string, version string) error {19 func Run(rawArgs []string, version string) error {
20+ // Subcommand router: `zot bot ...` is handled separately so the
21+ // generic flag parser doesn't reject "bot" as a positional arg.
22+ if handled, err := runBotCommand(rawArgs, version); handled {
23+ return err
24+ }
25+
20 args, err := ParseArgs(rawArgs)26 args, err := ParseArgs(rawArgs)
21 if err != nil {27 if err != nil {
22 PrintHelp(version)28 PrintHelp(version)
M internal/agent/config.go +57 -2
@@ -2,12 +2,14 @@
22 package agent
33
44 import (
5+ "context"
56 "encoding/json"
67 "errors"
78 "fmt"
89 "os"
910 "path/filepath"
1011 "runtime"
12+ "time"
1113
1214 "github.com/patriceckhart/zot/internal/auth"
1315 )
@@ -131,15 +133,68 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
131133 return c.Anthropic.APIKey, "apikey", "", nil
132134 }
133135 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
134- return c.Anthropic.OAuth.AccessToken, "oauth", "", nil
136+ tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)
137+ return tok.AccessToken, "oauth", "", nil
135138 }
136139 case "openai":
137140 if c.OpenAI.APIKey != "" {
138141 return c.OpenAI.APIKey, "apikey", "", nil
139142 }
140143 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
141- return c.OpenAI.OAuth.AccessToken, "oauth", c.OpenAI.OAuth.AccountID, nil
144+ tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
145+ return tok.AccessToken, "oauth", tok.AccountID, nil
142146 }
143147 }
144148 return "", "", "", fmt.Errorf("no credential for %s", provider)
145149 }
150+
151+// refreshIfExpired returns a usable OAuth token for the given provider,
152+// refreshing it synchronously when it's past (or near) expiry. The
153+// refreshed token is persisted to auth.json.
154+//
155+// Failures return the original token unchanged — the caller then makes
156+// a request with the stale access_token, which will 401. That's still
157+// better than crashing at credential-resolution time.
158+func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
159+ if tok == nil {
160+ return &auth.OAuthToken{}, fmt.Errorf("nil token")
161+ }
162+ if !tok.Expired() {
163+ return tok, nil
164+ }
165+ if tok.RefreshToken == "" {
166+ return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName)
167+ }
168+
169+ var op auth.OAuthProvider
170+ switch providerName {
171+ case "anthropic":
172+ op = auth.AnthropicOAuth
173+ case "openai":
174+ op = auth.OpenAIOAuth
175+ default:
176+ return tok, fmt.Errorf("unknown provider %q", providerName)
177+ }
178+
179+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
180+ defer cancel()
181+ next, err := op.Refresh(ctx, tok.RefreshToken)
182+ if err != nil {
183+ return tok, fmt.Errorf("refresh %s: %w", providerName, err)
184+ }
185+ // Preserve the refresh token if the server omitted it (Anthropic often does).
186+ if next.RefreshToken == "" {
187+ next.RefreshToken = tok.RefreshToken
188+ }
189+ // Carry over account id (openai) / id_token across refreshes.
190+ if next.AccountID == "" {
191+ next.AccountID = tok.AccountID
192+ }
193+ if next.IDToken == "" {
194+ next.IDToken = tok.IDToken
195+ }
196+ if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
197+ return next, fmt.Errorf("persist refreshed token: %w", err)
198+ }
199+ return next, nil
200+}
@@ -2,12 +2,14 @@
2 package agent2 package agent
3 3
4 import (4 import (
5+ "context"
5 "encoding/json"6 "encoding/json"
6 "errors"7 "errors"
7 "fmt"8 "fmt"
8 "os"9 "os"
9 "path/filepath"10 "path/filepath"
10 "runtime"11 "runtime"
12+ "time"
11 13
12 "github.com/patriceckhart/zot/internal/auth"14 "github.com/patriceckhart/zot/internal/auth"
13 )15 )
@@ -131,15 +133,68 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
131 return c.Anthropic.APIKey, "apikey", "", nil133 return c.Anthropic.APIKey, "apikey", "", nil
132 }134 }
133 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {135 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
134- return c.Anthropic.OAuth.AccessToken, "oauth", "", nil136+ tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)
137+ return tok.AccessToken, "oauth", "", nil
135 }138 }
136 case "openai":139 case "openai":
137 if c.OpenAI.APIKey != "" {140 if c.OpenAI.APIKey != "" {
138 return c.OpenAI.APIKey, "apikey", "", nil141 return c.OpenAI.APIKey, "apikey", "", nil
139 }142 }
140 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {143 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
141- return c.OpenAI.OAuth.AccessToken, "oauth", c.OpenAI.OAuth.AccountID, nil144+ tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
145+ return tok.AccessToken, "oauth", tok.AccountID, nil
142 }146 }
143 }147 }
144 return "", "", "", fmt.Errorf("no credential for %s", provider)148 return "", "", "", fmt.Errorf("no credential for %s", provider)
145 }149 }
150+
151+// refreshIfExpired returns a usable OAuth token for the given provider,
152+// refreshing it synchronously when it's past (or near) expiry. The
153+// refreshed token is persisted to auth.json.
154+//
155+// Failures return the original token unchanged — the caller then makes
156+// a request with the stale access_token, which will 401. That's still
157+// better than crashing at credential-resolution time.
158+func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
159+ if tok == nil {
160+ return &auth.OAuthToken{}, fmt.Errorf("nil token")
161+ }
162+ if !tok.Expired() {
163+ return tok, nil
164+ }
165+ if tok.RefreshToken == "" {
166+ return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName)
167+ }
168+
169+ var op auth.OAuthProvider
170+ switch providerName {
171+ case "anthropic":
172+ op = auth.AnthropicOAuth
173+ case "openai":
174+ op = auth.OpenAIOAuth
175+ default:
176+ return tok, fmt.Errorf("unknown provider %q", providerName)
177+ }
178+
179+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
180+ defer cancel()
181+ next, err := op.Refresh(ctx, tok.RefreshToken)
182+ if err != nil {
183+ return tok, fmt.Errorf("refresh %s: %w", providerName, err)
184+ }
185+ // Preserve the refresh token if the server omitted it (Anthropic often does).
186+ if next.RefreshToken == "" {
187+ next.RefreshToken = tok.RefreshToken
188+ }
189+ // Carry over account id (openai) / id_token across refreshes.
190+ if next.AccountID == "" {
191+ next.AccountID = tok.AccountID
192+ }
193+ if next.IDToken == "" {
194+ next.IDToken = tok.IDToken
195+ }
196+ if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
197+ return next, fmt.Errorf("persist refreshed token: %w", err)
198+ }
199+ return next, nil
200+}
M internal/agent/modes/model_dialog.go +105 -19
@@ -2,6 +2,8 @@ package modes
22
33 import (
44 "fmt"
5+ "sort"
6+ "strings"
57
68 "github.com/patriceckhart/zot/internal/provider"
79 "github.com/patriceckhart/zot/internal/tui"
@@ -9,13 +11,17 @@ import (
911
1012 // modelDialog is an inline picker for choosing the active model.
1113 // It lists all models known to the provider package (baked-in catalog
12-// + any live entries discovered via /v1/models) and lets the user pick
13-// one with arrow keys + enter.
14+// + any live entries discovered via /v1/models) sorted by provider
15+// then model id, and lets the user pick one with arrow keys + enter.
16+// Typing characters narrows the list via a fuzzy substring match that
17+// ignores punctuation (e.g. "opus46" matches "claude-opus-4-6").
1418 type modelDialog struct {
1519 active bool
16- models []provider.Model
20+ all []provider.Model // full catalog, sorted
21+ view []provider.Model // filtered view shown to the user
1722 cursor int
1823 current string // currently selected model id (highlighted)
24+ query string // live filter text typed by the user
1925 }
2026
2127 // modelDialogAction is returned by HandleKey.
@@ -34,22 +40,70 @@ func newModelDialog() *modelDialog {
3440 // it can be pre-selected.
3541 func (d *modelDialog) Open(current string) {
3642 d.active = true
37- d.models = provider.Active()
43+ d.all = sortedModels(provider.Active())
3844 d.current = current
45+ d.query = ""
46+ d.refilter()
47+}
48+
49+// Close hides the dialog.
50+func (d *modelDialog) Close() { d.active = false }
51+
52+// Active reports whether the dialog is visible and consumes input.
53+func (d *modelDialog) Active() bool { return d != nil && d.active }
54+
55+// refilter rebuilds view from all according to query, and snaps the
56+// cursor to either the current model (if visible) or the first row.
57+func (d *modelDialog) refilter() {
58+ needle := normalizeModelQuery(d.query)
59+ if needle == "" {
60+ d.view = append([]provider.Model(nil), d.all...)
61+ } else {
62+ out := make([]provider.Model, 0, len(d.all))
63+ for _, m := range d.all {
64+ if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
65+ out = append(out, m)
66+ }
67+ }
68+ d.view = out
69+ }
3970 d.cursor = 0
40- for i, m := range d.models {
41- if m.ID == current {
71+ for i, m := range d.view {
72+ if m.ID == d.current {
4273 d.cursor = i
4374 break
4475 }
4576 }
4677 }
4778
48-// Close hides the dialog.
49-func (d *modelDialog) Close() { d.active = false }
79+// sortedModels returns a fresh slice sorted by provider, then model id.
80+func sortedModels(in []provider.Model) []provider.Model {
81+ out := append([]provider.Model(nil), in...)
82+ sort.SliceStable(out, func(i, j int) bool {
83+ if out[i].Provider != out[j].Provider {
84+ return out[i].Provider < out[j].Provider
85+ }
86+ return out[i].ID < out[j].ID
87+ })
88+ return out
89+}
5090
51-// Active reports whether the dialog is visible and consumes input.
52-func (d *modelDialog) Active() bool { return d != nil && d.active }
91+// normalizeModelQuery lowercases and strips punctuation so fuzzy
92+// substring matching works on both the query and haystacks. "opus46"
93+// and "opus-4-6" both become "opus46".
94+func normalizeModelQuery(s string) string {
95+ var sb strings.Builder
96+ sb.Grow(len(s))
97+ for _, r := range s {
98+ switch {
99+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
100+ sb.WriteRune(r)
101+ case r >= 'A' && r <= 'Z':
102+ sb.WriteRune(r + ('a' - 'A'))
103+ }
104+ }
105+ return sb.String()
106+}
53107
54108 // Render returns the dialog lines.
55109 func (d *modelDialog) Render(th tui.Theme, width int) []string {
@@ -58,12 +112,28 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
58112 }
59113 var lines []string
60114 lines = append(lines, frameHeader(th, "model", width))
61- lines = append(lines, th.FG256(th.Muted, "pick a model (↑/↓, enter, esc to cancel)"))
62115
63- // Simple scroll window so very tall catalogs still fit in a short tui.
116+ hint := "pick a model (↑/↓, enter, esc to cancel)"
117+ if d.query != "" {
118+ hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
119+ if len(d.view) != 1 {
120+ hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
121+ }
122+ } else {
123+ hint += " · type to filter"
124+ }
125+ lines = append(lines, th.FG256(th.Muted, hint))
126+
127+ if len(d.view) == 0 {
128+ lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))
129+ lines = append(lines, frameRule(th, width))
130+ return lines
131+ }
132+
133+ // Scroll window so very tall catalogs still fit in a short tui.
64134 const visible = 14
65135 start := 0
66- end := len(d.models)
136+ end := len(d.view)
67137 if end > visible {
68138 start = d.cursor - visible/2
69139 if start < 0 {
@@ -76,7 +146,7 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
76146 }
77147
78148 for i := start; i < end; i++ {
79- m := d.models[i]
149+ m := d.view[i]
80150 prov := m.Provider
81151 id := m.ID
82152 reason := " "
@@ -106,8 +176,8 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
106176 if start > 0 {
107177 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))
108178 }
109- if end < len(d.models) {
110- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.models)-end)))
179+ if end < len(d.view) {
180+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end)))
111181 }
112182
113183 lines = append(lines, frameRule(th, width))
@@ -122,18 +192,34 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
122192 d.cursor--
123193 }
124194 case tui.KeyDown:
125- if d.cursor < len(d.models)-1 {
195+ if d.cursor < len(d.view)-1 {
126196 d.cursor++
127197 }
198+ case tui.KeyBackspace:
199+ if len(d.query) > 0 {
200+ // Drop one rune from the query.
201+ r := []rune(d.query)
202+ d.query = string(r[:len(r)-1])
203+ d.refilter()
204+ }
205+ case tui.KeyRune:
206+ if k.Alt || k.Ctrl {
207+ break
208+ }
209+ // Only printable ASCII is useful for narrowing.
210+ if k.Rune >= 0x20 && k.Rune < 0x7f {
211+ d.query += string(k.Rune)
212+ d.refilter()
213+ }
128214 case tui.KeyEsc:
129215 d.Close()
130216 return modelDialogAction{Close: true}
131217 case tui.KeyEnter:
132- if len(d.models) == 0 {
218+ if len(d.view) == 0 {
133219 d.Close()
134220 return modelDialogAction{Close: true}
135221 }
136- m := d.models[d.cursor]
222+ m := d.view[d.cursor]
137223 d.Close()
138224 return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID}
139225 }
@@ -2,6 +2,8 @@ package modes
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "sort"
6+ "strings"
5 7
6 "github.com/patriceckhart/zot/internal/provider"8 "github.com/patriceckhart/zot/internal/provider"
7 "github.com/patriceckhart/zot/internal/tui"9 "github.com/patriceckhart/zot/internal/tui"
@@ -9,13 +11,17 @@ import (
9 11
10 // modelDialog is an inline picker for choosing the active model.12 // modelDialog is an inline picker for choosing the active model.
11 // It lists all models known to the provider package (baked-in catalog13 // It lists all models known to the provider package (baked-in catalog
12-// + any live entries discovered via /v1/models) and lets the user pick14+// + any live entries discovered via /v1/models) sorted by provider
13-// one with arrow keys + enter.15+// then model id, and lets the user pick one with arrow keys + enter.
16+// Typing characters narrows the list via a fuzzy substring match that
17+// ignores punctuation (e.g. "opus46" matches "claude-opus-4-6").
14 type modelDialog struct {18 type modelDialog struct {
15 active bool19 active bool
16- models []provider.Model20+ all []provider.Model // full catalog, sorted
21+ view []provider.Model // filtered view shown to the user
17 cursor int22 cursor int
18 current string // currently selected model id (highlighted)23 current string // currently selected model id (highlighted)
24+ query string // live filter text typed by the user
19 }25 }
20 26
21 // modelDialogAction is returned by HandleKey.27 // modelDialogAction is returned by HandleKey.
@@ -34,22 +40,70 @@ func newModelDialog() *modelDialog {
34 // it can be pre-selected.40 // it can be pre-selected.
35 func (d *modelDialog) Open(current string) {41 func (d *modelDialog) Open(current string) {
36 d.active = true42 d.active = true
37- d.models = provider.Active()43+ d.all = sortedModels(provider.Active())
38 d.current = current44 d.current = current
45+ d.query = ""
46+ d.refilter()
47+}
48+
49+// Close hides the dialog.
50+func (d *modelDialog) Close() { d.active = false }
51+
52+// Active reports whether the dialog is visible and consumes input.
53+func (d *modelDialog) Active() bool { return d != nil && d.active }
54+
55+// refilter rebuilds view from all according to query, and snaps the
56+// cursor to either the current model (if visible) or the first row.
57+func (d *modelDialog) refilter() {
58+ needle := normalizeModelQuery(d.query)
59+ if needle == "" {
60+ d.view = append([]provider.Model(nil), d.all...)
61+ } else {
62+ out := make([]provider.Model, 0, len(d.all))
63+ for _, m := range d.all {
64+ if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
65+ out = append(out, m)
66+ }
67+ }
68+ d.view = out
69+ }
39 d.cursor = 070 d.cursor = 0
40- for i, m := range d.models {71+ for i, m := range d.view {
41- if m.ID == current {72+ if m.ID == d.current {
42 d.cursor = i73 d.cursor = i
43 break74 break
44 }75 }
45 }76 }
46 }77 }
47 78
48-// Close hides the dialog.79+// sortedModels returns a fresh slice sorted by provider, then model id.
49-func (d *modelDialog) Close() { d.active = false }80+func sortedModels(in []provider.Model) []provider.Model {
81+ out := append([]provider.Model(nil), in...)
82+ sort.SliceStable(out, func(i, j int) bool {
83+ if out[i].Provider != out[j].Provider {
84+ return out[i].Provider < out[j].Provider
85+ }
86+ return out[i].ID < out[j].ID
87+ })
88+ return out
89+}
50 90
51-// Active reports whether the dialog is visible and consumes input.91+// normalizeModelQuery lowercases and strips punctuation so fuzzy
52-func (d *modelDialog) Active() bool { return d != nil && d.active }92+// substring matching works on both the query and haystacks. "opus46"
93+// and "opus-4-6" both become "opus46".
94+func normalizeModelQuery(s string) string {
95+ var sb strings.Builder
96+ sb.Grow(len(s))
97+ for _, r := range s {
98+ switch {
99+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
100+ sb.WriteRune(r)
101+ case r >= 'A' && r <= 'Z':
102+ sb.WriteRune(r + ('a' - 'A'))
103+ }
104+ }
105+ return sb.String()
106+}
53 107
54 // Render returns the dialog lines.108 // Render returns the dialog lines.
55 func (d *modelDialog) Render(th tui.Theme, width int) []string {109 func (d *modelDialog) Render(th tui.Theme, width int) []string {
@@ -58,12 +112,28 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
58 }112 }
59 var lines []string113 var lines []string
60 lines = append(lines, frameHeader(th, "model", width))114 lines = append(lines, frameHeader(th, "model", width))
61- lines = append(lines, th.FG256(th.Muted, "pick a model (↑/↓, enter, esc to cancel)"))
62 115
63- // Simple scroll window so very tall catalogs still fit in a short tui.116+ hint := "pick a model (↑/↓, enter, esc to cancel)"
117+ if d.query != "" {
118+ hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
119+ if len(d.view) != 1 {
120+ hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
121+ }
122+ } else {
123+ hint += " · type to filter"
124+ }
125+ lines = append(lines, th.FG256(th.Muted, hint))
126+
127+ if len(d.view) == 0 {
128+ lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))
129+ lines = append(lines, frameRule(th, width))
130+ return lines
131+ }
132+
133+ // Scroll window so very tall catalogs still fit in a short tui.
64 const visible = 14134 const visible = 14
65 start := 0135 start := 0
66- end := len(d.models)136+ end := len(d.view)
67 if end > visible {137 if end > visible {
68 start = d.cursor - visible/2138 start = d.cursor - visible/2
69 if start < 0 {139 if start < 0 {
@@ -76,7 +146,7 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
76 }146 }
77 147
78 for i := start; i < end; i++ {148 for i := start; i < end; i++ {
79- m := d.models[i]149+ m := d.view[i]
80 prov := m.Provider150 prov := m.Provider
81 id := m.ID151 id := m.ID
82 reason := " "152 reason := " "
@@ -106,8 +176,8 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
106 if start > 0 {176 if start > 0 {
107 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))177 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))
108 }178 }
109- if end < len(d.models) {179+ if end < len(d.view) {
110- lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.models)-end)))180+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end)))
111 }181 }
112 182
113 lines = append(lines, frameRule(th, width))183 lines = append(lines, frameRule(th, width))
@@ -122,18 +192,34 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
122 d.cursor--192 d.cursor--
123 }193 }
124 case tui.KeyDown:194 case tui.KeyDown:
125- if d.cursor < len(d.models)-1 {195+ if d.cursor < len(d.view)-1 {
126 d.cursor++196 d.cursor++
127 }197 }
198+ case tui.KeyBackspace:
199+ if len(d.query) > 0 {
200+ // Drop one rune from the query.
201+ r := []rune(d.query)
202+ d.query = string(r[:len(r)-1])
203+ d.refilter()
204+ }
205+ case tui.KeyRune:
206+ if k.Alt || k.Ctrl {
207+ break
208+ }
209+ // Only printable ASCII is useful for narrowing.
210+ if k.Rune >= 0x20 && k.Rune < 0x7f {
211+ d.query += string(k.Rune)
212+ d.refilter()
213+ }
128 case tui.KeyEsc:214 case tui.KeyEsc:
129 d.Close()215 d.Close()
130 return modelDialogAction{Close: true}216 return modelDialogAction{Close: true}
131 case tui.KeyEnter:217 case tui.KeyEnter:
132- if len(d.models) == 0 {218+ if len(d.view) == 0 {
133 d.Close()219 d.Close()
134 return modelDialogAction{Close: true}220 return modelDialogAction{Close: true}
135 }221 }
136- m := d.models[d.cursor]222+ m := d.view[d.cursor]
137 d.Close()223 d.Close()
138 return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID}224 return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID}
139 }225 }
A internal/agent/modes/telegram/api.go +264 -0
@@ -0,0 +1,264 @@
1+package telegram
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "mime/multipart"
10+ "net/http"
11+ "net/url"
12+ "strconv"
13+ "strings"
14+ "time"
15+)
16+
17+// Telegram Bot API types used by the bridge. Only the subset we need.
18+
19+type User struct {
20+ ID int64 `json:"id"`
21+ IsBot bool `json:"is_bot"`
22+ FirstName string `json:"first_name"`
23+ Username string `json:"username"`
24+}
25+
26+type Chat struct {
27+ ID int64 `json:"id"`
28+ Type string `json:"type"` // "private" | "group" | ...
29+}
30+
31+type PhotoSize struct {
32+ FileID string `json:"file_id"`
33+ FileSize int `json:"file_size,omitempty"`
34+ Width int `json:"width,omitempty"`
35+ Height int `json:"height,omitempty"`
36+}
37+
38+type Document struct {
39+ FileID string `json:"file_id"`
40+ FileName string `json:"file_name,omitempty"`
41+ MimeType string `json:"mime_type,omitempty"`
42+ FileSize int `json:"file_size,omitempty"`
43+}
44+
45+type Message struct {
46+ MessageID int `json:"message_id"`
47+ From *User `json:"from"`
48+ Chat Chat `json:"chat"`
49+ Date int64 `json:"date"`
50+ Text string `json:"text,omitempty"`
51+ Caption string `json:"caption,omitempty"`
52+ Photo []PhotoSize `json:"photo,omitempty"`
53+ Document *Document `json:"document,omitempty"`
54+}
55+
56+type Update struct {
57+ UpdateID int64 `json:"update_id"`
58+ Message *Message `json:"message"`
59+ Edited *Message `json:"edited_message"`
60+}
61+
62+type File struct {
63+ FileID string `json:"file_id"`
64+ FilePath string `json:"file_path"`
65+ FileSize int `json:"file_size,omitempty"`
66+}
67+
68+// apiResponse is Telegram's envelope.
69+type apiResponse[T any] struct {
70+ OK bool `json:"ok"`
71+ Description string `json:"description"`
72+ Result T `json:"result"`
73+}
74+
75+// Client is a minimal Telegram Bot API client.
76+type Client struct {
77+ token string
78+ http *http.Client
79+}
80+
81+func NewClient(token string) *Client {
82+ return &Client{token: token, http: &http.Client{Timeout: 0}}
83+}
84+
85+func (c *Client) baseURL() string { return "https://api.telegram.org/bot" + c.token }
86+
87+// GetMe verifies the token and returns the bot's own User.
88+func (c *Client) GetMe(ctx context.Context) (*User, error) {
89+ var resp apiResponse[User]
90+ if err := c.call(ctx, "getMe", nil, &resp); err != nil {
91+ return nil, err
92+ }
93+ if !resp.OK {
94+ return nil, fmt.Errorf("getMe: %s", resp.Description)
95+ }
96+ return &resp.Result, nil
97+}
98+
99+// GetUpdates polls for new updates since offset with a long-poll timeout.
100+func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) {
101+ q := url.Values{}
102+ if offset != 0 {
103+ q.Set("offset", strconv.FormatInt(offset, 10))
104+ }
105+ q.Set("timeout", strconv.Itoa(timeoutSec))
106+ q.Set("allowed_updates", `["message","edited_message"]`)
107+
108+ var resp apiResponse[[]Update]
109+ if err := c.call(ctx, "getUpdates?"+q.Encode(), nil, &resp); err != nil {
110+ return nil, err
111+ }
112+ if !resp.OK {
113+ return nil, fmt.Errorf("getUpdates: %s", resp.Description)
114+ }
115+ return resp.Result, nil
116+}
117+
118+// SendMessage sends a plain-text reply.
119+func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, replyTo int) error {
120+ body := map[string]any{
121+ "chat_id": chatID,
122+ "text": text,
123+ }
124+ if replyTo > 0 {
125+ body["reply_to_message_id"] = replyTo
126+ }
127+ var resp apiResponse[json.RawMessage]
128+ if err := c.call(ctx, "sendMessage", body, &resp); err != nil {
129+ return err
130+ }
131+ if !resp.OK {
132+ return fmt.Errorf("sendMessage: %s", resp.Description)
133+ }
134+ return nil
135+}
136+
137+// SendChatAction keeps the "typing..." indicator alive. Call every ~4s.
138+func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string) error {
139+ body := map[string]any{"chat_id": chatID, "action": action}
140+ var resp apiResponse[json.RawMessage]
141+ _ = c.call(ctx, "sendChatAction", body, &resp) // ignore errors; it's advisory
142+ return nil
143+}
144+
145+// SendDocument uploads a local file as a document attachment.
146+func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error {
147+ f, err := openFile(path)
148+ if err != nil {
149+ return err
150+ }
151+ defer f.Close()
152+
153+ var buf bytes.Buffer
154+ w := multipart.NewWriter(&buf)
155+ _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
156+ if caption != "" {
157+ _ = w.WriteField("caption", caption)
158+ }
159+ part, err := w.CreateFormFile("document", lastPathElem(path))
160+ if err != nil {
161+ return err
162+ }
163+ if _, err := io.Copy(part, f); err != nil {
164+ return err
165+ }
166+ w.Close()
167+
168+ req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendDocument", &buf)
169+ if err != nil {
170+ return err
171+ }
172+ req.Header.Set("content-type", w.FormDataContentType())
173+ resp, err := c.http.Do(req)
174+ if err != nil {
175+ return err
176+ }
177+ defer resp.Body.Close()
178+ if resp.StatusCode >= 400 {
179+ body, _ := io.ReadAll(resp.Body)
180+ return fmt.Errorf("sendDocument http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
181+ }
182+ return nil
183+}
184+
185+// GetFile resolves a file_id to a downloadable path.
186+func (c *Client) GetFile(ctx context.Context, fileID string) (*File, error) {
187+ q := url.Values{}
188+ q.Set("file_id", fileID)
189+ var resp apiResponse[File]
190+ if err := c.call(ctx, "getFile?"+q.Encode(), nil, &resp); err != nil {
191+ return nil, err
192+ }
193+ if !resp.OK {
194+ return nil, fmt.Errorf("getFile: %s", resp.Description)
195+ }
196+ return &resp.Result, nil
197+}
198+
199+// DownloadFile downloads the file at filePath (from GetFile) into memory.
200+func (c *Client) DownloadFile(ctx context.Context, filePath string) ([]byte, error) {
201+ u := "https://api.telegram.org/file/bot" + c.token + "/" + filePath
202+ req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
203+ if err != nil {
204+ return nil, err
205+ }
206+ resp, err := c.http.Do(req)
207+ if err != nil {
208+ return nil, err
209+ }
210+ defer resp.Body.Close()
211+ if resp.StatusCode != 200 {
212+ return nil, fmt.Errorf("download %s: http %d", filePath, resp.StatusCode)
213+ }
214+ return io.ReadAll(resp.Body)
215+}
216+
217+// call issues a GET or POST request depending on whether body is nil.
218+func (c *Client) call(ctx context.Context, endpoint string, body map[string]any, out any) error {
219+ var req *http.Request
220+ var err error
221+ if body == nil {
222+ req, err = http.NewRequestWithContext(ctx, "GET", c.baseURL()+"/"+endpoint, nil)
223+ } else {
224+ b, _ := json.Marshal(body)
225+ req, err = http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/"+endpoint, bytes.NewReader(b))
226+ if err == nil {
227+ req.Header.Set("content-type", "application/json")
228+ }
229+ }
230+ if err != nil {
231+ return err
232+ }
233+ resp, err := c.http.Do(req)
234+ if err != nil {
235+ return err
236+ }
237+ defer resp.Body.Close()
238+ // Telegram returns 200 with ok=false for logical errors; read either way.
239+ respBody, _ := io.ReadAll(resp.Body)
240+ if len(respBody) == 0 {
241+ return fmt.Errorf("%s: empty response (status %d)", endpoint, resp.StatusCode)
242+ }
243+ return json.Unmarshal(respBody, out)
244+}
245+
246+// small helpers kept here so api.go has no other deps.
247+func lastPathElem(p string) string {
248+ for i := len(p) - 1; i >= 0; i-- {
249+ if p[i] == '/' || p[i] == '\\' {
250+ return p[i+1:]
251+ }
252+ }
253+ return p
254+}
255+
256+func openFile(path string) (io.ReadCloser, error) {
257+ return osOpen(path)
258+}
259+
260+// overridden in tests.
261+var osOpen = defaultOpen
262+
263+// Sleep is a package-level hook so tests can stub it.
264+var sleep = func(d time.Duration) { time.Sleep(d) }
@@ -0,0 +1,264 @@
1+package telegram
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "mime/multipart"
10+ "net/http"
11+ "net/url"
12+ "strconv"
13+ "strings"
14+ "time"
15+)
16+
17+// Telegram Bot API types used by the bridge. Only the subset we need.
18+
19+type User struct {
20+ ID int64 `json:"id"`
21+ IsBot bool `json:"is_bot"`
22+ FirstName string `json:"first_name"`
23+ Username string `json:"username"`
24+}
25+
26+type Chat struct {
27+ ID int64 `json:"id"`
28+ Type string `json:"type"` // "private" | "group" | ...
29+}
30+
31+type PhotoSize struct {
32+ FileID string `json:"file_id"`
33+ FileSize int `json:"file_size,omitempty"`
34+ Width int `json:"width,omitempty"`
35+ Height int `json:"height,omitempty"`
36+}
37+
38+type Document struct {
39+ FileID string `json:"file_id"`
40+ FileName string `json:"file_name,omitempty"`
41+ MimeType string `json:"mime_type,omitempty"`
42+ FileSize int `json:"file_size,omitempty"`
43+}
44+
45+type Message struct {
46+ MessageID int `json:"message_id"`
47+ From *User `json:"from"`
48+ Chat Chat `json:"chat"`
49+ Date int64 `json:"date"`
50+ Text string `json:"text,omitempty"`
51+ Caption string `json:"caption,omitempty"`
52+ Photo []PhotoSize `json:"photo,omitempty"`
53+ Document *Document `json:"document,omitempty"`
54+}
55+
56+type Update struct {
57+ UpdateID int64 `json:"update_id"`
58+ Message *Message `json:"message"`
59+ Edited *Message `json:"edited_message"`
60+}
61+
62+type File struct {
63+ FileID string `json:"file_id"`
64+ FilePath string `json:"file_path"`
65+ FileSize int `json:"file_size,omitempty"`
66+}
67+
68+// apiResponse is Telegram's envelope.
69+type apiResponse[T any] struct {
70+ OK bool `json:"ok"`
71+ Description string `json:"description"`
72+ Result T `json:"result"`
73+}
74+
75+// Client is a minimal Telegram Bot API client.
76+type Client struct {
77+ token string
78+ http *http.Client
79+}
80+
81+func NewClient(token string) *Client {
82+ return &Client{token: token, http: &http.Client{Timeout: 0}}
83+}
84+
85+func (c *Client) baseURL() string { return "https://api.telegram.org/bot" + c.token }
86+
87+// GetMe verifies the token and returns the bot's own User.
88+func (c *Client) GetMe(ctx context.Context) (*User, error) {
89+ var resp apiResponse[User]
90+ if err := c.call(ctx, "getMe", nil, &resp); err != nil {
91+ return nil, err
92+ }
93+ if !resp.OK {
94+ return nil, fmt.Errorf("getMe: %s", resp.Description)
95+ }
96+ return &resp.Result, nil
97+}
98+
99+// GetUpdates polls for new updates since offset with a long-poll timeout.
100+func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) {
101+ q := url.Values{}
102+ if offset != 0 {
103+ q.Set("offset", strconv.FormatInt(offset, 10))
104+ }
105+ q.Set("timeout", strconv.Itoa(timeoutSec))
106+ q.Set("allowed_updates", `["message","edited_message"]`)
107+
108+ var resp apiResponse[[]Update]
109+ if err := c.call(ctx, "getUpdates?"+q.Encode(), nil, &resp); err != nil {
110+ return nil, err
111+ }
112+ if !resp.OK {
113+ return nil, fmt.Errorf("getUpdates: %s", resp.Description)
114+ }
115+ return resp.Result, nil
116+}
117+
118+// SendMessage sends a plain-text reply.
119+func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, replyTo int) error {
120+ body := map[string]any{
121+ "chat_id": chatID,
122+ "text": text,
123+ }
124+ if replyTo > 0 {
125+ body["reply_to_message_id"] = replyTo
126+ }
127+ var resp apiResponse[json.RawMessage]
128+ if err := c.call(ctx, "sendMessage", body, &resp); err != nil {
129+ return err
130+ }
131+ if !resp.OK {
132+ return fmt.Errorf("sendMessage: %s", resp.Description)
133+ }
134+ return nil
135+}
136+
137+// SendChatAction keeps the "typing..." indicator alive. Call every ~4s.
138+func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string) error {
139+ body := map[string]any{"chat_id": chatID, "action": action}
140+ var resp apiResponse[json.RawMessage]
141+ _ = c.call(ctx, "sendChatAction", body, &resp) // ignore errors; it's advisory
142+ return nil
143+}
144+
145+// SendDocument uploads a local file as a document attachment.
146+func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error {
147+ f, err := openFile(path)
148+ if err != nil {
149+ return err
150+ }
151+ defer f.Close()
152+
153+ var buf bytes.Buffer
154+ w := multipart.NewWriter(&buf)
155+ _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
156+ if caption != "" {
157+ _ = w.WriteField("caption", caption)
158+ }
159+ part, err := w.CreateFormFile("document", lastPathElem(path))
160+ if err != nil {
161+ return err
162+ }
163+ if _, err := io.Copy(part, f); err != nil {
164+ return err
165+ }
166+ w.Close()
167+
168+ req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendDocument", &buf)
169+ if err != nil {
170+ return err
171+ }
172+ req.Header.Set("content-type", w.FormDataContentType())
173+ resp, err := c.http.Do(req)
174+ if err != nil {
175+ return err
176+ }
177+ defer resp.Body.Close()
178+ if resp.StatusCode >= 400 {
179+ body, _ := io.ReadAll(resp.Body)
180+ return fmt.Errorf("sendDocument http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
181+ }
182+ return nil
183+}
184+
185+// GetFile resolves a file_id to a downloadable path.
186+func (c *Client) GetFile(ctx context.Context, fileID string) (*File, error) {
187+ q := url.Values{}
188+ q.Set("file_id", fileID)
189+ var resp apiResponse[File]
190+ if err := c.call(ctx, "getFile?"+q.Encode(), nil, &resp); err != nil {
191+ return nil, err
192+ }
193+ if !resp.OK {
194+ return nil, fmt.Errorf("getFile: %s", resp.Description)
195+ }
196+ return &resp.Result, nil
197+}
198+
199+// DownloadFile downloads the file at filePath (from GetFile) into memory.
200+func (c *Client) DownloadFile(ctx context.Context, filePath string) ([]byte, error) {
201+ u := "https://api.telegram.org/file/bot" + c.token + "/" + filePath
202+ req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
203+ if err != nil {
204+ return nil, err
205+ }
206+ resp, err := c.http.Do(req)
207+ if err != nil {
208+ return nil, err
209+ }
210+ defer resp.Body.Close()
211+ if resp.StatusCode != 200 {
212+ return nil, fmt.Errorf("download %s: http %d", filePath, resp.StatusCode)
213+ }
214+ return io.ReadAll(resp.Body)
215+}
216+
217+// call issues a GET or POST request depending on whether body is nil.
218+func (c *Client) call(ctx context.Context, endpoint string, body map[string]any, out any) error {
219+ var req *http.Request
220+ var err error
221+ if body == nil {
222+ req, err = http.NewRequestWithContext(ctx, "GET", c.baseURL()+"/"+endpoint, nil)
223+ } else {
224+ b, _ := json.Marshal(body)
225+ req, err = http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/"+endpoint, bytes.NewReader(b))
226+ if err == nil {
227+ req.Header.Set("content-type", "application/json")
228+ }
229+ }
230+ if err != nil {
231+ return err
232+ }
233+ resp, err := c.http.Do(req)
234+ if err != nil {
235+ return err
236+ }
237+ defer resp.Body.Close()
238+ // Telegram returns 200 with ok=false for logical errors; read either way.
239+ respBody, _ := io.ReadAll(resp.Body)
240+ if len(respBody) == 0 {
241+ return fmt.Errorf("%s: empty response (status %d)", endpoint, resp.StatusCode)
242+ }
243+ return json.Unmarshal(respBody, out)
244+}
245+
246+// small helpers kept here so api.go has no other deps.
247+func lastPathElem(p string) string {
248+ for i := len(p) - 1; i >= 0; i-- {
249+ if p[i] == '/' || p[i] == '\\' {
250+ return p[i+1:]
251+ }
252+ }
253+ return p
254+}
255+
256+func openFile(path string) (io.ReadCloser, error) {
257+ return osOpen(path)
258+}
259+
260+// overridden in tests.
261+var osOpen = defaultOpen
262+
263+// Sleep is a package-level hook so tests can stub it.
264+var sleep = func(d time.Duration) { time.Sleep(d) }
A internal/agent/modes/telegram/bot.go +407 -0
@@ -0,0 +1,407 @@
1+package telegram
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/internal/core"
11+ "github.com/patriceckhart/zot/internal/provider"
12+)
13+
14+// Bot owns the Telegram polling loop and dispatches inbound DMs to
15+// the agent. It is a long-running goroutine; Run blocks until ctx
16+// cancels.
17+type Bot struct {
18+ Client *Client
19+ Agent *core.Agent
20+ Config Config
21+ ZotHome string
22+ // Save persists cfg to bot.json. Called whenever the bot pairs
23+ // with a new allowed user or advances LastUpdateID.
24+ Save func(Config) error
25+ // RefreshCreds is called before every turn to pick up newly
26+ // refreshed OAuth tokens. Optional; when nil, the bot uses the
27+ // credential it was built with. Implementations typically call
28+ // agent.ResolveCredentialFull which auto-refreshes expired tokens.
29+ RefreshCreds func() error
30+
31+ mu sync.Mutex
32+ busy bool
33+ activeCtx context.CancelFunc
34+ queue []queuedTurn
35+}
36+
37+// queuedTurn is an inbound DM waiting to become a prompt.
38+type queuedTurn struct {
39+ chatID int64
40+ messageID int
41+ prompt string
42+ images []provider.ImageBlock
43+}
44+
45+// Run drives the bot. Returns when ctx is cancelled or GetMe fails.
46+func (b *Bot) Run(ctx context.Context) error {
47+ if b.Config.BotToken == "" {
48+ return fmt.Errorf("no bot token configured; run `zot bot setup` first")
49+ }
50+ me, err := b.Client.GetMe(ctx)
51+ if err != nil {
52+ return fmt.Errorf("getMe: %w", err)
53+ }
54+ // Keep the stored username/id in sync with the actual bot.
55+ if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {
56+ b.Config.BotID = me.ID
57+ b.Config.BotUsername = me.Username
58+ _ = b.Save(b.Config)
59+ }
60+
61+ fmt.Printf("telegram bridge online as @%s (id=%d)\n", me.Username, me.ID)
62+ if b.Config.AllowedUserID == 0 {
63+ fmt.Println("no user paired yet — send /start to the bot from Telegram to claim it")
64+ } else {
65+ fmt.Printf("paired with telegram user id %d\n", b.Config.AllowedUserID)
66+ }
67+
68+ return b.pollLoop(ctx)
69+}
70+
71+// pollLoop long-polls Telegram for updates and dispatches them.
72+func (b *Bot) pollLoop(ctx context.Context) error {
73+ backoff := time.Second
74+ for {
75+ if err := ctx.Err(); err != nil {
76+ return err
77+ }
78+ updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30)
79+ if err != nil {
80+ if ctx.Err() != nil {
81+ return ctx.Err()
82+ }
83+ fmt.Fprintln(stderr(), "telegram: getUpdates error:", err)
84+ select {
85+ case <-ctx.Done():
86+ return ctx.Err()
87+ case <-time.After(backoff):
88+ }
89+ if backoff < 30*time.Second {
90+ backoff *= 2
91+ }
92+ continue
93+ }
94+ backoff = time.Second
95+ for _, u := range updates {
96+ if err := b.handleUpdate(ctx, u); err != nil {
97+ fmt.Fprintln(stderr(), "telegram: handleUpdate:", err)
98+ }
99+ b.Config.LastUpdateID = u.UpdateID
100+ _ = b.Save(b.Config)
101+ }
102+ }
103+}
104+
105+// handleUpdate processes a single Telegram update.
106+func (b *Bot) handleUpdate(ctx context.Context, u Update) error {
107+ msg := u.Message
108+ if msg == nil {
109+ msg = u.Edited
110+ }
111+ if msg == nil || msg.From == nil || msg.From.IsBot {
112+ return nil
113+ }
114+ if msg.Chat.Type != "private" {
115+ return nil
116+ }
117+
118+ // Pairing: first user who sends /start claims the bridge.
119+ text := strings.TrimSpace(msg.Text)
120+ if b.Config.AllowedUserID == 0 {
121+ if strings.HasPrefix(text, "/start") {
122+ b.Config.AllowedUserID = msg.From.ID
123+ _ = b.Save(b.Config)
124+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
125+ fmt.Sprintf("paired with @%s. send any message and i'll forward it to zot.", msg.From.Username),
126+ msg.MessageID)
127+ return nil
128+ }
129+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
130+ "this bot isn't paired yet. send /start to claim it.",
131+ msg.MessageID)
132+ return nil
133+ }
134+
135+ // Enforce allowed user.
136+ if msg.From.ID != b.Config.AllowedUserID {
137+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
138+ "this bot is paired with a different user.",
139+ msg.MessageID)
140+ return nil
141+ }
142+
143+ // Built-in commands that bypass the agent.
144+ switch text {
145+ case "/start", "/help":
146+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
147+ "send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop.",
148+ msg.MessageID)
149+ return nil
150+ case "/status":
151+ return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID)
152+ case "/stop":
153+ b.mu.Lock()
154+ cancel := b.activeCtx
155+ b.mu.Unlock()
156+ if cancel != nil {
157+ cancel()
158+ _ = b.Client.SendMessage(ctx, msg.Chat.ID, "cancelled the current turn.", msg.MessageID)
159+ } else {
160+ _ = b.Client.SendMessage(ctx, msg.Chat.ID, "nothing running.", msg.MessageID)
161+ }
162+ return nil
163+ }
164+
165+ // Build the prompt: combine text + caption; download image attachments.
166+ prompt := strings.TrimSpace(msg.Text)
167+ if msg.Caption != "" {
168+ if prompt != "" {
169+ prompt += "\n"
170+ }
171+ prompt += msg.Caption
172+ }
173+
174+ var images []provider.ImageBlock
175+ if len(msg.Photo) > 0 {
176+ // Photos arrive in multiple sizes; take the largest (last in the slice).
177+ largest := msg.Photo[len(msg.Photo)-1]
178+ if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil {
179+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
180+ } else {
181+ fmt.Fprintln(stderr(), "telegram: download photo:", err)
182+ }
183+ }
184+ if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
185+ if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
186+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
187+ }
188+ }
189+
190+ if prompt == "" && len(images) == 0 {
191+ return nil
192+ }
193+
194+ b.mu.Lock()
195+ b.queue = append(b.queue, queuedTurn{
196+ chatID: msg.Chat.ID,
197+ messageID: msg.MessageID,
198+ prompt: prompt,
199+ images: images,
200+ })
201+ idle := !b.busy
202+ b.mu.Unlock()
203+
204+ if idle {
205+ go b.drainQueue(ctx)
206+ }
207+ return nil
208+}
209+
210+// drainQueue runs queued turns one at a time until the queue is empty.
211+func (b *Bot) drainQueue(parent context.Context) {
212+ for {
213+ b.mu.Lock()
214+ if len(b.queue) == 0 {
215+ b.busy = false
216+ b.activeCtx = nil
217+ b.mu.Unlock()
218+ return
219+ }
220+ t := b.queue[0]
221+ b.queue = b.queue[1:]
222+ b.busy = true
223+ turnCtx, cancel := context.WithCancel(parent)
224+ b.activeCtx = cancel
225+ b.mu.Unlock()
226+
227+ if b.RefreshCreds != nil {
228+ if err := b.RefreshCreds(); err != nil {
229+ fmt.Fprintln(stderr(), "telegram: refresh creds:", err)
230+ }
231+ }
232+ b.runTurn(turnCtx, t)
233+ cancel()
234+ }
235+}
236+
237+// runTurn sends the queued prompt to the agent and streams the reply.
238+func (b *Bot) runTurn(ctx context.Context, t queuedTurn) {
239+ stopTyping := b.startTyping(ctx, t.chatID)
240+ defer stopTyping()
241+
242+ var replyBuilder strings.Builder
243+ var lastAssistantText string
244+ var turnErr error
245+
246+ sink := func(ev core.AgentEvent) {
247+ switch e := ev.(type) {
248+ case core.EvTextDelta:
249+ replyBuilder.WriteString(e.Delta)
250+ case core.EvAssistantMessage:
251+ var sb strings.Builder
252+ for _, c := range e.Message.Content {
253+ if tb, ok := c.(provider.TextBlock); ok {
254+ if sb.Len() > 0 {
255+ sb.WriteString("\n")
256+ }
257+ sb.WriteString(tb.Text)
258+ }
259+ }
260+ if sb.Len() > 0 {
261+ lastAssistantText = sb.String()
262+ }
263+ replyBuilder.Reset()
264+ case core.EvTurnEnd:
265+ if e.Err != nil {
266+ turnErr = e.Err
267+ }
268+ }
269+ }
270+
271+ if err := b.Agent.Prompt(ctx, t.prompt, t.images, sink); err != nil {
272+ turnErr = err
273+ }
274+
275+ reply := strings.TrimSpace(lastAssistantText)
276+ if reply == "" {
277+ reply = strings.TrimSpace(replyBuilder.String())
278+ }
279+ if turnErr != nil && ctx.Err() == nil {
280+ reply = "error: " + turnErr.Error()
281+ }
282+ if reply == "" {
283+ reply = "(no reply)"
284+ }
285+ // Telegram caps messages at 4096 chars. Chunk to be safe.
286+ for _, chunk := range chunkMessage(reply, 4000) {
287+ if err := b.Client.SendMessage(context.Background(), t.chatID, chunk, 0); err != nil {
288+ fmt.Fprintln(stderr(), "telegram: sendMessage:", err)
289+ break
290+ }
291+ }
292+}
293+
294+// startTyping keeps Telegram's "typing..." indicator alive until the
295+// returned stop function is called.
296+func (b *Bot) startTyping(ctx context.Context, chatID int64) func() {
297+ tctx, cancel := context.WithCancel(ctx)
298+ go func() {
299+ for {
300+ _ = b.Client.SendChatAction(tctx, chatID, "typing")
301+ select {
302+ case <-tctx.Done():
303+ return
304+ case <-time.After(4 * time.Second):
305+ }
306+ }
307+ }()
308+ return cancel
309+}
310+
311+// sendStatus describes agent state to the Telegram user.
312+func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error {
313+ b.mu.Lock()
314+ busy := b.busy
315+ queued := len(b.queue)
316+ b.mu.Unlock()
317+
318+ state := "idle"
319+ if busy {
320+ state = "working"
321+ }
322+ lines := []string{
323+ fmt.Sprintf("state: %s", state),
324+ fmt.Sprintf("queued: %d", queued),
325+ fmt.Sprintf("model: %s", b.Agent.Model),
326+ }
327+ cost := b.Agent.Cost()
328+ lines = append(lines, fmt.Sprintf("cost: $%.4f (%d in / %d out)",
329+ cost.CostUSD, cost.InputTokens, cost.OutputTokens))
330+ return b.Client.SendMessage(ctx, chatID, strings.Join(lines, "\n"), replyTo)
331+}
332+
333+// download fetches a file from Telegram and returns bytes + mime.
334+func (b *Bot) download(ctx context.Context, fileID, mime string) ([]byte, string, error) {
335+ f, err := b.Client.GetFile(ctx, fileID)
336+ if err != nil {
337+ return nil, "", err
338+ }
339+ data, err := b.Client.DownloadFile(ctx, f.FilePath)
340+ if err != nil {
341+ return nil, "", err
342+ }
343+ if mime == "" {
344+ mime = guessImageMIME(f.FilePath)
345+ }
346+ return data, mime, nil
347+}
348+
349+// chunkMessage splits s into chunks no larger than limit runes, on line
350+// boundaries when possible.
351+func chunkMessage(s string, limit int) []string {
352+ if len(s) <= limit {
353+ return []string{s}
354+ }
355+ var out []string
356+ lines := strings.Split(s, "\n")
357+ var cur strings.Builder
358+ for _, l := range lines {
359+ if cur.Len()+len(l)+1 > limit && cur.Len() > 0 {
360+ out = append(out, cur.String())
361+ cur.Reset()
362+ }
363+ if len(l) > limit {
364+ // Line itself too long; hard-split.
365+ for len(l) > limit {
366+ out = append(out, l[:limit])
367+ l = l[limit:]
368+ }
369+ }
370+ if cur.Len() > 0 {
371+ cur.WriteString("\n")
372+ }
373+ cur.WriteString(l)
374+ }
375+ if cur.Len() > 0 {
376+ out = append(out, cur.String())
377+ }
378+ return out
379+}
380+
381+// isImageMIME returns true for MIME types the model can probably ingest
382+// as a vision input.
383+func isImageMIME(m string) bool {
384+ switch strings.ToLower(m) {
385+ case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp":
386+ return true
387+ }
388+ return false
389+}
390+
391+// guessImageMIME infers a mime type from a filename suffix. Falls back
392+// to image/png because telegram photos are always re-encoded to jpeg
393+// but getFile's file_path may omit the extension.
394+func guessImageMIME(path string) string {
395+ lower := strings.ToLower(path)
396+ switch {
397+ case strings.HasSuffix(lower, ".png"):
398+ return "image/png"
399+ case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
400+ return "image/jpeg"
401+ case strings.HasSuffix(lower, ".gif"):
402+ return "image/gif"
403+ case strings.HasSuffix(lower, ".webp"):
404+ return "image/webp"
405+ }
406+ return "image/jpeg"
407+}
@@ -0,0 +1,407 @@
1+package telegram
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/internal/core"
11+ "github.com/patriceckhart/zot/internal/provider"
12+)
13+
14+// Bot owns the Telegram polling loop and dispatches inbound DMs to
15+// the agent. It is a long-running goroutine; Run blocks until ctx
16+// cancels.
17+type Bot struct {
18+ Client *Client
19+ Agent *core.Agent
20+ Config Config
21+ ZotHome string
22+ // Save persists cfg to bot.json. Called whenever the bot pairs
23+ // with a new allowed user or advances LastUpdateID.
24+ Save func(Config) error
25+ // RefreshCreds is called before every turn to pick up newly
26+ // refreshed OAuth tokens. Optional; when nil, the bot uses the
27+ // credential it was built with. Implementations typically call
28+ // agent.ResolveCredentialFull which auto-refreshes expired tokens.
29+ RefreshCreds func() error
30+
31+ mu sync.Mutex
32+ busy bool
33+ activeCtx context.CancelFunc
34+ queue []queuedTurn
35+}
36+
37+// queuedTurn is an inbound DM waiting to become a prompt.
38+type queuedTurn struct {
39+ chatID int64
40+ messageID int
41+ prompt string
42+ images []provider.ImageBlock
43+}
44+
45+// Run drives the bot. Returns when ctx is cancelled or GetMe fails.
46+func (b *Bot) Run(ctx context.Context) error {
47+ if b.Config.BotToken == "" {
48+ return fmt.Errorf("no bot token configured; run `zot bot setup` first")
49+ }
50+ me, err := b.Client.GetMe(ctx)
51+ if err != nil {
52+ return fmt.Errorf("getMe: %w", err)
53+ }
54+ // Keep the stored username/id in sync with the actual bot.
55+ if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {
56+ b.Config.BotID = me.ID
57+ b.Config.BotUsername = me.Username
58+ _ = b.Save(b.Config)
59+ }
60+
61+ fmt.Printf("telegram bridge online as @%s (id=%d)\n", me.Username, me.ID)
62+ if b.Config.AllowedUserID == 0 {
63+ fmt.Println("no user paired yet — send /start to the bot from Telegram to claim it")
64+ } else {
65+ fmt.Printf("paired with telegram user id %d\n", b.Config.AllowedUserID)
66+ }
67+
68+ return b.pollLoop(ctx)
69+}
70+
71+// pollLoop long-polls Telegram for updates and dispatches them.
72+func (b *Bot) pollLoop(ctx context.Context) error {
73+ backoff := time.Second
74+ for {
75+ if err := ctx.Err(); err != nil {
76+ return err
77+ }
78+ updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30)
79+ if err != nil {
80+ if ctx.Err() != nil {
81+ return ctx.Err()
82+ }
83+ fmt.Fprintln(stderr(), "telegram: getUpdates error:", err)
84+ select {
85+ case <-ctx.Done():
86+ return ctx.Err()
87+ case <-time.After(backoff):
88+ }
89+ if backoff < 30*time.Second {
90+ backoff *= 2
91+ }
92+ continue
93+ }
94+ backoff = time.Second
95+ for _, u := range updates {
96+ if err := b.handleUpdate(ctx, u); err != nil {
97+ fmt.Fprintln(stderr(), "telegram: handleUpdate:", err)
98+ }
99+ b.Config.LastUpdateID = u.UpdateID
100+ _ = b.Save(b.Config)
101+ }
102+ }
103+}
104+
105+// handleUpdate processes a single Telegram update.
106+func (b *Bot) handleUpdate(ctx context.Context, u Update) error {
107+ msg := u.Message
108+ if msg == nil {
109+ msg = u.Edited
110+ }
111+ if msg == nil || msg.From == nil || msg.From.IsBot {
112+ return nil
113+ }
114+ if msg.Chat.Type != "private" {
115+ return nil
116+ }
117+
118+ // Pairing: first user who sends /start claims the bridge.
119+ text := strings.TrimSpace(msg.Text)
120+ if b.Config.AllowedUserID == 0 {
121+ if strings.HasPrefix(text, "/start") {
122+ b.Config.AllowedUserID = msg.From.ID
123+ _ = b.Save(b.Config)
124+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
125+ fmt.Sprintf("paired with @%s. send any message and i'll forward it to zot.", msg.From.Username),
126+ msg.MessageID)
127+ return nil
128+ }
129+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
130+ "this bot isn't paired yet. send /start to claim it.",
131+ msg.MessageID)
132+ return nil
133+ }
134+
135+ // Enforce allowed user.
136+ if msg.From.ID != b.Config.AllowedUserID {
137+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
138+ "this bot is paired with a different user.",
139+ msg.MessageID)
140+ return nil
141+ }
142+
143+ // Built-in commands that bypass the agent.
144+ switch text {
145+ case "/start", "/help":
146+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
147+ "send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop.",
148+ msg.MessageID)
149+ return nil
150+ case "/status":
151+ return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID)
152+ case "/stop":
153+ b.mu.Lock()
154+ cancel := b.activeCtx
155+ b.mu.Unlock()
156+ if cancel != nil {
157+ cancel()
158+ _ = b.Client.SendMessage(ctx, msg.Chat.ID, "cancelled the current turn.", msg.MessageID)
159+ } else {
160+ _ = b.Client.SendMessage(ctx, msg.Chat.ID, "nothing running.", msg.MessageID)
161+ }
162+ return nil
163+ }
164+
165+ // Build the prompt: combine text + caption; download image attachments.
166+ prompt := strings.TrimSpace(msg.Text)
167+ if msg.Caption != "" {
168+ if prompt != "" {
169+ prompt += "\n"
170+ }
171+ prompt += msg.Caption
172+ }
173+
174+ var images []provider.ImageBlock
175+ if len(msg.Photo) > 0 {
176+ // Photos arrive in multiple sizes; take the largest (last in the slice).
177+ largest := msg.Photo[len(msg.Photo)-1]
178+ if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil {
179+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
180+ } else {
181+ fmt.Fprintln(stderr(), "telegram: download photo:", err)
182+ }
183+ }
184+ if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
185+ if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
186+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
187+ }
188+ }
189+
190+ if prompt == "" && len(images) == 0 {
191+ return nil
192+ }
193+
194+ b.mu.Lock()
195+ b.queue = append(b.queue, queuedTurn{
196+ chatID: msg.Chat.ID,
197+ messageID: msg.MessageID,
198+ prompt: prompt,
199+ images: images,
200+ })
201+ idle := !b.busy
202+ b.mu.Unlock()
203+
204+ if idle {
205+ go b.drainQueue(ctx)
206+ }
207+ return nil
208+}
209+
210+// drainQueue runs queued turns one at a time until the queue is empty.
211+func (b *Bot) drainQueue(parent context.Context) {
212+ for {
213+ b.mu.Lock()
214+ if len(b.queue) == 0 {
215+ b.busy = false
216+ b.activeCtx = nil
217+ b.mu.Unlock()
218+ return
219+ }
220+ t := b.queue[0]
221+ b.queue = b.queue[1:]
222+ b.busy = true
223+ turnCtx, cancel := context.WithCancel(parent)
224+ b.activeCtx = cancel
225+ b.mu.Unlock()
226+
227+ if b.RefreshCreds != nil {
228+ if err := b.RefreshCreds(); err != nil {
229+ fmt.Fprintln(stderr(), "telegram: refresh creds:", err)
230+ }
231+ }
232+ b.runTurn(turnCtx, t)
233+ cancel()
234+ }
235+}
236+
237+// runTurn sends the queued prompt to the agent and streams the reply.
238+func (b *Bot) runTurn(ctx context.Context, t queuedTurn) {
239+ stopTyping := b.startTyping(ctx, t.chatID)
240+ defer stopTyping()
241+
242+ var replyBuilder strings.Builder
243+ var lastAssistantText string
244+ var turnErr error
245+
246+ sink := func(ev core.AgentEvent) {
247+ switch e := ev.(type) {
248+ case core.EvTextDelta:
249+ replyBuilder.WriteString(e.Delta)
250+ case core.EvAssistantMessage:
251+ var sb strings.Builder
252+ for _, c := range e.Message.Content {
253+ if tb, ok := c.(provider.TextBlock); ok {
254+ if sb.Len() > 0 {
255+ sb.WriteString("\n")
256+ }
257+ sb.WriteString(tb.Text)
258+ }
259+ }
260+ if sb.Len() > 0 {
261+ lastAssistantText = sb.String()
262+ }
263+ replyBuilder.Reset()
264+ case core.EvTurnEnd:
265+ if e.Err != nil {
266+ turnErr = e.Err
267+ }
268+ }
269+ }
270+
271+ if err := b.Agent.Prompt(ctx, t.prompt, t.images, sink); err != nil {
272+ turnErr = err
273+ }
274+
275+ reply := strings.TrimSpace(lastAssistantText)
276+ if reply == "" {
277+ reply = strings.TrimSpace(replyBuilder.String())
278+ }
279+ if turnErr != nil && ctx.Err() == nil {
280+ reply = "error: " + turnErr.Error()
281+ }
282+ if reply == "" {
283+ reply = "(no reply)"
284+ }
285+ // Telegram caps messages at 4096 chars. Chunk to be safe.
286+ for _, chunk := range chunkMessage(reply, 4000) {
287+ if err := b.Client.SendMessage(context.Background(), t.chatID, chunk, 0); err != nil {
288+ fmt.Fprintln(stderr(), "telegram: sendMessage:", err)
289+ break
290+ }
291+ }
292+}
293+
294+// startTyping keeps Telegram's "typing..." indicator alive until the
295+// returned stop function is called.
296+func (b *Bot) startTyping(ctx context.Context, chatID int64) func() {
297+ tctx, cancel := context.WithCancel(ctx)
298+ go func() {
299+ for {
300+ _ = b.Client.SendChatAction(tctx, chatID, "typing")
301+ select {
302+ case <-tctx.Done():
303+ return
304+ case <-time.After(4 * time.Second):
305+ }
306+ }
307+ }()
308+ return cancel
309+}
310+
311+// sendStatus describes agent state to the Telegram user.
312+func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error {
313+ b.mu.Lock()
314+ busy := b.busy
315+ queued := len(b.queue)
316+ b.mu.Unlock()
317+
318+ state := "idle"
319+ if busy {
320+ state = "working"
321+ }
322+ lines := []string{
323+ fmt.Sprintf("state: %s", state),
324+ fmt.Sprintf("queued: %d", queued),
325+ fmt.Sprintf("model: %s", b.Agent.Model),
326+ }
327+ cost := b.Agent.Cost()
328+ lines = append(lines, fmt.Sprintf("cost: $%.4f (%d in / %d out)",
329+ cost.CostUSD, cost.InputTokens, cost.OutputTokens))
330+ return b.Client.SendMessage(ctx, chatID, strings.Join(lines, "\n"), replyTo)
331+}
332+
333+// download fetches a file from Telegram and returns bytes + mime.
334+func (b *Bot) download(ctx context.Context, fileID, mime string) ([]byte, string, error) {
335+ f, err := b.Client.GetFile(ctx, fileID)
336+ if err != nil {
337+ return nil, "", err
338+ }
339+ data, err := b.Client.DownloadFile(ctx, f.FilePath)
340+ if err != nil {
341+ return nil, "", err
342+ }
343+ if mime == "" {
344+ mime = guessImageMIME(f.FilePath)
345+ }
346+ return data, mime, nil
347+}
348+
349+// chunkMessage splits s into chunks no larger than limit runes, on line
350+// boundaries when possible.
351+func chunkMessage(s string, limit int) []string {
352+ if len(s) <= limit {
353+ return []string{s}
354+ }
355+ var out []string
356+ lines := strings.Split(s, "\n")
357+ var cur strings.Builder
358+ for _, l := range lines {
359+ if cur.Len()+len(l)+1 > limit && cur.Len() > 0 {
360+ out = append(out, cur.String())
361+ cur.Reset()
362+ }
363+ if len(l) > limit {
364+ // Line itself too long; hard-split.
365+ for len(l) > limit {
366+ out = append(out, l[:limit])
367+ l = l[limit:]
368+ }
369+ }
370+ if cur.Len() > 0 {
371+ cur.WriteString("\n")
372+ }
373+ cur.WriteString(l)
374+ }
375+ if cur.Len() > 0 {
376+ out = append(out, cur.String())
377+ }
378+ return out
379+}
380+
381+// isImageMIME returns true for MIME types the model can probably ingest
382+// as a vision input.
383+func isImageMIME(m string) bool {
384+ switch strings.ToLower(m) {
385+ case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp":
386+ return true
387+ }
388+ return false
389+}
390+
391+// guessImageMIME infers a mime type from a filename suffix. Falls back
392+// to image/png because telegram photos are always re-encoded to jpeg
393+// but getFile's file_path may omit the extension.
394+func guessImageMIME(path string) string {
395+ lower := strings.ToLower(path)
396+ switch {
397+ case strings.HasSuffix(lower, ".png"):
398+ return "image/png"
399+ case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
400+ return "image/jpeg"
401+ case strings.HasSuffix(lower, ".gif"):
402+ return "image/gif"
403+ case strings.HasSuffix(lower, ".webp"):
404+ return "image/webp"
405+ }
406+ return "image/jpeg"
407+}
A internal/agent/modes/telegram/config.go +64 -0
@@ -0,0 +1,64 @@
1+// Package telegram implements zot's Telegram bot bridge.
2+//
3+// It runs in-process, polling Telegram for DMs and forwarding them to
4+// a core.Agent. Responses stream back as Telegram messages. Images
5+// sent by the user are downloaded and attached to the next prompt as
6+// provider.ImageBlock, so vision-capable models see them the same way
7+// they would via drag-and-drop in the TUI.
8+//
9+// State (bot token, allowed user id) lives in $ZOT_HOME/bot.json.
10+package telegram
11+
12+import (
13+ "encoding/json"
14+ "errors"
15+ "os"
16+ "path/filepath"
17+)
18+
19+// Config is the on-disk state for the telegram bridge.
20+type Config struct {
21+ BotToken string `json:"bot_token,omitempty"`
22+ BotUsername string `json:"bot_username,omitempty"`
23+ BotID int64 `json:"bot_id,omitempty"`
24+ AllowedUserID int64 `json:"allowed_user_id,omitempty"`
25+ LastUpdateID int64 `json:"last_update_id,omitempty"`
26+}
27+
28+// ConfigPath returns the path to bot.json.
29+func ConfigPath(zotHome string) string {
30+ return filepath.Join(zotHome, "bot.json")
31+}
32+
33+// LoadConfig reads bot.json, returning a zero Config if it doesn't exist.
34+func LoadConfig(zotHome string) (Config, error) {
35+ var c Config
36+ b, err := os.ReadFile(ConfigPath(zotHome))
37+ if errors.Is(err, os.ErrNotExist) {
38+ return c, nil
39+ }
40+ if err != nil {
41+ return c, err
42+ }
43+ if err := json.Unmarshal(b, &c); err != nil {
44+ return c, err
45+ }
46+ return c, nil
47+}
48+
49+// SaveConfig writes bot.json atomically.
50+func SaveConfig(zotHome string, c Config) error {
51+ if err := os.MkdirAll(zotHome, 0o755); err != nil {
52+ return err
53+ }
54+ b, err := json.MarshalIndent(c, "", " ")
55+ if err != nil {
56+ return err
57+ }
58+ path := ConfigPath(zotHome)
59+ tmp := path + ".tmp"
60+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
61+ return err
62+ }
63+ return os.Rename(tmp, path)
64+}
@@ -0,0 +1,64 @@
1+// Package telegram implements zot's Telegram bot bridge.
2+//
3+// It runs in-process, polling Telegram for DMs and forwarding them to
4+// a core.Agent. Responses stream back as Telegram messages. Images
5+// sent by the user are downloaded and attached to the next prompt as
6+// provider.ImageBlock, so vision-capable models see them the same way
7+// they would via drag-and-drop in the TUI.
8+//
9+// State (bot token, allowed user id) lives in $ZOT_HOME/bot.json.
10+package telegram
11+
12+import (
13+ "encoding/json"
14+ "errors"
15+ "os"
16+ "path/filepath"
17+)
18+
19+// Config is the on-disk state for the telegram bridge.
20+type Config struct {
21+ BotToken string `json:"bot_token,omitempty"`
22+ BotUsername string `json:"bot_username,omitempty"`
23+ BotID int64 `json:"bot_id,omitempty"`
24+ AllowedUserID int64 `json:"allowed_user_id,omitempty"`
25+ LastUpdateID int64 `json:"last_update_id,omitempty"`
26+}
27+
28+// ConfigPath returns the path to bot.json.
29+func ConfigPath(zotHome string) string {
30+ return filepath.Join(zotHome, "bot.json")
31+}
32+
33+// LoadConfig reads bot.json, returning a zero Config if it doesn't exist.
34+func LoadConfig(zotHome string) (Config, error) {
35+ var c Config
36+ b, err := os.ReadFile(ConfigPath(zotHome))
37+ if errors.Is(err, os.ErrNotExist) {
38+ return c, nil
39+ }
40+ if err != nil {
41+ return c, err
42+ }
43+ if err := json.Unmarshal(b, &c); err != nil {
44+ return c, err
45+ }
46+ return c, nil
47+}
48+
49+// SaveConfig writes bot.json atomically.
50+func SaveConfig(zotHome string, c Config) error {
51+ if err := os.MkdirAll(zotHome, 0o755); err != nil {
52+ return err
53+ }
54+ b, err := json.MarshalIndent(c, "", " ")
55+ if err != nil {
56+ return err
57+ }
58+ path := ConfigPath(zotHome)
59+ tmp := path + ".tmp"
60+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
61+ return err
62+ }
63+ return os.Rename(tmp, path)
64+}
A internal/agent/modes/telegram/daemon.go +109 -0
@@ -0,0 +1,109 @@
1+package telegram
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "syscall"
11+ "time"
12+)
13+
14+// PIDPath returns the location of the bot's pid file.
15+func PIDPath(zotHome string) string {
16+ return filepath.Join(zotHome, "bot.pid")
17+}
18+
19+// LogPath returns the location of the bot's log file (stdout+stderr
20+// from a detached `zot bot start`).
21+func LogPath(zotHome string) string {
22+ return filepath.Join(zotHome, "logs", "bot.log")
23+}
24+
25+// WritePID persists pid to bot.pid. Overwrites any existing file.
26+func WritePID(zotHome string, pid int) error {
27+ p := PIDPath(zotHome)
28+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
29+ return err
30+ }
31+ return os.WriteFile(p, []byte(strconv.Itoa(pid)+"\n"), 0o644)
32+}
33+
34+// ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't
35+// exist. Returns an error for any other read/parse failure.
36+func ReadPID(zotHome string) (int, error) {
37+ b, err := os.ReadFile(PIDPath(zotHome))
38+ if errors.Is(err, os.ErrNotExist) {
39+ return 0, nil
40+ }
41+ if err != nil {
42+ return 0, err
43+ }
44+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
45+ if err != nil {
46+ return 0, fmt.Errorf("parse pid: %w", err)
47+ }
48+ return pid, nil
49+}
50+
51+// RemovePID deletes the pid file if it exists.
52+func RemovePID(zotHome string) error {
53+ err := os.Remove(PIDPath(zotHome))
54+ if errors.Is(err, os.ErrNotExist) {
55+ return nil
56+ }
57+ return err
58+}
59+
60+// IsRunning returns (pid, true) if a live process with the recorded
61+// pid exists, or (pid, false) if the pid file points to a dead process.
62+// Stale pid files are left in place; the caller may remove them.
63+func IsRunning(zotHome string) (int, bool, error) {
64+ pid, err := ReadPID(zotHome)
65+ if err != nil {
66+ return 0, false, err
67+ }
68+ if pid <= 0 {
69+ return 0, false, nil
70+ }
71+ proc, err := os.FindProcess(pid)
72+ if err != nil {
73+ return pid, false, nil
74+ }
75+ // signal 0 is POSIX's "does the process exist?" probe. On Windows
76+ // os.Process is always usable and Signal(0) returns nil, so we'd
77+ // miss stale pids; acceptable for the macos/linux-first audience.
78+ if err := proc.Signal(syscall.Signal(0)); err != nil {
79+ if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) {
80+ return pid, false, nil
81+ }
82+ // Other errors (EPERM) mean the process exists but we can't
83+ // inspect it; treat as running.
84+ return pid, true, nil
85+ }
86+ return pid, true, nil
87+}
88+
89+// StopProcess sends SIGTERM to pid and waits up to graceful for it to
90+// exit, then escalates to SIGKILL. Returns nil if the process is gone.
91+func StopProcess(pid int, graceful time.Duration) error {
92+ proc, err := os.FindProcess(pid)
93+ if err != nil {
94+ return err
95+ }
96+ _ = proc.Signal(syscall.SIGTERM)
97+
98+ deadline := time.Now().Add(graceful)
99+ for time.Now().Before(deadline) {
100+ if err := proc.Signal(syscall.Signal(0)); err != nil {
101+ if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) {
102+ return nil
103+ }
104+ }
105+ time.Sleep(100 * time.Millisecond)
106+ }
107+ _ = proc.Kill()
108+ return nil
109+}
@@ -0,0 +1,109 @@
1+package telegram
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "syscall"
11+ "time"
12+)
13+
14+// PIDPath returns the location of the bot's pid file.
15+func PIDPath(zotHome string) string {
16+ return filepath.Join(zotHome, "bot.pid")
17+}
18+
19+// LogPath returns the location of the bot's log file (stdout+stderr
20+// from a detached `zot bot start`).
21+func LogPath(zotHome string) string {
22+ return filepath.Join(zotHome, "logs", "bot.log")
23+}
24+
25+// WritePID persists pid to bot.pid. Overwrites any existing file.
26+func WritePID(zotHome string, pid int) error {
27+ p := PIDPath(zotHome)
28+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
29+ return err
30+ }
31+ return os.WriteFile(p, []byte(strconv.Itoa(pid)+"\n"), 0o644)
32+}
33+
34+// ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't
35+// exist. Returns an error for any other read/parse failure.
36+func ReadPID(zotHome string) (int, error) {
37+ b, err := os.ReadFile(PIDPath(zotHome))
38+ if errors.Is(err, os.ErrNotExist) {
39+ return 0, nil
40+ }
41+ if err != nil {
42+ return 0, err
43+ }
44+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
45+ if err != nil {
46+ return 0, fmt.Errorf("parse pid: %w", err)
47+ }
48+ return pid, nil
49+}
50+
51+// RemovePID deletes the pid file if it exists.
52+func RemovePID(zotHome string) error {
53+ err := os.Remove(PIDPath(zotHome))
54+ if errors.Is(err, os.ErrNotExist) {
55+ return nil
56+ }
57+ return err
58+}
59+
60+// IsRunning returns (pid, true) if a live process with the recorded
61+// pid exists, or (pid, false) if the pid file points to a dead process.
62+// Stale pid files are left in place; the caller may remove them.
63+func IsRunning(zotHome string) (int, bool, error) {
64+ pid, err := ReadPID(zotHome)
65+ if err != nil {
66+ return 0, false, err
67+ }
68+ if pid <= 0 {
69+ return 0, false, nil
70+ }
71+ proc, err := os.FindProcess(pid)
72+ if err != nil {
73+ return pid, false, nil
74+ }
75+ // signal 0 is POSIX's "does the process exist?" probe. On Windows
76+ // os.Process is always usable and Signal(0) returns nil, so we'd
77+ // miss stale pids; acceptable for the macos/linux-first audience.
78+ if err := proc.Signal(syscall.Signal(0)); err != nil {
79+ if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) {
80+ return pid, false, nil
81+ }
82+ // Other errors (EPERM) mean the process exists but we can't
83+ // inspect it; treat as running.
84+ return pid, true, nil
85+ }
86+ return pid, true, nil
87+}
88+
89+// StopProcess sends SIGTERM to pid and waits up to graceful for it to
90+// exit, then escalates to SIGKILL. Returns nil if the process is gone.
91+func StopProcess(pid int, graceful time.Duration) error {
92+ proc, err := os.FindProcess(pid)
93+ if err != nil {
94+ return err
95+ }
96+ _ = proc.Signal(syscall.SIGTERM)
97+
98+ deadline := time.Now().Add(graceful)
99+ for time.Now().Before(deadline) {
100+ if err := proc.Signal(syscall.Signal(0)); err != nil {
101+ if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) {
102+ return nil
103+ }
104+ }
105+ time.Sleep(100 * time.Millisecond)
106+ }
107+ _ = proc.Kill()
108+ return nil
109+}
A internal/agent/modes/telegram/fs.go +10 -0
@@ -0,0 +1,10 @@
1+package telegram
2+
3+import (
4+ "io"
5+ "os"
6+)
7+
8+func defaultOpen(path string) (io.ReadCloser, error) {
9+ return os.Open(path)
10+}
@@ -0,0 +1,10 @@
1+package telegram
2+
3+import (
4+ "io"
5+ "os"
6+)
7+
8+func defaultOpen(path string) (io.ReadCloser, error) {
9+ return os.Open(path)
10+}
A internal/agent/modes/telegram/io.go +9 -0
@@ -0,0 +1,9 @@
1+package telegram
2+
3+import (
4+ "io"
5+ "os"
6+)
7+
8+// stderr is a tiny hook so tests can redirect bot logging.
9+var stderr = func() io.Writer { return os.Stderr }
@@ -0,0 +1,9 @@
1+package telegram
2+
3+import (
4+ "io"
5+ "os"
6+)
7+
8+// stderr is a tiny hook so tests can redirect bot logging.
9+var stderr = func() io.Writer { return os.Stderr }

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