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