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