v0.2.36 5a4e492

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

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