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