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