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