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) 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.OnTranscriptCompacted = func(msgs []provider.Message) {
253 _ = sess.AppendCompaction(msgs)
254 }
255 defer sess.Close()
256 } else {
257 fmt.Fprintln(os.Stderr, "session:", serr)
258 }
259 }
260
261 var runner *bot.Runner
262 runner = bot.NewRunner(adapter, agent, bot.Config{
263 ZutHome: ZutHome(),
264 Provider: resolved.Provider,
265 Model: resolved.Model,
266 AuthMethod: resolved.AuthMethod,
267 CWD: args.CWD,
268 RefreshCreds: func() error {
269 // Re-run the same resolver the tui uses so we pick up
270 // refreshed oauth tokens, re-logins, and model switches.
271 // Only the provider client is swapped — tools, sandbox,
272 // system prompt, and transcript stay with the existing agent.
273 next, err := Resolve(args, true)
274 if err != nil {
275 return err
276 }
277 defer closeResolvedLSP(next)
278 agent.Client = next.NewClient()
279 agent.Model = next.Model
280 runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
281 return nil
282 },
283 })
284
285 // Record our pid so `zut <sub> status` / `zut <sub> stop` can find us,
286 // regardless of whether we were started directly or via `start`.
287 _ = bot.WritePIDFile(spec.pidPath(ZutHome()), os.Getpid())
288 defer bot.RemovePIDFile(spec.pidPath(ZutHome()))
289
290 // Translate sigterm/sigint into a context cancel so the bot's goroutines
291 // and the currently-running turn wind down cleanly.
292 ctx, cancel := context.WithCancel(context.Background())
293 sigc := make(chan os.Signal, 1)
294 signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
295 go func() {
296 <-sigc
297 cancel()
298 }()
299 defer cancel()
300 return runner.Run(ctx)
301}
302
303// openOrCreateSessionForBot reuses the same logic as interactive mode
304// but never prompts (no TTY picker); falls back to latest or new.
305func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, []any, error) {
306 if args.Continue {
307 if latest := core.LatestSession(ZutHome(), args.CWD); latest != "" {
308 s, msgs, err := core.OpenSession(latest)
309 if err != nil {
310 return nil, nil, err
311 }
312 ag.SetMessages(msgs)
313 return s, nil, nil
314 }
315 }
316 s, err := core.NewSession(ZutHome(), args.CWD, r.Provider, r.Model, version)
317 return s, nil, err
318}
319
320// maskToken returns "123456:ABC...xyz" so copies of zut 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(zutHome string) (bool, error) {
349 cfg, err := telegram.LoadConfig(zutHome)
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(zutHome string) (bot.BotAdapter, error) {
360 cfg, err := telegram.LoadConfig(zutHome)
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(zutHome, c) },
368 ), nil
369 },
370 }
371}
372
373// printTelegramBotHelp prints usage for `zut telegram-bot`.
374func printTelegramBotHelp() {
375 fmt.Fprint(os.Stderr, `zut telegram-bot — telegram bridge
376
377usage:
378 zut telegram-bot setup paste a BotFather token, verify, save
379 zut telegram-bot status show bridge config and whether it's running
380 zut telegram-bot run [flags] run in the foreground (ctrl+c to stop)
381 zut telegram-bot start [flags] launch in background, detach, return immediately
382 zut telegram-bot stop sigterm the running background bot, sigkill if needed
383 zut telegram-bot logs [--follow] tail the background bot's log file
384 zut telegram-bot reset forget token + allowed user
385
386setup flow:
387 1. talk to @BotFather on telegram, /newbot, copy the token
388 2. run "zut telegram-bot setup" and paste the token
389 3. run "zut telegram-bot start" (background) or "zut 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 $ZUT_HOME/bot.json # bot token + paired user (mode 0600)
399 $ZUT_HOME/bot.pid # pid of the running bot (written by run/start)
400 $ZUT_HOME/logs/bot.log # stdout+stderr from "zut 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(ZutHome())
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(ZutHome(), cfg); err != nil {
456 return err
457 }
458 fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZutHome()))
459 fmt.Println("next: run `zut 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(ZutHome())
467 if err != nil {
468 return err
469 }
470 if cfg.BotToken == "" {
471 fmt.Println("telegram: not configured (run `zut 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(ZutHome()))
484
485 pid, alive, _ := telegram.IsRunning(ZutHome())
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(ZutHome()))
491 default:
492 fmt.Println("process: stopped")
493 }
494 logPath := telegram.LogPath(ZutHome())
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(ZutHome())
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}