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