1package agent
2
3import (
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// detachChild configures cmd to run in its own process group so tty
23// signals sent to the parent (SIGINT, SIGHUP on logout) don't also
24// reach the detached bot. Platform-specific: setsid on unix, a noop
25// on windows (Go's spawn path already detaches when no console is
26// inherited). See botcmd_unix.go and botcmd_windows.go.
27var detachChild func(cmd *exec.Cmd)
28
29// runBotCommand dispatches `zot telegram-bot ...` subcommands. The
30// short alias "tg" is also accepted. Returns true if rawArgs begins
31// with a recognised subcommand, false otherwise.
32func runBotCommand(rawArgs []string, version string) (handled bool, err error) {
33 if len(rawArgs) == 0 {
34 return false, nil
35 }
36 switch rawArgs[0] {
37 case "telegram-bot", "tg":
38 // recognised
39 default:
40 return false, nil
41 }
42 sub := ""
43 var tail []string
44 if len(rawArgs) >= 2 {
45 sub = rawArgs[1]
46 tail = rawArgs[2:]
47 }
48 switch sub {
49 case "", "help", "-h", "--help":
50 printBotHelp()
51 return true, nil
52 case "setup":
53 return true, botSetup(tail)
54 case "status":
55 return true, botStatus()
56 case "reset":
57 return true, botReset()
58 case "run":
59 return true, botRun(tail, version)
60 case "start":
61 return true, botStart(tail)
62 case "stop":
63 return true, botStop()
64 case "logs":
65 return true, botLogs(tail)
66 default:
67 printBotHelp()
68 return true, fmt.Errorf("unknown bot subcommand %q", sub)
69 }
70}
71
72// printBotHelp prints usage for `zot bot`.
73func printBotHelp() {
74 fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge
75
76usage:
77 zot telegram-bot setup paste a BotFather token, verify, save
78 zot telegram-bot status show bridge config and whether it's running
79 zot telegram-bot run [flags] run in the foreground (ctrl+c to stop)
80 zot telegram-bot start [flags] launch in background, detach, return immediately
81 zot telegram-bot stop sigterm the running background bot, sigkill if needed
82 zot telegram-bot logs [--follow] tail the background bot's log file
83 zot telegram-bot reset forget token + allowed user
84
85setup flow:
86 1. talk to @BotFather on telegram, /newbot, copy the token
87 2. run "zot telegram-bot setup" and paste the token
88 3. run "zot telegram-bot start" (background) or "zot telegram-bot run" (foreground)
89 4. send /start to your bot from telegram; the first sender claims it
90
91while the bot is running, dm it anything and the message is forwarded
92to the agent the same way it would be from the tui. image attachments
93(photos or image/* documents) are passed to vision-capable models.
94telegram commands the bot handles directly: /help, /status, /stop.
95
96config & state:
97 $ZOT_HOME/bot.json # bot token + paired user (mode 0600)
98 $ZOT_HOME/bot.pid # pid of the running bot (written by run/start)
99 $ZOT_HOME/logs/bot.log # stdout+stderr from "zot telegram-bot start"
100`)
101}
102
103// botSetup interactively reads a bot token, verifies it via getMe, and saves it.
104func botSetup(_ []string) error {
105 cfg, err := telegram.LoadConfig(ZotHome())
106 if err != nil {
107 return err
108 }
109
110 fmt.Print("telegram bot token (from @BotFather): ")
111 reader := bufio.NewReader(os.Stdin)
112 line, err := reader.ReadString('\n')
113 if err != nil {
114 return err
115 }
116 token := strings.TrimSpace(line)
117 if token == "" {
118 return fmt.Errorf("no token provided")
119 }
120
121 client := telegram.NewClient(token)
122 me, err := client.GetMe(context.Background())
123 if err != nil {
124 return fmt.Errorf("token rejected by telegram: %w", err)
125 }
126 cfg.BotToken = token
127 cfg.BotUsername = me.Username
128 cfg.BotID = me.ID
129 // Any stored pairing might be for a different bot; clear it.
130 cfg.AllowedUserID = 0
131 cfg.LastUpdateID = 0
132 if err := telegram.SaveConfig(ZotHome(), cfg); err != nil {
133 return err
134 }
135 fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZotHome()))
136 fmt.Println("next: run `zot telegram-bot run`, then send /start to your bot from telegram.")
137 return nil
138}
139
140// botStatus prints the current bot config without the token, plus
141// whether the background process is alive.
142func botStatus() error {
143 cfg, err := telegram.LoadConfig(ZotHome())
144 if err != nil {
145 return err
146 }
147 if cfg.BotToken == "" {
148 fmt.Println("telegram: not configured (run `zot telegram-bot setup`)")
149 return nil
150 }
151 maskedTok := maskToken(cfg.BotToken)
152 fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID)
153 fmt.Printf("token: %s\n", maskedTok)
154 if cfg.AllowedUserID == 0 {
155 fmt.Println("paired with: (unpaired — send /start from telegram to claim)")
156 } else {
157 fmt.Printf("paired with: telegram user id %d\n", cfg.AllowedUserID)
158 }
159 fmt.Printf("last update: %d\n", cfg.LastUpdateID)
160 fmt.Printf("config file: %s\n", telegram.ConfigPath(ZotHome()))
161
162 pid, alive, _ := telegram.IsRunning(ZotHome())
163 switch {
164 case alive:
165 fmt.Printf("process: running (pid %d)\n", pid)
166 case pid > 0:
167 fmt.Printf("process: stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZotHome()))
168 default:
169 fmt.Println("process: stopped")
170 }
171 logPath := telegram.LogPath(ZotHome())
172 if fi, err := os.Stat(logPath); err == nil {
173 fmt.Printf("log file: %s (%d bytes)\n", logPath, fi.Size())
174 }
175 return nil
176}
177
178// botReset wipes the on-disk bot.json entry.
179func botReset() error {
180 p := telegram.ConfigPath(ZotHome())
181 if _, err := os.Stat(p); os.IsNotExist(err) {
182 fmt.Println("no bot config to reset")
183 return nil
184 }
185 if err := os.Remove(p); err != nil {
186 return err
187 }
188 fmt.Println("removed", p)
189 return nil
190}
191
192// botStart launches `zot telegram-bot run` as a detached child process, writes
193// its pid to $ZOT_HOME/bot.pid, and returns immediately. Stdout/stderr
194// of the child are redirected to $ZOT_HOME/logs/bot.log.
195func botStart(rawTail []string) error {
196 // Refuse to start if another bot is already running.
197 if pid, alive, _ := telegram.IsRunning(ZotHome()); alive {
198 return fmt.Errorf("bot is already running (pid %d); use `zot telegram-bot stop` first", pid)
199 }
200 _ = telegram.RemovePID(ZotHome()) // clear any stale pid file
201
202 cfg, err := telegram.LoadConfig(ZotHome())
203 if err != nil {
204 return err
205 }
206 if cfg.BotToken == "" {
207 return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first")
208 }
209
210 self, err := os.Executable()
211 if err != nil {
212 return fmt.Errorf("locate zot binary: %w", err)
213 }
214
215 logPath := telegram.LogPath(ZotHome())
216 if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
217 return err
218 }
219 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
220 if err != nil {
221 return fmt.Errorf("open log file: %w", err)
222 }
223 defer logFile.Close()
224
225 // Refuse to start from a `go run` temp binary: Go deletes the
226 // binary when `go run` exits, which breaks the detached child.
227 // Users hit cryptic tls / exec errors on that path; fail clearly.
228 if strings.Contains(self, string(os.PathSeparator)+"go-build") ||
229 strings.Contains(self, string(os.PathSeparator)+"go-tmp") {
230 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)
231 }
232
233 // Child argv: same flags the user passed to `zot telegram-bot start`,
234 // mapped to `zot telegram-bot run`. Preserves --provider, --model, --cwd, etc.
235 args := append([]string{"telegram-bot", "run"}, rawTail...)
236 cmd := exec.Command(self, args...)
237 cmd.Stdout = logFile
238 cmd.Stderr = logFile
239 cmd.Stdin = nil
240 // Detach: new session / new process group so terminal signals
241 // don't reach the child. Impl lives in botcmd_unix.go /
242 // botcmd_windows.go because Setsid is posix-only.
243 detachChild(cmd)
244
245 if err := cmd.Start(); err != nil {
246 return fmt.Errorf("spawn: %w", err)
247 }
248 if err := telegram.WritePID(ZotHome(), cmd.Process.Pid); err != nil {
249 _ = cmd.Process.Kill()
250 return fmt.Errorf("write pid: %w", err)
251 }
252 // Don't wait() — detach. OS will reparent the child to init when we exit.
253 go func() { _ = cmd.Process.Release() }()
254
255 fmt.Printf("started zot telegram-bot as pid %d (logs: %s)\n", cmd.Process.Pid, logPath)
256 fmt.Println("use `zot telegram-bot logs -f` to tail, `zot telegram-bot stop` to stop.")
257 return nil
258}
259
260// botStop sends SIGTERM to the running bot (SIGKILL if it doesn't
261// exit within 5s) and cleans up the pid file.
262func botStop() error {
263 pid, alive, err := telegram.IsRunning(ZotHome())
264 if err != nil {
265 return err
266 }
267 if !alive {
268 if pid > 0 {
269 _ = telegram.RemovePID(ZotHome())
270 fmt.Printf("no live process; cleared stale pid %d\n", pid)
271 return nil
272 }
273 fmt.Println("bot is not running")
274 return nil
275 }
276 if err := telegram.StopProcess(pid, 5*time.Second); err != nil {
277 return fmt.Errorf("stop pid %d: %w", pid, err)
278 }
279 _ = telegram.RemovePID(ZotHome())
280 fmt.Printf("stopped pid %d\n", pid)
281 return nil
282}
283
284// botLogs prints (or tails with --follow) the bot log file.
285func botLogs(rawTail []string) error {
286 follow := false
287 for _, a := range rawTail {
288 if a == "-f" || a == "--follow" {
289 follow = true
290 }
291 }
292 p := telegram.LogPath(ZotHome())
293 f, err := os.Open(p)
294 if errors.Is(err, os.ErrNotExist) {
295 fmt.Println("no log file at", p)
296 return nil
297 }
298 if err != nil {
299 return err
300 }
301 defer f.Close()
302
303 if _, err := io.Copy(os.Stdout, f); err != nil {
304 return err
305 }
306 if !follow {
307 return nil
308 }
309
310 // Naive tail -f: poll for new bytes until ctrl+c.
311 sigc := make(chan os.Signal, 1)
312 signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
313 defer signal.Stop(sigc)
314 for {
315 select {
316 case <-sigc:
317 return nil
318 case <-time.After(500 * time.Millisecond):
319 if _, err := io.Copy(os.Stdout, f); err != nil {
320 return err
321 }
322 }
323 }
324}
325
326// botRun starts the polling loop in the foreground. Ctrl+C stops it.
327func botRun(rawTail []string, version string) error {
328 // Parse only a small subset of flags relevant to bot run. We reuse
329 // the main args parser so --provider/--model/--cwd/--api-key/--reasoning
330 // behave the same as in the tui.
331 args, err := ParseArgs(rawTail)
332 if err != nil {
333 return err
334 }
335
336 // Bot mode always requires credentials (can't pop a /login dialog).
337 resolved, err := Resolve(args, true)
338 if err != nil {
339 return err
340 }
341
342 cfg, err := telegram.LoadConfig(ZotHome())
343 if err != nil {
344 return err
345 }
346 if cfg.BotToken == "" {
347 return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first")
348 }
349
350 agent := resolved.NewAgent()
351
352 // Session: optional, same model as the tui. Persist so DMs build on
353 // prior context. --no-session disables.
354 var sess *core.Session
355 if !args.NoSess {
356 s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version)
357 if serr == nil {
358 sess = s
359 defer sess.Close()
360 } else {
361 fmt.Fprintln(os.Stderr, "session:", serr)
362 }
363 }
364
365 b := &telegram.Bot{
366 Client: telegram.NewClient(cfg.BotToken),
367 Agent: agent,
368 Config: cfg,
369 ZotHome: ZotHome(),
370 Save: func(c telegram.Config) error {
371 return telegram.SaveConfig(ZotHome(), c)
372 },
373 RefreshCreds: func() error {
374 // Re-run the same resolver the tui uses so we pick up
375 // refreshed oauth tokens, re-logins, and model switches.
376 // Only the provider client is swapped — tools, sandbox,
377 // system prompt, and transcript stay with the existing agent.
378 next, err := Resolve(args, true)
379 if err != nil {
380 return err
381 }
382 agent.Client = next.NewClient()
383 agent.Model = next.Model
384 return nil
385 },
386 }
387
388 // Record our pid so `zot telegram-bot status` / `zot telegram-bot stop` can find us,
389 // regardless of whether we were started directly or via `bot start`.
390 _ = telegram.WritePID(ZotHome(), os.Getpid())
391 defer telegram.RemovePID(ZotHome())
392
393 // Translate sigterm/sigint into a context cancel so the bot's goroutines
394 // and the currently-running turn wind down cleanly.
395 ctx, cancel := context.WithCancel(context.Background())
396 sigc := make(chan os.Signal, 1)
397 signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
398 go func() {
399 <-sigc
400 cancel()
401 }()
402 defer cancel()
403 return b.Run(ctx)
404}
405
406// openOrCreateSessionForBot reuses the same logic as interactive mode
407// but never prompts (no TTY picker); falls back to latest or new.
408func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, []any, error) {
409 if args.Continue {
410 if latest := core.LatestSession(ZotHome(), args.CWD); latest != "" {
411 s, msgs, err := core.OpenSession(latest)
412 if err != nil {
413 return nil, nil, err
414 }
415 ag.SetMessages(msgs)
416 return s, nil, nil
417 }
418 }
419 s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
420 return s, nil, err
421}
422
423// maskToken returns "123456:ABC…xyz" so copies of zot telegram-bot status can be
424// pasted into bug reports without leaking the full token.
425func maskToken(tok string) string {
426 if len(tok) <= 10 {
427 return "<hidden>"
428 }
429 // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body.
430 i := strings.IndexByte(tok, ':')
431 if i < 0 {
432 return tok[:4] + "…" + tok[len(tok)-4:]
433 }
434 body := tok[i+1:]
435 if len(body) < 8 {
436 return tok[:i+1] + "<hidden>"
437 }
438 return tok[:i+1] + body[:3] + "…" + body[len(body)-3:]
439}
440
441// _ compile-time hint so the strconv import stays if we later add numeric parsing.
442var _ = strconv.Itoa