v0.2.60 3cd4df6

botcmd.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close