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 reportSkillDiagnostics(os.Stderr, resolved.SkillDiagnostics)
222
223 if ok, cerr := spec.configured(ZotHome()); cerr != nil {
224 return cerr
225 } else if !ok {
226 return fmt.Errorf("%s is not configured — run `zot %s setup` first", spec.name, spec.subcommand)
227 }
228 adapter, err := spec.newAdapter(ZotHome())
229 if err != nil {
230 return err
231 }
232
233 agent := resolved.NewAgent()
234
235 // Session: optional, same model as the tui. Persist so DMs build on
236 // prior context. --no-session disables.
237 var sess *core.Session
238 if !args.NoSess {
239 s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version)
240 if serr == nil {
241 sess = s
242 agent.OnMessageAppended = func(msg provider.Message) {
243 _ = sess.AppendMessage(msg)
244 }
245 agent.OnUsage = func(u provider.Usage) {
246 _ = sess.AppendUsage(u, u)
247 }
248 agent.OnTranscriptCompacted = func(msgs []provider.Message) {
249 _ = sess.AppendCompaction(msgs)
250 }
251 defer sess.Close()
252 } else {
253 fmt.Fprintln(os.Stderr, "session:", serr)
254 }
255 }
256
257 var runner *bot.Runner
258 runner = bot.NewRunner(adapter, agent, bot.Config{
259 ZotHome: ZotHome(),
260 Provider: resolved.Provider,
261 Model: resolved.Model,
262 AuthMethod: resolved.AuthMethod,
263 CWD: args.CWD,
264 RefreshCreds: func() error {
265 // Re-run the same resolver the tui uses so we pick up
266 // refreshed oauth tokens, re-logins, and model switches.
267 // Only the provider client is swapped — tools, sandbox,
268 // system prompt, and transcript stay with the existing agent.
269 next, err := Resolve(args, true)
270 if err != nil {
271 return err
272 }
273 agent.Client = next.NewClient()
274 agent.Model = next.Model
275 runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
276 return nil
277 },
278 })
279
280 // Record our pid so `zot <sub> status` / `zot <sub> stop` can find us,
281 // regardless of whether we were started directly or via `start`.
282 _ = bot.WritePIDFile(spec.pidPath(ZotHome()), os.Getpid())
283 defer bot.RemovePIDFile(spec.pidPath(ZotHome()))
284
285 // Translate sigterm/sigint into a context cancel so the bot's goroutines
286 // and the currently-running turn wind down cleanly.
287 ctx, cancel := context.WithCancel(context.Background())
288 sigc := make(chan os.Signal, 1)
289 signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
290 go func() {
291 <-sigc
292 cancel()
293 }()
294 defer cancel()
295 return runner.Run(ctx)
296}
297
298// openOrCreateSessionForBot reuses the same logic as interactive mode
299// but never prompts (no TTY picker); falls back to latest or new.
300func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, []any, error) {
301 if args.Continue {
302 if latest := core.LatestSession(ZotHome(), args.CWD); latest != "" {
303 s, msgs, err := core.OpenSession(latest)
304 if err != nil {
305 return nil, nil, err
306 }
307 ag.SetMessages(msgs)
308 bindAgentSession(ag, s)
309 return s, nil, nil
310 }
311 }
312 s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
313 if err == nil {
314 bindAgentSession(ag, s)
315 }
316 return s, nil, err
317}
318
319// maskToken returns "123456:ABC...xyz" so copies of zot telegram-bot status can be
320// pasted into bug reports without leaking the full token.
321func maskToken(tok string) string {
322 if len(tok) <= 10 {
323 return "<hidden>"
324 }
325 // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body.
326 i := strings.IndexByte(tok, ':')
327 if i < 0 {
328 return tok[:4] + "..." + tok[len(tok)-4:]
329 }
330 body := tok[i+1:]
331 if len(body) < 8 {
332 return tok[:i+1] + "<hidden>"
333 }
334 return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]
335}
336
337// telegramSpec wires the existing Telegram setup/status/reset flows
338// into the generic dispatcher. Bodies are the pre-refactor botSetup,
339// botStatus, botReset, printBotHelp — unchanged.
340func telegramSpec() *botSpec {
341 return &botSpec{
342 name: "telegram",
343 subcommand: "telegram-bot",
344 aliases: []string{"tg"},
345 pidPath: telegram.PIDPath,
346 logPath: telegram.LogPath,
347 configured: func(zotHome string) (bool, error) {
348 cfg, err := telegram.LoadConfig(zotHome)
349 if err != nil {
350 return false, err
351 }
352 return cfg.BotToken != "", nil
353 },
354 printHelp: printTelegramBotHelp,
355 setup: telegramBotSetup,
356 status: telegramBotStatus,
357 reset: telegramBotReset,
358 newAdapter: func(zotHome string) (bot.BotAdapter, error) {
359 cfg, err := telegram.LoadConfig(zotHome)
360 if err != nil {
361 return nil, err
362 }
363 return telegram.NewAdapter(
364 telegram.NewClient(cfg.BotToken),
365 &cfg,
366 func(c telegram.Config) error { return telegram.SaveConfig(zotHome, c) },
367 ), nil
368 },
369 }
370}
371
372// printTelegramBotHelp prints usage for `zot telegram-bot`.
373func printTelegramBotHelp() {
374 fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge
375
376usage:
377 zot telegram-bot setup paste a BotFather token, verify, save
378 zot telegram-bot status show bridge config and whether it's running
379 zot telegram-bot run [flags] run in the foreground (ctrl+c to stop)
380 zot telegram-bot start [flags] launch in background, detach, return immediately
381 zot telegram-bot stop sigterm the running background bot, sigkill if needed
382 zot telegram-bot logs [--follow] tail the background bot's log file
383 zot telegram-bot reset forget token + allowed user
384
385setup flow:
386 1. talk to @BotFather on telegram, /newbot, copy the token
387 2. run "zot telegram-bot setup" and paste the token
388 3. run "zot telegram-bot start" (background) or "zot telegram-bot run" (foreground)
389 4. send /start to your bot from telegram; the first sender claims it
390
391while the bot is running, dm it anything and the message is forwarded
392to the agent the same way it would be from the tui. image attachments
393(photos or image/* documents) are passed to vision-capable models.
394telegram commands the bot handles directly: /help, /status, /stop.
395
396config & state:
397 $ZOT_HOME/bot.json # bot token + paired user (mode 0600)
398 $ZOT_HOME/bot.pid # pid of the running bot (written by run/start)
399 $ZOT_HOME/logs/bot.log # stdout+stderr from "zot telegram-bot start"
400`)
401}
402
403var (
404 botTokenIsTerminal = term.IsTerminal
405 botTokenReadPassword = term.ReadPassword
406)
407
408func readBotToken(in *os.File, out io.Writer) (string, error) {
409 if botTokenIsTerminal(int(in.Fd())) {
410 value, err := botTokenReadPassword(int(in.Fd()))
411 fmt.Fprintln(out)
412 if err != nil {
413 return "", fmt.Errorf("read telegram bot token: %w", err)
414 }
415 return string(value), nil
416 }
417
418 value, err := bufio.NewReader(in).ReadString('\n')
419 if err != nil && !errors.Is(err, io.EOF) {
420 return "", fmt.Errorf("read telegram bot token: %w", err)
421 }
422 return value, nil
423}
424
425// telegramBotSetup interactively reads a bot token without echoing it on a
426// terminal, verifies it via getMe, and saves it.
427func telegramBotSetup(_ []string) error {
428 cfg, err := telegram.LoadConfig(ZotHome())
429 if err != nil {
430 return err
431 }
432
433 fmt.Print("telegram bot token (from @BotFather): ")
434 line, err := readBotToken(os.Stdin, os.Stdout)
435 if err != nil {
436 return err
437 }
438 token := strings.TrimSpace(line)
439 if token == "" {
440 return fmt.Errorf("no token provided")
441 }
442
443 client := telegram.NewClient(token)
444 me, err := client.GetMe(context.Background())
445 if err != nil {
446 return fmt.Errorf("token rejected by telegram: %w", err)
447 }
448 cfg.BotToken = token
449 cfg.BotUsername = me.Username
450 cfg.BotID = me.ID
451 // Any stored pairing might be for a different bot; clear it.
452 cfg.AllowedUserID = 0
453 cfg.LastUpdateID = 0
454 if err := telegram.SaveConfig(ZotHome(), cfg); err != nil {
455 return err
456 }
457 fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZotHome()))
458 fmt.Println("next: run `zot telegram-bot run`, then send /start to your bot from telegram.")
459 return nil
460}
461
462// telegramBotStatus prints the current bot config without the token, plus
463// whether the background process is alive.
464func telegramBotStatus() error {
465 cfg, err := telegram.LoadConfig(ZotHome())
466 if err != nil {
467 return err
468 }
469 if cfg.BotToken == "" {
470 fmt.Println("telegram: not configured (run `zot telegram-bot setup`)")
471 return nil
472 }
473 maskedTok := maskToken(cfg.BotToken)
474 fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID)
475 fmt.Printf("token: %s\n", maskedTok)
476 if cfg.AllowedUserID == 0 {
477 fmt.Println("paired with: (unpaired — send /start from telegram to claim)")
478 } else {
479 fmt.Printf("paired with: telegram user id %d\n", cfg.AllowedUserID)
480 }
481 fmt.Printf("last update: %d\n", cfg.LastUpdateID)
482 fmt.Printf("config file: %s\n", telegram.ConfigPath(ZotHome()))
483
484 pid, alive, _ := telegram.IsRunning(ZotHome())
485 switch {
486 case alive:
487 fmt.Printf("process: running (pid %d)\n", pid)
488 case pid > 0:
489 fmt.Printf("process: stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZotHome()))
490 default:
491 fmt.Println("process: stopped")
492 }
493 logPath := telegram.LogPath(ZotHome())
494 if fi, err := os.Stat(logPath); err == nil {
495 fmt.Printf("log file: %s (%d bytes)\n", logPath, fi.Size())
496 }
497 return nil
498}
499
500// telegramBotReset wipes the on-disk bot.json entry.
501func telegramBotReset() error {
502 p := telegram.ConfigPath(ZotHome())
503 if _, err := os.Stat(p); os.IsNotExist(err) {
504 fmt.Println("no bot config to reset")
505 return nil
506 }
507 if err := os.Remove(p); err != nil {
508 return err
509 }
510 fmt.Println("removed", p)
511 return nil
512}