detached 2e83129

refactor: make bot daemon CLI spec-driven (telegram as first spec)

commit
2e83129dbd1befe6892935ab845e052ebd1dab4e
author
mi-skam · · 2026-07-06 07:57 UTC
parent
5f6a3d0
signature
SSH UnverifiedSHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0w The key is unknown
3 files · +274 -182
M packages/agent/botcmd.go +209 -182
@@ -27,17 +27,16 @@ import (
2727 // inherited). See botcmd_unix.go and botcmd_windows.go.
2828 var detachChild func(cmd *exec.Cmd)
2929
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.
30+// runBotCommand dispatches `zot <protocol>-bot ...` subcommands via
31+// the botSpec registry. Returns true if rawArgs begins with a
32+// recognised subcommand, false otherwise. The short alias "tg" (and
33+// "mx" for matrix) are also accepted.
3334 func runBotCommand(rawArgs []string, version string) (handled bool, err error) {
3435 if len(rawArgs) == 0 {
3536 return false, nil
3637 }
37- switch rawArgs[0] {
38- case "telegram-bot", "tg":
39- // recognised
40- default:
38+ spec := specFor(rawArgs[0])
39+ if spec == nil {
4140 return false, nil
4241 }
4342 sub := ""
@@ -48,164 +47,43 @@ func runBotCommand(rawArgs []string, version string) (handled bool, err error) {
4847 }
4948 switch sub {
5049 case "", "help", "-h", "--help":
51- printBotHelp()
50+ spec.printHelp()
5251 return true, nil
5352 case "setup":
54- return true, botSetup(tail)
53+ return true, spec.setup(tail)
5554 case "status":
56- return true, botStatus()
55+ return true, spec.status()
5756 case "reset":
58- return true, botReset()
57+ return true, spec.reset()
5958 case "run":
60- return true, botRun(tail, version)
59+ return true, botRun(spec, tail, version)
6160 case "start":
62- return true, botStart(tail)
61+ return true, botStart(spec, tail)
6362 case "stop":
64- return true, botStop()
63+ return true, botStop(spec)
6564 case "logs":
66- return true, botLogs(tail)
65+ return true, botLogs(spec, tail)
6766 default:
68- printBotHelp()
69- return true, fmt.Errorf("unknown bot subcommand %q", sub)
67+ spec.printHelp()
68+ return true, fmt.Errorf("unknown %s subcommand %q", spec.subcommand, sub)
7069 }
7170 }
7271
73-// printBotHelp prints usage for `zot bot`.
74-func printBotHelp() {
75- fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge
76-
77-usage:
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-
86-setup 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-
92-while the bot is running, dm it anything and the message is forwarded
93-to the agent the same way it would be from the tui. image attachments
94-(photos or image/* documents) are passed to vision-capable models.
95-telegram commands the bot handles directly: /help, /status, /stop.
96-
97-config & 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.
105-func 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.
143-func 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.
180-func 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.
196-func botStart(rawTail []string) error {
72+// botStart launches `zot <spec.subcommand> run` as a detached child
73+// process, writes its pid to the spec's pid file, and returns
74+// immediately. Stdout/stderr of the child are redirected to the spec's
75+// log file.
76+func botStart(spec *botSpec, rawTail []string) error {
19777 // 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)
78+ if pid, alive, _ := bot.IsRunningAt(spec.pidPath(ZotHome())); alive {
79+ return fmt.Errorf("%s is already running (pid %d); use `zot %s stop` first", spec.name, pid, spec.subcommand)
20080 }
201- _ = telegram.RemovePID(ZotHome()) // clear any stale pid file
81+ _ = bot.RemovePIDFile(spec.pidPath(ZotHome())) // clear any stale pid file
20282
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")
83+ if ok, cerr := spec.configured(ZotHome()); cerr != nil {
84+ return cerr
85+ } else if !ok {
86+ return fmt.Errorf("%s is not configured — run `zot %s setup` first", spec.name, spec.subcommand)
20987 }
21088
21189 self, err := os.Executable()
@@ -213,7 +91,7 @@ func botStart(rawTail []string) error {
21391 return fmt.Errorf("locate zot binary: %w", err)
21492 }
21593
216- logPath := telegram.LogPath(ZotHome())
94+ logPath := spec.logPath(ZotHome())
21795 if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
21896 return err
21997 }
@@ -231,9 +109,9 @@ func botStart(rawTail []string) error {
231109 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)
232110 }
233111
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...)
112+ // Child argv: same flags the user passed to `zot <sub> start`,
113+ // mapped to `zot <sub> run`. Preserves --provider, --model, --cwd, etc.
114+ args := append([]string{spec.subcommand, "run"}, rawTail...)
237115 cmd := exec.Command(self, args...)
238116 cmd.Stdout = logFile
239117 cmd.Stderr = logFile
@@ -246,51 +124,51 @@ func botStart(rawTail []string) error {
246124 if err := cmd.Start(); err != nil {
247125 return fmt.Errorf("spawn: %w", err)
248126 }
249- if err := telegram.WritePID(ZotHome(), cmd.Process.Pid); err != nil {
127+ if err := bot.WritePIDFile(spec.pidPath(ZotHome()), cmd.Process.Pid); err != nil {
250128 _ = cmd.Process.Kill()
251129 return fmt.Errorf("write pid: %w", err)
252130 }
253131 // Don't wait() — detach. OS will reparent the child to init when we exit.
254132 go func() { _ = cmd.Process.Release() }()
255133
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.")
134+ fmt.Printf("started zot %s as pid %d (logs: %s)\n", spec.subcommand, cmd.Process.Pid, logPath)
135+ fmt.Printf("use `zot %s logs -f` to tail, `zot %s stop` to stop.\n", spec.subcommand, spec.subcommand)
258136 return nil
259137 }
260138
261139 // botStop sends SIGTERM to the running bot (SIGKILL if it doesn't
262140 // exit within 5s) and cleans up the pid file.
263-func botStop() error {
264- pid, alive, err := telegram.IsRunning(ZotHome())
141+func botStop(spec *botSpec) error {
142+ pid, alive, err := bot.IsRunningAt(spec.pidPath(ZotHome()))
265143 if err != nil {
266144 return err
267145 }
268146 if !alive {
269147 if pid > 0 {
270- _ = telegram.RemovePID(ZotHome())
148+ _ = bot.RemovePIDFile(spec.pidPath(ZotHome()))
271149 fmt.Printf("no live process; cleared stale pid %d\n", pid)
272150 return nil
273151 }
274- fmt.Println("bot is not running")
152+ fmt.Printf("%s is not running\n", spec.name)
275153 return nil
276154 }
277- if err := telegram.StopProcess(pid, 5*time.Second); err != nil {
155+ if err := bot.StopProcess(pid, 5*time.Second); err != nil {
278156 return fmt.Errorf("stop pid %d: %w", pid, err)
279157 }
280- _ = telegram.RemovePID(ZotHome())
158+ _ = bot.RemovePIDFile(spec.pidPath(ZotHome()))
281159 fmt.Printf("stopped pid %d\n", pid)
282160 return nil
283161 }
284162
285163 // botLogs prints (or tails with --follow) the bot log file.
286-func botLogs(rawTail []string) error {
164+func botLogs(spec *botSpec, rawTail []string) error {
287165 follow := false
288166 for _, a := range rawTail {
289167 if a == "-f" || a == "--follow" {
290168 follow = true
291169 }
292170 }
293- p := telegram.LogPath(ZotHome())
171+ p := spec.logPath(ZotHome())
294172 f, err := os.Open(p)
295173 if errors.Is(err, os.ErrNotExist) {
296174 fmt.Println("no log file at", p)
@@ -325,7 +203,7 @@ func botLogs(rawTail []string) error {
325203 }
326204
327205 // botRun starts the polling loop in the foreground. Ctrl+C stops it.
328-func botRun(rawTail []string, version string) error {
206+func botRun(spec *botSpec, rawTail []string, version string) error {
329207 // Parse only a small subset of flags relevant to bot run. We reuse
330208 // the main args parser so --provider/--model/--cwd/--api-key/--reasoning
331209 // behave the same as in the tui.
@@ -340,13 +218,15 @@ func botRun(rawTail []string, version string) error {
340218 return err
341219 }
342220
343- cfg, err := telegram.LoadConfig(ZotHome())
221+ if ok, cerr := spec.configured(ZotHome()); cerr != nil {
222+ return cerr
223+ } else if !ok {
224+ return fmt.Errorf("%s is not configured — run `zot %s setup` first", spec.name, spec.subcommand)
225+ }
226+ adapter, err := spec.newAdapter(ZotHome())
344227 if err != nil {
345228 return err
346229 }
347- if cfg.BotToken == "" {
348- return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first")
349- }
350230
351231 agent := resolved.NewAgent()
352232
@@ -372,14 +252,6 @@ func botRun(rawTail []string, version string) error {
372252 }
373253 }
374254
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- )
383255 var runner *bot.Runner
384256 runner = bot.NewRunner(adapter, agent, bot.Config{
385257 ZotHome: ZotHome(),
@@ -403,10 +275,10 @@ func botRun(rawTail []string, version string) error {
403275 },
404276 })
405277
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())
278+ // Record our pid so `zot <sub> status` / `zot <sub> stop` can find us,
279+ // regardless of whether we were started directly or via `start`.
280+ _ = bot.WritePIDFile(spec.pidPath(ZotHome()), os.Getpid())
281+ defer bot.RemovePIDFile(spec.pidPath(ZotHome()))
410282
411283 // Translate sigterm/sigint into a context cancel so the bot's goroutines
412284 // and the currently-running turn wind down cleanly.
@@ -455,3 +327,158 @@ func maskToken(tok string) string {
455327 }
456328 return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]
457329 }
330+
331+// telegramSpec wires the existing Telegram setup/status/reset flows
332+// into the generic dispatcher. Bodies are the pre-refactor botSetup,
333+// botStatus, botReset, printBotHelp — unchanged.
334+func telegramSpec() *botSpec {
335+ return &botSpec{
336+ name: "telegram",
337+ subcommand: "telegram-bot",
338+ aliases: []string{"tg"},
339+ pidPath: telegram.PIDPath,
340+ logPath: telegram.LogPath,
341+ configured: func(zotHome string) (bool, error) {
342+ cfg, err := telegram.LoadConfig(zotHome)
343+ if err != nil {
344+ return false, err
345+ }
346+ return cfg.BotToken != "", nil
347+ },
348+ printHelp: printTelegramBotHelp,
349+ setup: telegramBotSetup,
350+ status: telegramBotStatus,
351+ reset: telegramBotReset,
352+ newAdapter: func(zotHome string) (bot.BotAdapter, error) {
353+ cfg, err := telegram.LoadConfig(zotHome)
354+ if err != nil {
355+ return nil, err
356+ }
357+ return telegram.NewAdapter(
358+ telegram.NewClient(cfg.BotToken),
359+ &cfg,
360+ func(c telegram.Config) error { return telegram.SaveConfig(zotHome, c) },
361+ ), nil
362+ },
363+ }
364+}
365+
366+// printTelegramBotHelp prints usage for `zot telegram-bot`.
367+func printTelegramBotHelp() {
368+ fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge
369+
370+usage:
371+ zot telegram-bot setup paste a BotFather token, verify, save
372+ zot telegram-bot status show bridge config and whether it's running
373+ zot telegram-bot run [flags] run in the foreground (ctrl+c to stop)
374+ zot telegram-bot start [flags] launch in background, detach, return immediately
375+ zot telegram-bot stop sigterm the running background bot, sigkill if needed
376+ zot telegram-bot logs [--follow] tail the background bot's log file
377+ zot telegram-bot reset forget token + allowed user
378+
379+setup flow:
380+ 1. talk to @BotFather on telegram, /newbot, copy the token
381+ 2. run "zot telegram-bot setup" and paste the token
382+ 3. run "zot telegram-bot start" (background) or "zot telegram-bot run" (foreground)
383+ 4. send /start to your bot from telegram; the first sender claims it
384+
385+while the bot is running, dm it anything and the message is forwarded
386+to the agent the same way it would be from the tui. image attachments
387+(photos or image/* documents) are passed to vision-capable models.
388+telegram commands the bot handles directly: /help, /status, /stop.
389+
390+config & state:
391+ $ZOT_HOME/bot.json # bot token + paired user (mode 0600)
392+ $ZOT_HOME/bot.pid # pid of the running bot (written by run/start)
393+ $ZOT_HOME/logs/bot.log # stdout+stderr from "zot telegram-bot start"
394+`)
395+}
396+
397+// telegramBotSetup interactively reads a bot token, verifies it via getMe, and saves it.
398+func telegramBotSetup(_ []string) error {
399+ cfg, err := telegram.LoadConfig(ZotHome())
400+ if err != nil {
401+ return err
402+ }
403+
404+ fmt.Print("telegram bot token (from @BotFather): ")
405+ reader := bufio.NewReader(os.Stdin)
406+ line, err := reader.ReadString('\n')
407+ if err != nil {
408+ return err
409+ }
410+ token := strings.TrimSpace(line)
411+ if token == "" {
412+ return fmt.Errorf("no token provided")
413+ }
414+
415+ client := telegram.NewClient(token)
416+ me, err := client.GetMe(context.Background())
417+ if err != nil {
418+ return fmt.Errorf("token rejected by telegram: %w", err)
419+ }
420+ cfg.BotToken = token
421+ cfg.BotUsername = me.Username
422+ cfg.BotID = me.ID
423+ // Any stored pairing might be for a different bot; clear it.
424+ cfg.AllowedUserID = 0
425+ cfg.LastUpdateID = 0
426+ if err := telegram.SaveConfig(ZotHome(), cfg); err != nil {
427+ return err
428+ }
429+ fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZotHome()))
430+ fmt.Println("next: run `zot telegram-bot run`, then send /start to your bot from telegram.")
431+ return nil
432+}
433+
434+// telegramBotStatus prints the current bot config without the token, plus
435+// whether the background process is alive.
436+func telegramBotStatus() error {
437+ cfg, err := telegram.LoadConfig(ZotHome())
438+ if err != nil {
439+ return err
440+ }
441+ if cfg.BotToken == "" {
442+ fmt.Println("telegram: not configured (run `zot telegram-bot setup`)")
443+ return nil
444+ }
445+ maskedTok := maskToken(cfg.BotToken)
446+ fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID)
447+ fmt.Printf("token: %s\n", maskedTok)
448+ if cfg.AllowedUserID == 0 {
449+ fmt.Println("paired with: (unpaired — send /start from telegram to claim)")
450+ } else {
451+ fmt.Printf("paired with: telegram user id %d\n", cfg.AllowedUserID)
452+ }
453+ fmt.Printf("last update: %d\n", cfg.LastUpdateID)
454+ fmt.Printf("config file: %s\n", telegram.ConfigPath(ZotHome()))
455+
456+ pid, alive, _ := telegram.IsRunning(ZotHome())
457+ switch {
458+ case alive:
459+ fmt.Printf("process: running (pid %d)\n", pid)
460+ case pid > 0:
461+ fmt.Printf("process: stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZotHome()))
462+ default:
463+ fmt.Println("process: stopped")
464+ }
465+ logPath := telegram.LogPath(ZotHome())
466+ if fi, err := os.Stat(logPath); err == nil {
467+ fmt.Printf("log file: %s (%d bytes)\n", logPath, fi.Size())
468+ }
469+ return nil
470+}
471+
472+// telegramBotReset wipes the on-disk bot.json entry.
473+func telegramBotReset() error {
474+ p := telegram.ConfigPath(ZotHome())
475+ if _, err := os.Stat(p); os.IsNotExist(err) {
476+ fmt.Println("no bot config to reset")
477+ return nil
478+ }
479+ if err := os.Remove(p); err != nil {
480+ return err
481+ }
482+ fmt.Println("removed", p)
483+ return nil
484+}
@@ -27,17 +27,16 @@ import (
27 // inherited). See botcmd_unix.go and botcmd_windows.go.27 // inherited). See botcmd_unix.go and botcmd_windows.go.
28 var detachChild func(cmd *exec.Cmd)28 var detachChild func(cmd *exec.Cmd)
29 29
30-// runBotCommand dispatches `zot telegram-bot ...` subcommands. The30+// runBotCommand dispatches `zot <protocol>-bot ...` subcommands via
31-// short alias "tg" is also accepted. Returns true if rawArgs begins31+// the botSpec registry. Returns true if rawArgs begins with a
32-// with a recognised subcommand, false otherwise.32+// recognised subcommand, false otherwise. The short alias "tg" (and
33+// "mx" for matrix) are also accepted.
33 func runBotCommand(rawArgs []string, version string) (handled bool, err error) {34 func runBotCommand(rawArgs []string, version string) (handled bool, err error) {
34 if len(rawArgs) == 0 {35 if len(rawArgs) == 0 {
35 return false, nil36 return false, nil
36 }37 }
37- switch rawArgs[0] {38+ spec := specFor(rawArgs[0])
38- case "telegram-bot", "tg":39+ if spec == nil {
39- // recognised
40- default:
41 return false, nil40 return false, nil
42 }41 }
43 sub := ""42 sub := ""
@@ -48,164 +47,43 @@ func runBotCommand(rawArgs []string, version string) (handled bool, err error) {
48 }47 }
49 switch sub {48 switch sub {
50 case "", "help", "-h", "--help":49 case "", "help", "-h", "--help":
51- printBotHelp()50+ spec.printHelp()
52 return true, nil51 return true, nil
53 case "setup":52 case "setup":
54- return true, botSetup(tail)53+ return true, spec.setup(tail)
55 case "status":54 case "status":
56- return true, botStatus()55+ return true, spec.status()
57 case "reset":56 case "reset":
58- return true, botReset()57+ return true, spec.reset()
59 case "run":58 case "run":
60- return true, botRun(tail, version)59+ return true, botRun(spec, tail, version)
61 case "start":60 case "start":
62- return true, botStart(tail)61+ return true, botStart(spec, tail)
63 case "stop":62 case "stop":
64- return true, botStop()63+ return true, botStop(spec)
65 case "logs":64 case "logs":
66- return true, botLogs(tail)65+ return true, botLogs(spec, tail)
67 default:66 default:
68- printBotHelp()67+ spec.printHelp()
69- return true, fmt.Errorf("unknown bot subcommand %q", sub)68+ return true, fmt.Errorf("unknown %s subcommand %q", spec.subcommand, sub)
70 }69 }
71 }70 }
72 71
73-// printBotHelp prints usage for `zot bot`.72+// botStart launches `zot <spec.subcommand> run` as a detached child
74-func printBotHelp() {73+// process, writes its pid to the spec's pid file, and returns
75- fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge74+// immediately. Stdout/stderr of the child are redirected to the spec's
76-75+// log file.
77-usage:76+func botStart(spec *botSpec, rawTail []string) error {
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-
86-setup 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-
92-while the bot is running, dm it anything and the message is forwarded
93-to the agent the same way it would be from the tui. image attachments
94-(photos or image/* documents) are passed to vision-capable models.
95-telegram commands the bot handles directly: /help, /status, /stop.
96-
97-config & 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.
105-func 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.
143-func 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.
180-func 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.
196-func botStart(rawTail []string) error {
197 // Refuse to start if another bot is already running.77 // Refuse to start if another bot is already running.
198- if pid, alive, _ := telegram.IsRunning(ZotHome()); alive {78+ if pid, alive, _ := bot.IsRunningAt(spec.pidPath(ZotHome())); alive {
199- return fmt.Errorf("bot is already running (pid %d); use `zot telegram-bot stop` first", pid)79+ return fmt.Errorf("%s is already running (pid %d); use `zot %s stop` first", spec.name, pid, spec.subcommand)
200 }80 }
201- _ = telegram.RemovePID(ZotHome()) // clear any stale pid file81+ _ = bot.RemovePIDFile(spec.pidPath(ZotHome())) // clear any stale pid file
202 82
203- cfg, err := telegram.LoadConfig(ZotHome())83+ if ok, cerr := spec.configured(ZotHome()); cerr != nil {
204- if err != nil {84+ return cerr
205- return err85+ } else if !ok {
206- }86+ return fmt.Errorf("%s is not configured — run `zot %s setup` first", spec.name, spec.subcommand)
207- if cfg.BotToken == "" {
208- return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first")
209 }87 }
210 88
211 self, err := os.Executable()89 self, err := os.Executable()
@@ -213,7 +91,7 @@ func botStart(rawTail []string) error {
213 return fmt.Errorf("locate zot binary: %w", err)91 return fmt.Errorf("locate zot binary: %w", err)
214 }92 }
215 93
216- logPath := telegram.LogPath(ZotHome())94+ logPath := spec.logPath(ZotHome())
217 if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {95 if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
218 return err96 return err
219 }97 }
@@ -231,9 +109,9 @@ func botStart(rawTail []string) error {
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)109 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 }110 }
233 111
234- // Child argv: same flags the user passed to `zot telegram-bot start`,112+ // Child argv: same flags the user passed to `zot <sub> start`,
235- // mapped to `zot telegram-bot run`. Preserves --provider, --model, --cwd, etc.113+ // mapped to `zot <sub> run`. Preserves --provider, --model, --cwd, etc.
236- args := append([]string{"telegram-bot", "run"}, rawTail...)114+ args := append([]string{spec.subcommand, "run"}, rawTail...)
237 cmd := exec.Command(self, args...)115 cmd := exec.Command(self, args...)
238 cmd.Stdout = logFile116 cmd.Stdout = logFile
239 cmd.Stderr = logFile117 cmd.Stderr = logFile
@@ -246,51 +124,51 @@ func botStart(rawTail []string) error {
246 if err := cmd.Start(); err != nil {124 if err := cmd.Start(); err != nil {
247 return fmt.Errorf("spawn: %w", err)125 return fmt.Errorf("spawn: %w", err)
248 }126 }
249- if err := telegram.WritePID(ZotHome(), cmd.Process.Pid); err != nil {127+ if err := bot.WritePIDFile(spec.pidPath(ZotHome()), cmd.Process.Pid); err != nil {
250 _ = cmd.Process.Kill()128 _ = cmd.Process.Kill()
251 return fmt.Errorf("write pid: %w", err)129 return fmt.Errorf("write pid: %w", err)
252 }130 }
253 // Don't wait() — detach. OS will reparent the child to init when we exit.131 // Don't wait() — detach. OS will reparent the child to init when we exit.
254 go func() { _ = cmd.Process.Release() }()132 go func() { _ = cmd.Process.Release() }()
255 133
256- fmt.Printf("started zot telegram-bot as pid %d (logs: %s)\n", cmd.Process.Pid, logPath)134+ fmt.Printf("started zot %s as pid %d (logs: %s)\n", spec.subcommand, cmd.Process.Pid, logPath)
257- fmt.Println("use `zot telegram-bot logs -f` to tail, `zot telegram-bot stop` to stop.")135+ fmt.Printf("use `zot %s logs -f` to tail, `zot %s stop` to stop.\n", spec.subcommand, spec.subcommand)
258 return nil136 return nil
259 }137 }
260 138
261 // botStop sends SIGTERM to the running bot (SIGKILL if it doesn't139 // botStop sends SIGTERM to the running bot (SIGKILL if it doesn't
262 // exit within 5s) and cleans up the pid file.140 // exit within 5s) and cleans up the pid file.
263-func botStop() error {141+func botStop(spec *botSpec) error {
264- pid, alive, err := telegram.IsRunning(ZotHome())142+ pid, alive, err := bot.IsRunningAt(spec.pidPath(ZotHome()))
265 if err != nil {143 if err != nil {
266 return err144 return err
267 }145 }
268 if !alive {146 if !alive {
269 if pid > 0 {147 if pid > 0 {
270- _ = telegram.RemovePID(ZotHome())148+ _ = bot.RemovePIDFile(spec.pidPath(ZotHome()))
271 fmt.Printf("no live process; cleared stale pid %d\n", pid)149 fmt.Printf("no live process; cleared stale pid %d\n", pid)
272 return nil150 return nil
273 }151 }
274- fmt.Println("bot is not running")152+ fmt.Printf("%s is not running\n", spec.name)
275 return nil153 return nil
276 }154 }
277- if err := telegram.StopProcess(pid, 5*time.Second); err != nil {155+ if err := bot.StopProcess(pid, 5*time.Second); err != nil {
278 return fmt.Errorf("stop pid %d: %w", pid, err)156 return fmt.Errorf("stop pid %d: %w", pid, err)
279 }157 }
280- _ = telegram.RemovePID(ZotHome())158+ _ = bot.RemovePIDFile(spec.pidPath(ZotHome()))
281 fmt.Printf("stopped pid %d\n", pid)159 fmt.Printf("stopped pid %d\n", pid)
282 return nil160 return nil
283 }161 }
284 162
285 // botLogs prints (or tails with --follow) the bot log file.163 // botLogs prints (or tails with --follow) the bot log file.
286-func botLogs(rawTail []string) error {164+func botLogs(spec *botSpec, rawTail []string) error {
287 follow := false165 follow := false
288 for _, a := range rawTail {166 for _, a := range rawTail {
289 if a == "-f" || a == "--follow" {167 if a == "-f" || a == "--follow" {
290 follow = true168 follow = true
291 }169 }
292 }170 }
293- p := telegram.LogPath(ZotHome())171+ p := spec.logPath(ZotHome())
294 f, err := os.Open(p)172 f, err := os.Open(p)
295 if errors.Is(err, os.ErrNotExist) {173 if errors.Is(err, os.ErrNotExist) {
296 fmt.Println("no log file at", p)174 fmt.Println("no log file at", p)
@@ -325,7 +203,7 @@ func botLogs(rawTail []string) error {
325 }203 }
326 204
327 // botRun starts the polling loop in the foreground. Ctrl+C stops it.205 // botRun starts the polling loop in the foreground. Ctrl+C stops it.
328-func botRun(rawTail []string, version string) error {206+func botRun(spec *botSpec, rawTail []string, version string) error {
329 // Parse only a small subset of flags relevant to bot run. We reuse207 // Parse only a small subset of flags relevant to bot run. We reuse
330 // the main args parser so --provider/--model/--cwd/--api-key/--reasoning208 // the main args parser so --provider/--model/--cwd/--api-key/--reasoning
331 // behave the same as in the tui.209 // behave the same as in the tui.
@@ -340,13 +218,15 @@ func botRun(rawTail []string, version string) error {
340 return err218 return err
341 }219 }
342 220
343- cfg, err := telegram.LoadConfig(ZotHome())221+ if ok, cerr := spec.configured(ZotHome()); cerr != nil {
222+ return cerr
223+ } else if !ok {
224+ return fmt.Errorf("%s is not configured — run `zot %s setup` first", spec.name, spec.subcommand)
225+ }
226+ adapter, err := spec.newAdapter(ZotHome())
344 if err != nil {227 if err != nil {
345 return err228 return err
346 }229 }
347- if cfg.BotToken == "" {
348- return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first")
349- }
350 230
351 agent := resolved.NewAgent()231 agent := resolved.NewAgent()
352 232
@@ -372,14 +252,6 @@ func botRun(rawTail []string, version string) error {
372 }252 }
373 }253 }
374 254
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.Runner255 var runner *bot.Runner
384 runner = bot.NewRunner(adapter, agent, bot.Config{256 runner = bot.NewRunner(adapter, agent, bot.Config{
385 ZotHome: ZotHome(),257 ZotHome: ZotHome(),
@@ -403,10 +275,10 @@ func botRun(rawTail []string, version string) error {
403 },275 },
404 })276 })
405 277
406- // Record our pid so `zot telegram-bot status` / `zot telegram-bot stop` can find us,278+ // Record our pid so `zot <sub> status` / `zot <sub> stop` can find us,
407- // regardless of whether we were started directly or via `bot start`.279+ // regardless of whether we were started directly or via `start`.
408- _ = telegram.WritePID(ZotHome(), os.Getpid())280+ _ = bot.WritePIDFile(spec.pidPath(ZotHome()), os.Getpid())
409- defer telegram.RemovePID(ZotHome())281+ defer bot.RemovePIDFile(spec.pidPath(ZotHome()))
410 282
411 // Translate sigterm/sigint into a context cancel so the bot's goroutines283 // Translate sigterm/sigint into a context cancel so the bot's goroutines
412 // and the currently-running turn wind down cleanly.284 // and the currently-running turn wind down cleanly.
@@ -455,3 +327,158 @@ func maskToken(tok string) string {
455 }327 }
456 return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]328 return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]
457 }329 }
330+
331+// telegramSpec wires the existing Telegram setup/status/reset flows
332+// into the generic dispatcher. Bodies are the pre-refactor botSetup,
333+// botStatus, botReset, printBotHelp — unchanged.
334+func telegramSpec() *botSpec {
335+ return &botSpec{
336+ name: "telegram",
337+ subcommand: "telegram-bot",
338+ aliases: []string{"tg"},
339+ pidPath: telegram.PIDPath,
340+ logPath: telegram.LogPath,
341+ configured: func(zotHome string) (bool, error) {
342+ cfg, err := telegram.LoadConfig(zotHome)
343+ if err != nil {
344+ return false, err
345+ }
346+ return cfg.BotToken != "", nil
347+ },
348+ printHelp: printTelegramBotHelp,
349+ setup: telegramBotSetup,
350+ status: telegramBotStatus,
351+ reset: telegramBotReset,
352+ newAdapter: func(zotHome string) (bot.BotAdapter, error) {
353+ cfg, err := telegram.LoadConfig(zotHome)
354+ if err != nil {
355+ return nil, err
356+ }
357+ return telegram.NewAdapter(
358+ telegram.NewClient(cfg.BotToken),
359+ &cfg,
360+ func(c telegram.Config) error { return telegram.SaveConfig(zotHome, c) },
361+ ), nil
362+ },
363+ }
364+}
365+
366+// printTelegramBotHelp prints usage for `zot telegram-bot`.
367+func printTelegramBotHelp() {
368+ fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge
369+
370+usage:
371+ zot telegram-bot setup paste a BotFather token, verify, save
372+ zot telegram-bot status show bridge config and whether it's running
373+ zot telegram-bot run [flags] run in the foreground (ctrl+c to stop)
374+ zot telegram-bot start [flags] launch in background, detach, return immediately
375+ zot telegram-bot stop sigterm the running background bot, sigkill if needed
376+ zot telegram-bot logs [--follow] tail the background bot's log file
377+ zot telegram-bot reset forget token + allowed user
378+
379+setup flow:
380+ 1. talk to @BotFather on telegram, /newbot, copy the token
381+ 2. run "zot telegram-bot setup" and paste the token
382+ 3. run "zot telegram-bot start" (background) or "zot telegram-bot run" (foreground)
383+ 4. send /start to your bot from telegram; the first sender claims it
384+
385+while the bot is running, dm it anything and the message is forwarded
386+to the agent the same way it would be from the tui. image attachments
387+(photos or image/* documents) are passed to vision-capable models.
388+telegram commands the bot handles directly: /help, /status, /stop.
389+
390+config & state:
391+ $ZOT_HOME/bot.json # bot token + paired user (mode 0600)
392+ $ZOT_HOME/bot.pid # pid of the running bot (written by run/start)
393+ $ZOT_HOME/logs/bot.log # stdout+stderr from "zot telegram-bot start"
394+`)
395+}
396+
397+// telegramBotSetup interactively reads a bot token, verifies it via getMe, and saves it.
398+func telegramBotSetup(_ []string) error {
399+ cfg, err := telegram.LoadConfig(ZotHome())
400+ if err != nil {
401+ return err
402+ }
403+
404+ fmt.Print("telegram bot token (from @BotFather): ")
405+ reader := bufio.NewReader(os.Stdin)
406+ line, err := reader.ReadString('\n')
407+ if err != nil {
408+ return err
409+ }
410+ token := strings.TrimSpace(line)
411+ if token == "" {
412+ return fmt.Errorf("no token provided")
413+ }
414+
415+ client := telegram.NewClient(token)
416+ me, err := client.GetMe(context.Background())
417+ if err != nil {
418+ return fmt.Errorf("token rejected by telegram: %w", err)
419+ }
420+ cfg.BotToken = token
421+ cfg.BotUsername = me.Username
422+ cfg.BotID = me.ID
423+ // Any stored pairing might be for a different bot; clear it.
424+ cfg.AllowedUserID = 0
425+ cfg.LastUpdateID = 0
426+ if err := telegram.SaveConfig(ZotHome(), cfg); err != nil {
427+ return err
428+ }
429+ fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZotHome()))
430+ fmt.Println("next: run `zot telegram-bot run`, then send /start to your bot from telegram.")
431+ return nil
432+}
433+
434+// telegramBotStatus prints the current bot config without the token, plus
435+// whether the background process is alive.
436+func telegramBotStatus() error {
437+ cfg, err := telegram.LoadConfig(ZotHome())
438+ if err != nil {
439+ return err
440+ }
441+ if cfg.BotToken == "" {
442+ fmt.Println("telegram: not configured (run `zot telegram-bot setup`)")
443+ return nil
444+ }
445+ maskedTok := maskToken(cfg.BotToken)
446+ fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID)
447+ fmt.Printf("token: %s\n", maskedTok)
448+ if cfg.AllowedUserID == 0 {
449+ fmt.Println("paired with: (unpaired — send /start from telegram to claim)")
450+ } else {
451+ fmt.Printf("paired with: telegram user id %d\n", cfg.AllowedUserID)
452+ }
453+ fmt.Printf("last update: %d\n", cfg.LastUpdateID)
454+ fmt.Printf("config file: %s\n", telegram.ConfigPath(ZotHome()))
455+
456+ pid, alive, _ := telegram.IsRunning(ZotHome())
457+ switch {
458+ case alive:
459+ fmt.Printf("process: running (pid %d)\n", pid)
460+ case pid > 0:
461+ fmt.Printf("process: stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZotHome()))
462+ default:
463+ fmt.Println("process: stopped")
464+ }
465+ logPath := telegram.LogPath(ZotHome())
466+ if fi, err := os.Stat(logPath); err == nil {
467+ fmt.Printf("log file: %s (%d bytes)\n", logPath, fi.Size())
468+ }
469+ return nil
470+}
471+
472+// telegramBotReset wipes the on-disk bot.json entry.
473+func telegramBotReset() error {
474+ p := telegram.ConfigPath(ZotHome())
475+ if _, err := os.Stat(p); os.IsNotExist(err) {
476+ fmt.Println("no bot config to reset")
477+ return nil
478+ }
479+ if err := os.Remove(p); err != nil {
480+ return err
481+ }
482+ fmt.Println("removed", p)
483+ return nil
484+}
A packages/agent/botspec.go +50 -0
@@ -0,0 +1,50 @@
1+package agent
2+
3+import (
4+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
5+)
6+
7+// botSpec describes one bot protocol to the generic daemon CLI in
8+// botcmd.go. Everything protocol-specific (setup flow, status text,
9+// adapter construction, state files) hangs off this struct; the
10+// subcommand plumbing (run/start/stop/logs) is shared.
11+type botSpec struct {
12+ name string // "telegram", "matrix" — used in messages
13+ subcommand string // "telegram-bot", "matrix-bot"
14+ aliases []string // {"tg"}, {"mx"}
15+
16+ pidPath func(zotHome string) string
17+ logPath func(zotHome string) string
18+
19+ // configured reports whether setup has been completed (e.g. a
20+ // token is present), with a hint error message when it hasn't.
21+ configured func(zotHome string) (bool, error)
22+
23+ printHelp func()
24+ setup func(tail []string) error
25+ status func() error
26+ reset func() error
27+
28+ // newAdapter builds the protocol adapter for the standalone
29+ // daemon. Credential refresh stays generic in botRun.
30+ newAdapter func(zotHome string) (bot.BotAdapter, error)
31+}
32+
33+// botSpecs is the registry the dispatcher walks. Matrix is appended
34+// in matrixcmd.go's init.
35+var botSpecs = []*botSpec{telegramSpec()}
36+
37+// specFor returns the spec matching subcommand or one of its aliases.
38+func specFor(subcommand string) *botSpec {
39+ for _, s := range botSpecs {
40+ if s.subcommand == subcommand {
41+ return s
42+ }
43+ for _, a := range s.aliases {
44+ if a == subcommand {
45+ return s
46+ }
47+ }
48+ }
49+ return nil
50+}
@@ -0,0 +1,50 @@
1+package agent
2+
3+import (
4+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
5+)
6+
7+// botSpec describes one bot protocol to the generic daemon CLI in
8+// botcmd.go. Everything protocol-specific (setup flow, status text,
9+// adapter construction, state files) hangs off this struct; the
10+// subcommand plumbing (run/start/stop/logs) is shared.
11+type botSpec struct {
12+ name string // "telegram", "matrix" — used in messages
13+ subcommand string // "telegram-bot", "matrix-bot"
14+ aliases []string // {"tg"}, {"mx"}
15+
16+ pidPath func(zotHome string) string
17+ logPath func(zotHome string) string
18+
19+ // configured reports whether setup has been completed (e.g. a
20+ // token is present), with a hint error message when it hasn't.
21+ configured func(zotHome string) (bool, error)
22+
23+ printHelp func()
24+ setup func(tail []string) error
25+ status func() error
26+ reset func() error
27+
28+ // newAdapter builds the protocol adapter for the standalone
29+ // daemon. Credential refresh stays generic in botRun.
30+ newAdapter func(zotHome string) (bot.BotAdapter, error)
31+}
32+
33+// botSpecs is the registry the dispatcher walks. Matrix is appended
34+// in matrixcmd.go's init.
35+var botSpecs = []*botSpec{telegramSpec()}
36+
37+// specFor returns the spec matching subcommand or one of its aliases.
38+func specFor(subcommand string) *botSpec {
39+ for _, s := range botSpecs {
40+ if s.subcommand == subcommand {
41+ return s
42+ }
43+ for _, a := range s.aliases {
44+ if a == subcommand {
45+ return s
46+ }
47+ }
48+ }
49+ return nil
50+}
A packages/agent/botspec_test.go +15 -0
@@ -0,0 +1,15 @@
1+package agent
2+
3+import "testing"
4+
5+func TestSpecForSubcommand(t *testing.T) {
6+ if s := specFor("telegram-bot"); s == nil || s.name != "telegram" {
7+ t.Fatalf("telegram-bot not matched: %#v", s)
8+ }
9+ if s := specFor("tg"); s == nil || s.name != "telegram" {
10+ t.Fatal("alias tg not matched")
11+ }
12+ if specFor("nonsense") != nil {
13+ t.Fatal("unknown subcommand must not match")
14+ }
15+}
@@ -0,0 +1,15 @@
1+package agent
2+
3+import "testing"
4+
5+func TestSpecForSubcommand(t *testing.T) {
6+ if s := specFor("telegram-bot"); s == nil || s.name != "telegram" {
7+ t.Fatalf("telegram-bot not matched: %#v", s)
8+ }
9+ if s := specFor("tg"); s == nil || s.name != "telegram" {
10+ t.Fatal("alias tg not matched")
11+ }
12+ if specFor("nonsense") != nil {
13+ t.Fatal("unknown subcommand must not match")
14+ }
15+}

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 477

↑↓ moveenter openesc close