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 Unverified
SHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0wThe key is unknown
3 files · +274 -182
M packages/agent/botcmd.go +209 -182
| @@ -27,17 +27,16 @@ import ( | |||
| 27 | 27 | // inherited). See botcmd_unix.go and botcmd_windows.go. | |
| 28 | 28 | var detachChild func(cmd *exec.Cmd) | |
| 29 | 29 | ||
| 30 | - | // runBotCommand dispatches `zot telegram-bot ...` subcommands. The | |
| 31 | - | // short alias "tg" is also accepted. Returns true if rawArgs begins | |
| 32 | - | // with a recognised subcommand, false otherwise. | |
| 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. | |
| 33 | 34 | func runBotCommand(rawArgs []string, version string) (handled bool, err error) { | |
| 34 | 35 | if len(rawArgs) == 0 { | |
| 35 | 36 | return false, nil | |
| 36 | 37 | } | |
| 37 | - | switch rawArgs[0] { | |
| 38 | - | case "telegram-bot", "tg": | |
| 39 | - | // recognised | |
| 40 | - | default: | |
| 38 | + | spec := specFor(rawArgs[0]) | |
| 39 | + | if spec == nil { | |
| 41 | 40 | return false, nil | |
| 42 | 41 | } | |
| 43 | 42 | sub := "" | |
| @@ -48,164 +47,43 @@ func runBotCommand(rawArgs []string, version string) (handled bool, err error) { | |||
| 48 | 47 | } | |
| 49 | 48 | switch sub { | |
| 50 | 49 | case "", "help", "-h", "--help": | |
| 51 | - | printBotHelp() | |
| 50 | + | spec.printHelp() | |
| 52 | 51 | return true, nil | |
| 53 | 52 | case "setup": | |
| 54 | - | return true, botSetup(tail) | |
| 53 | + | return true, spec.setup(tail) | |
| 55 | 54 | case "status": | |
| 56 | - | return true, botStatus() | |
| 55 | + | return true, spec.status() | |
| 57 | 56 | case "reset": | |
| 58 | - | return true, botReset() | |
| 57 | + | return true, spec.reset() | |
| 59 | 58 | case "run": | |
| 60 | - | return true, botRun(tail, version) | |
| 59 | + | return true, botRun(spec, tail, version) | |
| 61 | 60 | case "start": | |
| 62 | - | return true, botStart(tail) | |
| 61 | + | return true, botStart(spec, tail) | |
| 63 | 62 | case "stop": | |
| 64 | - | return true, botStop() | |
| 63 | + | return true, botStop(spec) | |
| 65 | 64 | case "logs": | |
| 66 | - | return true, botLogs(tail) | |
| 65 | + | return true, botLogs(spec, tail) | |
| 67 | 66 | 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) | |
| 70 | 69 | } | |
| 71 | 70 | } | |
| 72 | 71 | ||
| 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 { | |
| 197 | 77 | // 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) | |
| 200 | 80 | } | |
| 201 | - | _ = telegram.RemovePID(ZotHome()) // clear any stale pid file | |
| 81 | + | _ = bot.RemovePIDFile(spec.pidPath(ZotHome())) // clear any stale pid file | |
| 202 | 82 | ||
| 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) | |
| 209 | 87 | } | |
| 210 | 88 | ||
| 211 | 89 | self, err := os.Executable() | |
| @@ -213,7 +91,7 @@ func botStart(rawTail []string) error { | |||
| 213 | 91 | return fmt.Errorf("locate zot binary: %w", err) | |
| 214 | 92 | } | |
| 215 | 93 | ||
| 216 | - | logPath := telegram.LogPath(ZotHome()) | |
| 94 | + | logPath := spec.logPath(ZotHome()) | |
| 217 | 95 | if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { | |
| 218 | 96 | return err | |
| 219 | 97 | } | |
| @@ -231,9 +109,9 @@ func botStart(rawTail []string) error { | |||
| 231 | 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`, | |
| 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...) | |
| 237 | 115 | cmd := exec.Command(self, args...) | |
| 238 | 116 | cmd.Stdout = logFile | |
| 239 | 117 | cmd.Stderr = logFile | |
| @@ -246,51 +124,51 @@ func botStart(rawTail []string) error { | |||
| 246 | 124 | if err := cmd.Start(); err != nil { | |
| 247 | 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 | 128 | _ = cmd.Process.Kill() | |
| 251 | 129 | return fmt.Errorf("write pid: %w", err) | |
| 252 | 130 | } | |
| 253 | 131 | // Don't wait() — detach. OS will reparent the child to init when we exit. | |
| 254 | 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) | |
| 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) | |
| 258 | 136 | return nil | |
| 259 | 137 | } | |
| 260 | 138 | ||
| 261 | 139 | // botStop sends SIGTERM to the running bot (SIGKILL if it doesn't | |
| 262 | 140 | // 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())) | |
| 265 | 143 | if err != nil { | |
| 266 | 144 | return err | |
| 267 | 145 | } | |
| 268 | 146 | if !alive { | |
| 269 | 147 | if pid > 0 { | |
| 270 | - | _ = telegram.RemovePID(ZotHome()) | |
| 148 | + | _ = bot.RemovePIDFile(spec.pidPath(ZotHome())) | |
| 271 | 149 | fmt.Printf("no live process; cleared stale pid %d\n", pid) | |
| 272 | 150 | return nil | |
| 273 | 151 | } | |
| 274 | - | fmt.Println("bot is not running") | |
| 152 | + | fmt.Printf("%s is not running\n", spec.name) | |
| 275 | 153 | 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 | 156 | return fmt.Errorf("stop pid %d: %w", pid, err) | |
| 279 | 157 | } | |
| 280 | - | _ = telegram.RemovePID(ZotHome()) | |
| 158 | + | _ = bot.RemovePIDFile(spec.pidPath(ZotHome())) | |
| 281 | 159 | fmt.Printf("stopped pid %d\n", pid) | |
| 282 | 160 | return nil | |
| 283 | 161 | } | |
| 284 | 162 | ||
| 285 | 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 | 165 | follow := false | |
| 288 | 166 | for _, a := range rawTail { | |
| 289 | 167 | if a == "-f" || a == "--follow" { | |
| 290 | 168 | follow = true | |
| 291 | 169 | } | |
| 292 | 170 | } | |
| 293 | - | p := telegram.LogPath(ZotHome()) | |
| 171 | + | p := spec.logPath(ZotHome()) | |
| 294 | 172 | f, err := os.Open(p) | |
| 295 | 173 | if errors.Is(err, os.ErrNotExist) { | |
| 296 | 174 | fmt.Println("no log file at", p) | |
| @@ -325,7 +203,7 @@ func botLogs(rawTail []string) error { | |||
| 325 | 203 | } | |
| 326 | 204 | ||
| 327 | 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 | 207 | // Parse only a small subset of flags relevant to bot run. We reuse | |
| 330 | 208 | // the main args parser so --provider/--model/--cwd/--api-key/--reasoning | |
| 331 | 209 | // behave the same as in the tui. | |
| @@ -340,13 +218,15 @@ func botRun(rawTail []string, version string) error { | |||
| 340 | 218 | 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 | 227 | if err != nil { | |
| 345 | 228 | 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 | 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 | 255 | var runner *bot.Runner | |
| 384 | 256 | runner = bot.NewRunner(adapter, agent, bot.Config{ | |
| 385 | 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, | |
| 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())) | |
| 410 | 282 | ||
| 411 | 283 | // Translate sigterm/sigint into a context cancel so the bot's goroutines | |
| 412 | 284 | // and the currently-running turn wind down cleanly. | |
| @@ -455,3 +327,158 @@ func maskToken(tok string) string { | |||
| 455 | 327 | } | |
| 456 | 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 | + | } | |
| @@ -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. The | 30 | + | // runBotCommand dispatches `zot <protocol>-bot ...` subcommands via |
| 31 | - | // short alias "tg" is also accepted. Returns true if rawArgs begins | 31 | + | // 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, nil | 36 | 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, nil | 40 | 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, nil | 51 | 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 bridge | 74 | + | // 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 file | 81 | + | _ = 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 err | 85 | + | } 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 err | 96 | 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 = logFile | 116 | cmd.Stdout = logFile | ||
| 239 | cmd.Stderr = logFile | 117 | 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 nil | 136 | return nil | ||
| 259 | } | 137 | } | ||
| 260 | 138 | ||||
| 261 | // botStop sends SIGTERM to the running bot (SIGKILL if it doesn't | 139 | // 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 err | 144 | 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 nil | 150 | return nil | ||
| 273 | } | 151 | } | ||
| 274 | - | fmt.Println("bot is not running") | 152 | + | fmt.Printf("%s is not running\n", spec.name) |
| 275 | return nil | 153 | 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 nil | 160 | 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 := false | 165 | 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 = true | 168 | 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 reuse | 207 | // Parse only a small subset of flags relevant to bot run. We reuse | ||
| 330 | // the main args parser so --provider/--model/--cwd/--api-key/--reasoning | 208 | // 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 err | 218 | 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 err | 228 | 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.Runner | 255 | 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 goroutines | 283 | // 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 | + | } | |||