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