detached 11b3818

Merge branch 'main' into discuss-extension-submit-frame

commit
11b381879e6cb7c5f8dbcae2681a2b5c87b5c0b1
author
Patric Eckhart · · 2026-07-06 16:15 UTC
committer
GitHub · · 2026-07-06 16:15 UTC
merge of
9bb3540 + 95e29d1 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.64
13 files · +445 -285
M README.md +8 -0
@@ -768,6 +768,12 @@ From then on, any DM you send is forwarded to the agent as a user prompt. Attach
768768
769769 Bot mode respects the usual zot flags: `--provider`, `--model`, `--cwd`, `--reasoning`, `--continue`, `--no-session`, `--no-tools`, and so on. Run `zot tg run -c --model claude-opus-4-1` to resume the latest session on Opus, for example.
770770
771+### Architecture: protocol-agnostic bot core
772+
773+The messenger functionality is split in two layers. A generic, protocol-agnostic core lives in `packages/agent/modes/bot`: it owns the turn queue, agent prompting, built-in command dispatch (`/start`, `/help`, `/status`, `/stop`), status formatting, and per-turn credential refresh. Concrete transports implement the small `BotAdapter` interface (inbound polling, sending replies, a typing indicator, and optional protocol-specific status text); the Telegram support in `packages/agent/modes/telegram` is one such adapter.
774+
775+This means additional messaging backends (Discord, Slack, Signal, and similar) can be added by implementing `BotAdapter` in a new package and wiring up a subcommand. No changes to the runner, agent, or core are required. Channel IDs are opaque strings owned by the adapter, so the shared runner stays free of protocol-specific types.
776+
771777 ## Development
772778
773779 ```bash
@@ -790,6 +796,8 @@ packages/agent/ cli wiring, arg parsing, system prompt, co
790796 packages/agent/extensions/ extension subprocess manager
791797 packages/agent/extproto/ extension wire-format types
792798 packages/agent/modes/ interactive tui, print, json, dialogs
799+packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
800+packages/agent/modes/telegram/ telegram adapter, api client, daemon
793801 packages/agent/tools/ read, write, edit, bash, sandbox
794802 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
795803 packages/agent/swarm/ background subagent runtime
@@ -768,6 +768,12 @@ From then on, any DM you send is forwarded to the agent as a user prompt. Attach
768 768
769 Bot mode respects the usual zot flags: `--provider`, `--model`, `--cwd`, `--reasoning`, `--continue`, `--no-session`, `--no-tools`, and so on. Run `zot tg run -c --model claude-opus-4-1` to resume the latest session on Opus, for example.769 Bot mode respects the usual zot flags: `--provider`, `--model`, `--cwd`, `--reasoning`, `--continue`, `--no-session`, `--no-tools`, and so on. Run `zot tg run -c --model claude-opus-4-1` to resume the latest session on Opus, for example.
770 770
771+### Architecture: protocol-agnostic bot core
772+
773+The messenger functionality is split in two layers. A generic, protocol-agnostic core lives in `packages/agent/modes/bot`: it owns the turn queue, agent prompting, built-in command dispatch (`/start`, `/help`, `/status`, `/stop`), status formatting, and per-turn credential refresh. Concrete transports implement the small `BotAdapter` interface (inbound polling, sending replies, a typing indicator, and optional protocol-specific status text); the Telegram support in `packages/agent/modes/telegram` is one such adapter.
774+
775+This means additional messaging backends (Discord, Slack, Signal, and similar) can be added by implementing `BotAdapter` in a new package and wiring up a subcommand. No changes to the runner, agent, or core are required. Channel IDs are opaque strings owned by the adapter, so the shared runner stays free of protocol-specific types.
776+
771 ## Development777 ## Development
772 778
773 ```bash779 ```bash
@@ -790,6 +796,8 @@ packages/agent/ cli wiring, arg parsing, system prompt, co
790 packages/agent/extensions/ extension subprocess manager796 packages/agent/extensions/ extension subprocess manager
791 packages/agent/extproto/ extension wire-format types797 packages/agent/extproto/ extension wire-format types
792 packages/agent/modes/ interactive tui, print, json, dialogs798 packages/agent/modes/ interactive tui, print, json, dialogs
799+packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
800+packages/agent/modes/telegram/ telegram adapter, api client, daemon
793 packages/agent/tools/ read, write, edit, bash, sandbox801 packages/agent/tools/ read, write, edit, bash, sandbox
794 packages/agent/skills/ skill discovery, frontmatter parser, skill tool802 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
795 packages/agent/swarm/ background subagent runtime803 packages/agent/swarm/ background subagent runtime
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+}
A packages/agent/modes/bot/daemon.go +66 -0
@@ -0,0 +1,66 @@
1+package bot
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "time"
11+)
12+
13+// WritePIDFile persists pid to path. Overwrites any existing file.
14+func WritePIDFile(path string, pid int) error {
15+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
16+ return err
17+ }
18+ return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o644)
19+}
20+
21+// ReadPIDFile returns the pid stored at path, or 0 if the file doesn't exist.
22+func ReadPIDFile(path string) (int, error) {
23+ b, err := os.ReadFile(path)
24+ if errors.Is(err, os.ErrNotExist) {
25+ return 0, nil
26+ }
27+ if err != nil {
28+ return 0, err
29+ }
30+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
31+ if err != nil {
32+ return 0, fmt.Errorf("parse pid: %w", err)
33+ }
34+ return pid, nil
35+}
36+
37+// RemovePIDFile deletes the pid file if it exists.
38+func RemovePIDFile(path string) error {
39+ err := os.Remove(path)
40+ if errors.Is(err, os.ErrNotExist) {
41+ return nil
42+ }
43+ return err
44+}
45+
46+// IsRunningAt reports whether a live process is recorded at pidPath.
47+// Semantics identical to the old telegram.IsRunning.
48+func IsRunningAt(pidPath string) (int, bool, error) {
49+ pid, err := ReadPIDFile(pidPath)
50+ if err != nil {
51+ return 0, false, err
52+ }
53+ if pid <= 0 {
54+ return 0, false, nil
55+ }
56+ alive, err := processAlive(pid)
57+ if err != nil {
58+ return pid, false, nil
59+ }
60+ return pid, alive, nil
61+}
62+
63+// StopProcess asks pid to exit, waits up to graceful, then force-kills.
64+func StopProcess(pid int, graceful time.Duration) error {
65+ return stopProcess(pid, graceful)
66+}
@@ -0,0 +1,66 @@
1+package bot
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "time"
11+)
12+
13+// WritePIDFile persists pid to path. Overwrites any existing file.
14+func WritePIDFile(path string, pid int) error {
15+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
16+ return err
17+ }
18+ return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o644)
19+}
20+
21+// ReadPIDFile returns the pid stored at path, or 0 if the file doesn't exist.
22+func ReadPIDFile(path string) (int, error) {
23+ b, err := os.ReadFile(path)
24+ if errors.Is(err, os.ErrNotExist) {
25+ return 0, nil
26+ }
27+ if err != nil {
28+ return 0, err
29+ }
30+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
31+ if err != nil {
32+ return 0, fmt.Errorf("parse pid: %w", err)
33+ }
34+ return pid, nil
35+}
36+
37+// RemovePIDFile deletes the pid file if it exists.
38+func RemovePIDFile(path string) error {
39+ err := os.Remove(path)
40+ if errors.Is(err, os.ErrNotExist) {
41+ return nil
42+ }
43+ return err
44+}
45+
46+// IsRunningAt reports whether a live process is recorded at pidPath.
47+// Semantics identical to the old telegram.IsRunning.
48+func IsRunningAt(pidPath string) (int, bool, error) {
49+ pid, err := ReadPIDFile(pidPath)
50+ if err != nil {
51+ return 0, false, err
52+ }
53+ if pid <= 0 {
54+ return 0, false, nil
55+ }
56+ alive, err := processAlive(pid)
57+ if err != nil {
58+ return pid, false, nil
59+ }
60+ return pid, alive, nil
61+}
62+
63+// StopProcess asks pid to exit, waits up to graceful, then force-kills.
64+func StopProcess(pid int, graceful time.Duration) error {
65+ return stopProcess(pid, graceful)
66+}
R packages/agent/modes/telegram/process_unix.go → packages/agent/modes/bot/process_unix.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
11 //go:build !windows
22
3-package telegram
3+package bot
44
55 import (
66 "errors"
@@ -1,6 +1,6 @@
1 //go:build !windows1 //go:build !windows
2 2
3-package telegram3+package bot
4 4
5 import (5 import (
6 "errors"6 "errors"
R packages/agent/modes/telegram/process_windows.go → packages/agent/modes/bot/process_windows.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
11 //go:build windows
22
3-package telegram
3+package bot
44
55 import (
66 "errors"
@@ -1,6 +1,6 @@
1 //go:build windows1 //go:build windows
2 2
3-package telegram3+package bot
4 4
5 import (5 import (
6 "errors"6 "errors"
R packages/agent/modes/telegram/bot.go → packages/agent/modes/bot/util.go +8 -8 renamed, 80% similar
@@ -1,13 +1,13 @@
1-package telegram
1+package bot
22
33 import (
44 "strings"
55 "unicode/utf8"
66 )
77
8-// chunkMessage splits s into chunks no larger than limit runes, on line
8+// ChunkMessage splits s into chunks no larger than limit runes, on line
99 // boundaries when possible.
10-func chunkMessage(s string, limit int) []string {
10+func ChunkMessage(s string, limit int) []string {
1111 if limit <= 0 || utf8.RuneCountInString(s) <= limit {
1212 return []string{s}
1313 }
@@ -63,9 +63,9 @@ func byteIndexAfterRunes(s string, n int) int {
6363 return len(s)
6464 }
6565
66-// isImageMIME returns true for MIME types the model can probably ingest
66+// IsImageMIME returns true for MIME types the model can probably ingest
6767 // as a vision input.
68-func isImageMIME(m string) bool {
68+func IsImageMIME(m string) bool {
6969 switch strings.ToLower(m) {
7070 case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp":
7171 return true
@@ -73,10 +73,10 @@ func isImageMIME(m string) bool {
7373 return false
7474 }
7575
76-// guessImageMIME infers a mime type from a filename suffix. Falls back
77-// to image/png because telegram photos are always re-encoded to jpeg
76+// GuessImageMIME infers a mime type from a filename suffix. Falls back
77+// to image/jpeg because telegram photos are always re-encoded to jpeg
7878 // but getFile's file_path may omit the extension.
79-func guessImageMIME(path string) string {
79+func GuessImageMIME(path string) string {
8080 lower := strings.ToLower(path)
8181 switch {
8282 case strings.HasSuffix(lower, ".png"):
@@ -1,13 +1,13 @@
1-package telegram1+package bot
2 2
3 import (3 import (
4 "strings"4 "strings"
5 "unicode/utf8"5 "unicode/utf8"
6 )6 )
7 7
8-// chunkMessage splits s into chunks no larger than limit runes, on line8+// ChunkMessage splits s into chunks no larger than limit runes, on line
9 // boundaries when possible.9 // boundaries when possible.
10-func chunkMessage(s string, limit int) []string {10+func ChunkMessage(s string, limit int) []string {
11 if limit <= 0 || utf8.RuneCountInString(s) <= limit {11 if limit <= 0 || utf8.RuneCountInString(s) <= limit {
12 return []string{s}12 return []string{s}
13 }13 }
@@ -63,9 +63,9 @@ func byteIndexAfterRunes(s string, n int) int {
63 return len(s)63 return len(s)
64 }64 }
65 65
66-// isImageMIME returns true for MIME types the model can probably ingest66+// IsImageMIME returns true for MIME types the model can probably ingest
67 // as a vision input.67 // as a vision input.
68-func isImageMIME(m string) bool {68+func IsImageMIME(m string) bool {
69 switch strings.ToLower(m) {69 switch strings.ToLower(m) {
70 case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp":70 case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp":
71 return true71 return true
@@ -73,10 +73,10 @@ func isImageMIME(m string) bool {
73 return false73 return false
74 }74 }
75 75
76-// guessImageMIME infers a mime type from a filename suffix. Falls back76+// GuessImageMIME infers a mime type from a filename suffix. Falls back
77-// to image/png because telegram photos are always re-encoded to jpeg77+// to image/jpeg because telegram photos are always re-encoded to jpeg
78 // but getFile's file_path may omit the extension.78 // but getFile's file_path may omit the extension.
79-func guessImageMIME(path string) string {79+func GuessImageMIME(path string) string {
80 lower := strings.ToLower(path)80 lower := strings.ToLower(path)
81 switch {81 switch {
82 case strings.HasSuffix(lower, ".png"):82 case strings.HasSuffix(lower, ".png"):
A packages/agent/modes/bot/util_test.go +71 -0
@@ -0,0 +1,71 @@
1+package bot
2+
3+import (
4+ "strings"
5+ "testing"
6+ "unicode/utf8"
7+)
8+
9+func TestChunkMessageShort(t *testing.T) {
10+ got := ChunkMessage("hello", 10)
11+ if len(got) != 1 || got[0] != "hello" {
12+ t.Fatalf("got %#v", got)
13+ }
14+}
15+
16+func TestChunkMessageSplitsOnLines(t *testing.T) {
17+ in := strings.Repeat("aaaa\n", 5) + "bbbb"
18+ for _, c := range ChunkMessage(in, 10) {
19+ if utf8.RuneCountInString(c) > 10 {
20+ t.Fatalf("chunk too long: %q", c)
21+ }
22+ }
23+}
24+
25+func TestChunkMessageHardSplitsLongLine(t *testing.T) {
26+ in := strings.Repeat("x", 25)
27+ got := ChunkMessage(in, 10)
28+ if len(got) != 3 {
29+ t.Fatalf("want 3 chunks, got %d: %#v", len(got), got)
30+ }
31+}
32+
33+// Ported from telegram/bot_test.go to preserve rune-boundary coverage.
34+func TestChunkMessageUsesRuneLimit(t *testing.T) {
35+ s := strings.Repeat("界", 4001)
36+ chunks := ChunkMessage(s, 4000)
37+ if len(chunks) != 2 {
38+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
39+ }
40+ if got := utf8.RuneCountInString(chunks[0]); got != 4000 {
41+ t.Fatalf("first chunk runes = %d, want 4000", got)
42+ }
43+ if got := utf8.RuneCountInString(chunks[1]); got != 1 {
44+ t.Fatalf("second chunk runes = %d, want 1", got)
45+ }
46+}
47+
48+func TestChunkMessageDoesNotSplitMultiByteRune(t *testing.T) {
49+ chunks := ChunkMessage("🙂🙂🙂", 2)
50+ if len(chunks) != 2 {
51+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
52+ }
53+ if chunks[0] != "🙂🙂" || chunks[1] != "🙂" {
54+ t.Fatalf("chunks = %#v", chunks)
55+ }
56+}
57+
58+func TestIsImageMIME(t *testing.T) {
59+ if !IsImageMIME("image/PNG") || IsImageMIME("application/pdf") {
60+ t.Fatal("mime gate wrong")
61+ }
62+}
63+
64+func TestGuessImageMIME(t *testing.T) {
65+ if GuessImageMIME("a/b.webp") != "image/webp" {
66+ t.Fatal("webp")
67+ }
68+ if GuessImageMIME("photos/file") != "image/jpeg" {
69+ t.Fatal("fallback should be jpeg (telegram re-encodes photos)")
70+ }
71+}
@@ -0,0 +1,71 @@
1+package bot
2+
3+import (
4+ "strings"
5+ "testing"
6+ "unicode/utf8"
7+)
8+
9+func TestChunkMessageShort(t *testing.T) {
10+ got := ChunkMessage("hello", 10)
11+ if len(got) != 1 || got[0] != "hello" {
12+ t.Fatalf("got %#v", got)
13+ }
14+}
15+
16+func TestChunkMessageSplitsOnLines(t *testing.T) {
17+ in := strings.Repeat("aaaa\n", 5) + "bbbb"
18+ for _, c := range ChunkMessage(in, 10) {
19+ if utf8.RuneCountInString(c) > 10 {
20+ t.Fatalf("chunk too long: %q", c)
21+ }
22+ }
23+}
24+
25+func TestChunkMessageHardSplitsLongLine(t *testing.T) {
26+ in := strings.Repeat("x", 25)
27+ got := ChunkMessage(in, 10)
28+ if len(got) != 3 {
29+ t.Fatalf("want 3 chunks, got %d: %#v", len(got), got)
30+ }
31+}
32+
33+// Ported from telegram/bot_test.go to preserve rune-boundary coverage.
34+func TestChunkMessageUsesRuneLimit(t *testing.T) {
35+ s := strings.Repeat("界", 4001)
36+ chunks := ChunkMessage(s, 4000)
37+ if len(chunks) != 2 {
38+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
39+ }
40+ if got := utf8.RuneCountInString(chunks[0]); got != 4000 {
41+ t.Fatalf("first chunk runes = %d, want 4000", got)
42+ }
43+ if got := utf8.RuneCountInString(chunks[1]); got != 1 {
44+ t.Fatalf("second chunk runes = %d, want 1", got)
45+ }
46+}
47+
48+func TestChunkMessageDoesNotSplitMultiByteRune(t *testing.T) {
49+ chunks := ChunkMessage("🙂🙂🙂", 2)
50+ if len(chunks) != 2 {
51+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
52+ }
53+ if chunks[0] != "🙂🙂" || chunks[1] != "🙂" {
54+ t.Fatalf("chunks = %#v", chunks)
55+ }
56+}
57+
58+func TestIsImageMIME(t *testing.T) {
59+ if !IsImageMIME("image/PNG") || IsImageMIME("application/pdf") {
60+ t.Fatal("mime gate wrong")
61+ }
62+}
63+
64+func TestGuessImageMIME(t *testing.T) {
65+ if GuessImageMIME("a/b.webp") != "image/webp" {
66+ t.Fatal("webp")
67+ }
68+ if GuessImageMIME("photos/file") != "image/jpeg" {
69+ t.Fatal("fallback should be jpeg (telegram re-encodes photos)")
70+ }
71+}
M packages/agent/modes/telegram/adapter.go +3 -3
@@ -178,7 +178,7 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update,
178178 fmt.Fprintln(stderr(), "telegram: download photo:", err)
179179 }
180180 }
181- if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
181+ if msg.Document != nil && bot.IsImageMIME(msg.Document.MimeType) {
182182 if data, mime, err := a.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
183183 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
184184 }
@@ -202,7 +202,7 @@ func (a *Adapter) Send(ctx context.Context, channelID, text string, opts bot.Sen
202202 replyTo = n
203203 }
204204 }
205- for _, chunk := range chunkMessage(text, 4000) {
205+ for _, chunk := range bot.ChunkMessage(text, 4000) {
206206 if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil {
207207 return err
208208 }
@@ -252,7 +252,7 @@ func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, st
252252 return nil, "", err
253253 }
254254 if mime == "" {
255- mime = guessImageMIME(f.FilePath)
255+ mime = bot.GuessImageMIME(f.FilePath)
256256 }
257257 return data, mime, nil
258258 }
@@ -178,7 +178,7 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update,
178 fmt.Fprintln(stderr(), "telegram: download photo:", err)178 fmt.Fprintln(stderr(), "telegram: download photo:", err)
179 }179 }
180 }180 }
181- if msg.Document != nil && isImageMIME(msg.Document.MimeType) {181+ if msg.Document != nil && bot.IsImageMIME(msg.Document.MimeType) {
182 if data, mime, err := a.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {182 if data, mime, err := a.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
183 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})183 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
184 }184 }
@@ -202,7 +202,7 @@ func (a *Adapter) Send(ctx context.Context, channelID, text string, opts bot.Sen
202 replyTo = n202 replyTo = n
203 }203 }
204 }204 }
205- for _, chunk := range chunkMessage(text, 4000) {205+ for _, chunk := range bot.ChunkMessage(text, 4000) {
206 if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil {206 if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil {
207 return err207 return err
208 }208 }
@@ -252,7 +252,7 @@ func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, st
252 return nil, "", err252 return nil, "", err
253 }253 }
254 if mime == "" {254 if mime == "" {
255- mime = guessImageMIME(f.FilePath)255+ mime = bot.GuessImageMIME(f.FilePath)
256 }256 }
257 return data, mime, nil257 return data, mime, nil
258 }258 }
D packages/agent/modes/telegram/bot_test.go +0 -31
@@ -1,31 +0,0 @@
1-package telegram
2-
3-import (
4- "strings"
5- "testing"
6- "unicode/utf8"
7-)
8-
9-func TestChunkMessageUsesRuneLimit(t *testing.T) {
10- s := strings.Repeat("界", 4001)
11- chunks := chunkMessage(s, 4000)
12- if len(chunks) != 2 {
13- t.Fatalf("len(chunks) = %d, want 2", len(chunks))
14- }
15- if got := utf8.RuneCountInString(chunks[0]); got != 4000 {
16- t.Fatalf("first chunk runes = %d, want 4000", got)
17- }
18- if got := utf8.RuneCountInString(chunks[1]); got != 1 {
19- t.Fatalf("second chunk runes = %d, want 1", got)
20- }
21-}
22-
23-func TestChunkMessageDoesNotSplitMultiByteRune(t *testing.T) {
24- chunks := chunkMessage("🙂🙂🙂", 2)
25- if len(chunks) != 2 {
26- t.Fatalf("len(chunks) = %d, want 2", len(chunks))
27- }
28- if chunks[0] != "🙂🙂" || chunks[1] != "🙂" {
29- t.Fatalf("chunks = %#v", chunks)
30- }
31-}
@@ -1,31 +0,0 @@
1-package telegram
2-
3-import (
4- "strings"
5- "testing"
6- "unicode/utf8"
7-)
8-
9-func TestChunkMessageUsesRuneLimit(t *testing.T) {
10- s := strings.Repeat("界", 4001)
11- chunks := chunkMessage(s, 4000)
12- if len(chunks) != 2 {
13- t.Fatalf("len(chunks) = %d, want 2", len(chunks))
14- }
15- if got := utf8.RuneCountInString(chunks[0]); got != 4000 {
16- t.Fatalf("first chunk runes = %d, want 4000", got)
17- }
18- if got := utf8.RuneCountInString(chunks[1]); got != 1 {
19- t.Fatalf("second chunk runes = %d, want 1", got)
20- }
21-}
22-
23-func TestChunkMessageDoesNotSplitMultiByteRune(t *testing.T) {
24- chunks := chunkMessage("🙂🙂🙂", 2)
25- if len(chunks) != 2 {
26- t.Fatalf("len(chunks) = %d, want 2", len(chunks))
27- }
28- if chunks[0] != "🙂🙂" || chunks[1] != "🙂" {
29- t.Fatalf("chunks = %#v", chunks)
30- }
31-}
M packages/agent/modes/telegram/bridge.go +4 -3
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "time"
99
10+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
1011 "github.com/patriceckhart/zot/packages/provider"
1112 )
1213
@@ -195,7 +196,7 @@ func (b *Bridge) sendToPaired(text, prefix string) {
195196 if prefix != "" {
196197 text = prefix + text
197198 }
198- for _, chunk := range chunkMessage(text, 4000) {
199+ for _, chunk := range bot.ChunkMessage(text, 4000) {
199200 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
200201 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
201202 return
@@ -368,7 +369,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
368369 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
369370 }
370371 }
371- if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
372+ if msg.Document != nil && bot.IsImageMIME(msg.Document.MimeType) {
372373 if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
373374 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
374375 }
@@ -397,7 +398,7 @@ func (b *Bridge) download(ctx context.Context, fileID, mimeOverride string) ([]b
397398 }
398399 mime := mimeOverride
399400 if mime == "" {
400- mime = guessImageMIME(f.FilePath)
401+ mime = bot.GuessImageMIME(f.FilePath)
401402 }
402403 return data, mime, nil
403404 }
@@ -7,6 +7,7 @@ import (
7 "sync"7 "sync"
8 "time"8 "time"
9 9
10+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
10 "github.com/patriceckhart/zot/packages/provider"11 "github.com/patriceckhart/zot/packages/provider"
11 )12 )
12 13
@@ -195,7 +196,7 @@ func (b *Bridge) sendToPaired(text, prefix string) {
195 if prefix != "" {196 if prefix != "" {
196 text = prefix + text197 text = prefix + text
197 }198 }
198- for _, chunk := range chunkMessage(text, 4000) {199+ for _, chunk := range bot.ChunkMessage(text, 4000) {
199 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {200 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
200 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)201 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
201 return202 return
@@ -368,7 +369,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
368 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})369 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
369 }370 }
370 }371 }
371- if msg.Document != nil && isImageMIME(msg.Document.MimeType) {372+ if msg.Document != nil && bot.IsImageMIME(msg.Document.MimeType) {
372 if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {373 if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
373 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})374 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
374 }375 }
@@ -397,7 +398,7 @@ func (b *Bridge) download(ctx context.Context, fileID, mimeOverride string) ([]b
397 }398 }
398 mime := mimeOverride399 mime := mimeOverride
399 if mime == "" {400 if mime == "" {
400- mime = guessImageMIME(f.FilePath)401+ mime = bot.GuessImageMIME(f.FilePath)
401 }402 }
402 return data, mime, nil403 return data, mime, nil
403 }404 }
M packages/agent/modes/telegram/daemon.go +9 -56
@@ -1,81 +1,34 @@
11 package telegram
22
33 import (
4- "errors"
5- "fmt"
6- "os"
74 "path/filepath"
8- "strconv"
9- "strings"
105 "time"
6+
7+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
118 )
129
1310 // PIDPath returns the location of the bot's pid file.
14-func PIDPath(zotHome string) string {
15- return filepath.Join(zotHome, "bot.pid")
16-}
11+func PIDPath(zotHome string) string { return filepath.Join(zotHome, "bot.pid") }
1712
1813 // LogPath returns the location of the bot's log file (stdout+stderr
1914 // from a detached `zot bot start`).
20-func LogPath(zotHome string) string {
21- return filepath.Join(zotHome, "logs", "bot.log")
22-}
15+func LogPath(zotHome string) string { return filepath.Join(zotHome, "logs", "bot.log") }
2316
2417 // WritePID persists pid to bot.pid. Overwrites any existing file.
25-func WritePID(zotHome string, pid int) error {
26- p := PIDPath(zotHome)
27- if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
28- return err
29- }
30- return os.WriteFile(p, []byte(strconv.Itoa(pid)+"\n"), 0o644)
31-}
18+func WritePID(zotHome string, pid int) error { return bot.WritePIDFile(PIDPath(zotHome), pid) }
3219
3320 // ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't
3421 // exist. Returns an error for any other read/parse failure.
35-func ReadPID(zotHome string) (int, error) {
36- b, err := os.ReadFile(PIDPath(zotHome))
37- if errors.Is(err, os.ErrNotExist) {
38- return 0, nil
39- }
40- if err != nil {
41- return 0, err
42- }
43- pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
44- if err != nil {
45- return 0, fmt.Errorf("parse pid: %w", err)
46- }
47- return pid, nil
48-}
22+func ReadPID(zotHome string) (int, error) { return bot.ReadPIDFile(PIDPath(zotHome)) }
4923
5024 // RemovePID deletes the pid file if it exists.
51-func RemovePID(zotHome string) error {
52- err := os.Remove(PIDPath(zotHome))
53- if errors.Is(err, os.ErrNotExist) {
54- return nil
55- }
56- return err
57-}
25+func RemovePID(zotHome string) error { return bot.RemovePIDFile(PIDPath(zotHome)) }
5826
5927 // IsRunning returns (pid, true) if a live process with the recorded
6028 // pid exists, or (pid, false) if the pid file points to a dead process.
6129 // Stale pid files are left in place; the caller may remove them.
62-func IsRunning(zotHome string) (int, bool, error) {
63- pid, err := ReadPID(zotHome)
64- if err != nil {
65- return 0, false, err
66- }
67- if pid <= 0 {
68- return 0, false, nil
69- }
70- alive, err := processAlive(pid)
71- if err != nil {
72- return pid, false, nil
73- }
74- return pid, alive, nil
75-}
30+func IsRunning(zotHome string) (int, bool, error) { return bot.IsRunningAt(PIDPath(zotHome)) }
7631
7732 // StopProcess asks pid to exit and waits up to graceful for it to stop,
7833 // then escalates to a forced kill. Returns nil if the process is gone.
79-func StopProcess(pid int, graceful time.Duration) error {
80- return stopProcess(pid, graceful)
81-}
34+func StopProcess(pid int, graceful time.Duration) error { return bot.StopProcess(pid, graceful) }
@@ -1,81 +1,34 @@
1 package telegram1 package telegram
2 2
3 import (3 import (
4- "errors"
5- "fmt"
6- "os"
7 "path/filepath"4 "path/filepath"
8- "strconv"
9- "strings"
10 "time"5 "time"
6+
7+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
11 )8 )
12 9
13 // PIDPath returns the location of the bot's pid file.10 // PIDPath returns the location of the bot's pid file.
14-func PIDPath(zotHome string) string {11+func PIDPath(zotHome string) string { return filepath.Join(zotHome, "bot.pid") }
15- return filepath.Join(zotHome, "bot.pid")
16-}
17 12
18 // LogPath returns the location of the bot's log file (stdout+stderr13 // LogPath returns the location of the bot's log file (stdout+stderr
19 // from a detached `zot bot start`).14 // from a detached `zot bot start`).
20-func LogPath(zotHome string) string {15+func LogPath(zotHome string) string { return filepath.Join(zotHome, "logs", "bot.log") }
21- return filepath.Join(zotHome, "logs", "bot.log")
22-}
23 16
24 // WritePID persists pid to bot.pid. Overwrites any existing file.17 // WritePID persists pid to bot.pid. Overwrites any existing file.
25-func WritePID(zotHome string, pid int) error {18+func WritePID(zotHome string, pid int) error { return bot.WritePIDFile(PIDPath(zotHome), pid) }
26- p := PIDPath(zotHome)
27- if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
28- return err
29- }
30- return os.WriteFile(p, []byte(strconv.Itoa(pid)+"\n"), 0o644)
31-}
32 19
33 // ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't20 // ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't
34 // exist. Returns an error for any other read/parse failure.21 // exist. Returns an error for any other read/parse failure.
35-func ReadPID(zotHome string) (int, error) {22+func ReadPID(zotHome string) (int, error) { return bot.ReadPIDFile(PIDPath(zotHome)) }
36- b, err := os.ReadFile(PIDPath(zotHome))
37- if errors.Is(err, os.ErrNotExist) {
38- return 0, nil
39- }
40- if err != nil {
41- return 0, err
42- }
43- pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
44- if err != nil {
45- return 0, fmt.Errorf("parse pid: %w", err)
46- }
47- return pid, nil
48-}
49 23
50 // RemovePID deletes the pid file if it exists.24 // RemovePID deletes the pid file if it exists.
51-func RemovePID(zotHome string) error {25+func RemovePID(zotHome string) error { return bot.RemovePIDFile(PIDPath(zotHome)) }
52- err := os.Remove(PIDPath(zotHome))
53- if errors.Is(err, os.ErrNotExist) {
54- return nil
55- }
56- return err
57-}
58 26
59 // IsRunning returns (pid, true) if a live process with the recorded27 // IsRunning returns (pid, true) if a live process with the recorded
60 // pid exists, or (pid, false) if the pid file points to a dead process.28 // pid exists, or (pid, false) if the pid file points to a dead process.
61 // Stale pid files are left in place; the caller may remove them.29 // Stale pid files are left in place; the caller may remove them.
62-func IsRunning(zotHome string) (int, bool, error) {30+func IsRunning(zotHome string) (int, bool, error) { return bot.IsRunningAt(PIDPath(zotHome)) }
63- pid, err := ReadPID(zotHome)
64- if err != nil {
65- return 0, false, err
66- }
67- if pid <= 0 {
68- return 0, false, nil
69- }
70- alive, err := processAlive(pid)
71- if err != nil {
72- return pid, false, nil
73- }
74- return pid, alive, nil
75-}
76 31
77 // StopProcess asks pid to exit and waits up to graceful for it to stop,32 // StopProcess asks pid to exit and waits up to graceful for it to stop,
78 // then escalates to a forced kill. Returns nil if the process is gone.33 // then escalates to a forced kill. Returns nil if the process is gone.
79-func StopProcess(pid int, graceful time.Duration) error {34+func StopProcess(pid int, graceful time.Duration) error { return bot.StopProcess(pid, graceful) }
80- return stopProcess(pid, graceful)
81-}

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