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 bindAgentSession(ag, s)
308 return s, nil, nil
309 }
310 }
311 s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
312 if err == nil {
313 bindAgentSession(ag, s)
314 }
315 return s, nil, err
316}
317
318// maskToken returns "123456:ABC...xyz" so copies of zot telegram-bot status can be
319// pasted into bug reports without leaking the full token.
320func maskToken(tok string) string {
321 if len(tok) <= 10 {
322 return "<hidden>"
323 }
324 // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body.
325 i := strings.IndexByte(tok, ':')
326 if i < 0 {
327 return tok[:4] + "..." + tok[len(tok)-4:]
328 }
329 body := tok[i+1:]
330 if len(body) < 8 {
331 return tok[:i+1] + "<hidden>"
332 }
333 return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]
334}
335
336// telegramSpec wires the existing Telegram setup/status/reset flows
337// into the generic dispatcher. Bodies are the pre-refactor botSetup,
338// botStatus, botReset, printBotHelp — unchanged.
339func telegramSpec() *botSpec {
340 return &botSpec{
341 name: "telegram",
342 subcommand: "telegram-bot",
343 aliases: []string{"tg"},
344 pidPath: telegram.PIDPath,
345 logPath: telegram.LogPath,
346 configured: func(zotHome string) (bool, error) {
347 cfg, err := telegram.LoadConfig(zotHome)
348 if err != nil {
349 return false, err
350 }
351 return cfg.BotToken != "", nil
352 },
353 printHelp: printTelegramBotHelp,
354 setup: telegramBotSetup,
355 status: telegramBotStatus,
356 reset: telegramBotReset,
357 newAdapter: func(zotHome string) (bot.BotAdapter, error) {
358 cfg, err := telegram.LoadConfig(zotHome)
359 if err != nil {
360 return nil, err
361 }
362 return telegram.NewAdapter(
363 telegram.NewClient(cfg.BotToken),
364 &cfg,
365 func(c telegram.Config) error { return telegram.SaveConfig(zotHome, c) },
366 ), nil
367 },
368 }
369}
370
371// printTelegramBotHelp prints usage for `zot telegram-bot`.
372func printTelegramBotHelp() {
373 fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge
374
375usage:
376 zot telegram-bot setup paste a BotFather token, verify, save
377 zot telegram-bot status show bridge config and whether it's running
378 zot telegram-bot run [flags] run in the foreground (ctrl+c to stop)
379 zot telegram-bot start [flags] launch in background, detach, return immediately
380 zot telegram-bot stop sigterm the running background bot, sigkill if needed
381 zot telegram-bot logs [--follow] tail the background bot's log file
382 zot telegram-bot reset forget token + allowed user
383
384setup flow:
385 1. talk to @BotFather on telegram, /newbot, copy the token
386 2. run "zot telegram-bot setup" and paste the token
387 3. run "zot telegram-bot start" (background) or "zot telegram-bot run" (foreground)
388 4. send /start to your bot from telegram; the first sender claims it
389
390while the bot is running, dm it anything and the message is forwarded
391to the agent the same way it would be from the tui. image attachments
392(photos or image/* documents) are passed to vision-capable models.
393telegram commands the bot handles directly: /help, /status, /stop.
394
395config & state:
396 $ZOT_HOME/bot.json # bot token + paired user (mode 0600)
397 $ZOT_HOME/bot.pid # pid of the running bot (written by run/start)
398 $ZOT_HOME/logs/bot.log # stdout+stderr from "zot telegram-bot start"
399`)
400}
401
402var (
403 botTokenIsTerminal = term.IsTerminal
404 botTokenReadPassword = term.ReadPassword
405)
406
407func readBotToken(in *os.File, out io.Writer) (string, error) {
408 if botTokenIsTerminal(int(in.Fd())) {
409 value, err := botTokenReadPassword(int(in.Fd()))
410 fmt.Fprintln(out)
411 if err != nil {
412 return "", fmt.Errorf("read telegram bot token: %w", err)
413 }
414 return string(value), nil
415 }
416
417 value, err := bufio.NewReader(in).ReadString('\n')
418 if err != nil && !errors.Is(err, io.EOF) {
419 return "", fmt.Errorf("read telegram bot token: %w", err)
420 }
421 return value, nil
422}
423
424// telegramBotSetup interactively reads a bot token without echoing it on a
425// terminal, verifies it via getMe, and saves it.
426func telegramBotSetup(_ []string) error {
427 cfg, err := telegram.LoadConfig(ZotHome())
428 if err != nil {
429 return err
430 }
431
432 fmt.Print("telegram bot token (from @BotFather): ")
433 line, err := readBotToken(os.Stdin, os.Stdout)
434 if err != nil {
435 return err
436 }
437 token := strings.TrimSpace(line)
438 if token == "" {
439 return fmt.Errorf("no token provided")
440 }
441
442 client := telegram.NewClient(token)
443 me, err := client.GetMe(context.Background())
444 if err != nil {
445 return fmt.Errorf("token rejected by telegram: %w", err)
446 }
447 cfg.BotToken = token
448 cfg.BotUsername = me.Username
449 cfg.BotID = me.ID
450 // Any stored pairing might be for a different bot; clear it.
451 cfg.AllowedUserID = 0
452 cfg.LastUpdateID = 0
453 if err := telegram.SaveConfig(ZotHome(), cfg); err != nil {
454 return err
455 }
456 fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZotHome()))
457 fmt.Println("next: run `zot telegram-bot run`, then send /start to your bot from telegram.")
458 return nil
459}
460
461// telegramBotStatus prints the current bot config without the token, plus
462// whether the background process is alive.
463func telegramBotStatus() error {
464 cfg, err := telegram.LoadConfig(ZotHome())
465 if err != nil {
466 return err
467 }
468 if cfg.BotToken == "" {
469 fmt.Println("telegram: not configured (run `zot telegram-bot setup`)")
470 return nil
471 }
472 maskedTok := maskToken(cfg.BotToken)
473 fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID)
474 fmt.Printf("token: %s\n", maskedTok)
475 if cfg.AllowedUserID == 0 {
476 fmt.Println("paired with: (unpaired — send /start from telegram to claim)")
477 } else {
478 fmt.Printf("paired with: telegram user id %d\n", cfg.AllowedUserID)
479 }
480 fmt.Printf("last update: %d\n", cfg.LastUpdateID)
481 fmt.Printf("config file: %s\n", telegram.ConfigPath(ZotHome()))
482
483 pid, alive, _ := telegram.IsRunning(ZotHome())
484 switch {
485 case alive:
486 fmt.Printf("process: running (pid %d)\n", pid)
487 case pid > 0:
488 fmt.Printf("process: stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZotHome()))
489 default:
490 fmt.Println("process: stopped")
491 }
492 logPath := telegram.LogPath(ZotHome())
493 if fi, err := os.Stat(logPath); err == nil {
494 fmt.Printf("log file: %s (%d bytes)\n", logPath, fi.Size())
495 }
496 return nil
497}
498
499// telegramBotReset wipes the on-disk bot.json entry.
500func telegramBotReset() error {
501 p := telegram.ConfigPath(ZotHome())
502 if _, err := os.Stat(p); os.IsNotExist(err) {
503 fmt.Println("no bot config to reset")
504 return nil
505 }
506 if err := os.Remove(p); err != nil {
507 return err
508 }
509 fmt.Println("removed", p)
510 return nil
511}