Merge branch 'main' into discuss-extension-submit-frame
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 | |||
| 768 | 768 | ||
| 769 | 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 | 777 | ## Development | |
| 772 | 778 | ||
| 773 | 779 | ```bash | |
| @@ -790,6 +796,8 @@ packages/agent/ cli wiring, arg parsing, system prompt, co | |||
| 790 | 796 | packages/agent/extensions/ extension subprocess manager | |
| 791 | 797 | packages/agent/extproto/ extension wire-format types | |
| 792 | 798 | 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 | 801 | packages/agent/tools/ read, write, edit, bash, sandbox | |
| 794 | 802 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | |
| 795 | 803 | 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 | ## Development | 777 | ## Development | ||
| 772 | 778 | ||||
| 773 | ```bash | 779 | ```bash | ||
| @@ -790,6 +796,8 @@ packages/agent/ cli wiring, arg parsing, system prompt, co | |||||
| 790 | packages/agent/extensions/ extension subprocess manager | 796 | packages/agent/extensions/ extension subprocess manager | ||
| 791 | packages/agent/extproto/ extension wire-format types | 797 | packages/agent/extproto/ extension wire-format types | ||
| 792 | packages/agent/modes/ interactive tui, print, json, dialogs | 798 | 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, sandbox | 801 | packages/agent/tools/ read, write, edit, bash, sandbox | ||
| 794 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | 802 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | ||
| 795 | packages/agent/swarm/ background subagent runtime | 803 | packages/agent/swarm/ background subagent runtime | ||
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 | + | } | |||
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 @@ | |||
| 1 | 1 | //go:build !windows | |
| 2 | 2 | ||
| 3 | - | package telegram | |
| 3 | + | package bot | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | //go:build !windows | 1 | //go:build !windows | ||
| 2 | 2 | ||||
| 3 | - | package telegram | 3 | + | 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 @@ | |||
| 1 | 1 | //go:build windows | |
| 2 | 2 | ||
| 3 | - | package telegram | |
| 3 | + | package bot | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | //go:build windows | 1 | //go:build windows | ||
| 2 | 2 | ||||
| 3 | - | package telegram | 3 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | 5 | "unicode/utf8" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 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 | |
| 9 | 9 | // boundaries when possible. | |
| 10 | - | func chunkMessage(s string, limit int) []string { | |
| 10 | + | func ChunkMessage(s string, limit int) []string { | |
| 11 | 11 | if limit <= 0 || utf8.RuneCountInString(s) <= limit { | |
| 12 | 12 | return []string{s} | |
| 13 | 13 | } | |
| @@ -63,9 +63,9 @@ func byteIndexAfterRunes(s string, n int) int { | |||
| 63 | 63 | return len(s) | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | - | // isImageMIME returns true for MIME types the model can probably ingest | |
| 66 | + | // IsImageMIME returns true for MIME types the model can probably ingest | |
| 67 | 67 | // as a vision input. | |
| 68 | - | func isImageMIME(m string) bool { | |
| 68 | + | func IsImageMIME(m string) bool { | |
| 69 | 69 | switch strings.ToLower(m) { | |
| 70 | 70 | case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp": | |
| 71 | 71 | return true | |
| @@ -73,10 +73,10 @@ func isImageMIME(m string) bool { | |||
| 73 | 73 | return false | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 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 | |
| 78 | 78 | // but getFile's file_path may omit the extension. | |
| 79 | - | func guessImageMIME(path string) string { | |
| 79 | + | func GuessImageMIME(path string) string { | |
| 80 | 80 | lower := strings.ToLower(path) | |
| 81 | 81 | switch { | |
| 82 | 82 | case strings.HasSuffix(lower, ".png"): | |
| @@ -1,13 +1,13 @@ | |||||
| 1 | - | package telegram | 1 | + | 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 line | 8 | + | // 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 ingest | 66 | + | // 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 true | 71 | return true | ||
| @@ -73,10 +73,10 @@ func isImageMIME(m string) bool { | |||||
| 73 | return false | 73 | return false | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | - | // guessImageMIME infers a mime type from a filename suffix. Falls back | 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 | 77 | + | // 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, | |||
| 178 | 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 | 182 | if data, mime, err := a.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil { | |
| 183 | 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 | 202 | replyTo = n | |
| 203 | 203 | } | |
| 204 | 204 | } | |
| 205 | - | for _, chunk := range chunkMessage(text, 4000) { | |
| 205 | + | for _, chunk := range bot.ChunkMessage(text, 4000) { | |
| 206 | 206 | if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil { | |
| 207 | 207 | return err | |
| 208 | 208 | } | |
| @@ -252,7 +252,7 @@ func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, st | |||
| 252 | 252 | return nil, "", err | |
| 253 | 253 | } | |
| 254 | 254 | if mime == "" { | |
| 255 | - | mime = guessImageMIME(f.FilePath) | |
| 255 | + | mime = bot.GuessImageMIME(f.FilePath) | |
| 256 | 256 | } | |
| 257 | 257 | return data, mime, nil | |
| 258 | 258 | } | |
| @@ -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 = n | 202 | 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 err | 207 | return err | ||
| 208 | } | 208 | } | ||
| @@ -252,7 +252,7 @@ func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, st | |||||
| 252 | return nil, "", err | 252 | 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, nil | 257 | 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 ( | |||
| 7 | 7 | "sync" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/modes/bot" | |
| 10 | 11 | "github.com/patriceckhart/zot/packages/provider" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -195,7 +196,7 @@ func (b *Bridge) sendToPaired(text, prefix string) { | |||
| 195 | 196 | if prefix != "" { | |
| 196 | 197 | text = prefix + text | |
| 197 | 198 | } | |
| 198 | - | for _, chunk := range chunkMessage(text, 4000) { | |
| 199 | + | for _, chunk := range bot.ChunkMessage(text, 4000) { | |
| 199 | 200 | if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil { | |
| 200 | 201 | fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err) | |
| 201 | 202 | return | |
| @@ -368,7 +369,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||
| 368 | 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 | 373 | if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil { | |
| 373 | 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 | 399 | mime := mimeOverride | |
| 399 | 400 | if mime == "" { | |
| 400 | - | mime = guessImageMIME(f.FilePath) | |
| 401 | + | mime = bot.GuessImageMIME(f.FilePath) | |
| 401 | 402 | } | |
| 402 | 403 | return data, mime, nil | |
| 403 | 404 | } | |
| @@ -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 + text | 197 | 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 | return | 202 | 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 := mimeOverride | 399 | 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, nil | 403 | return data, mime, nil | ||
| 403 | } | 404 | } | ||
M packages/agent/modes/telegram/daemon.go +9 -56
| @@ -1,81 +1,34 @@ | |||
| 1 | 1 | package telegram | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | "os" | |
| 7 | 4 | "path/filepath" | |
| 8 | - | "strconv" | |
| 9 | - | "strings" | |
| 10 | 5 | "time" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/agent/modes/bot" | |
| 11 | 8 | ) | |
| 12 | 9 | ||
| 13 | 10 | // 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") } | |
| 17 | 12 | ||
| 18 | 13 | // LogPath returns the location of the bot's log file (stdout+stderr | |
| 19 | 14 | // 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") } | |
| 23 | 16 | ||
| 24 | 17 | // 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) } | |
| 32 | 19 | ||
| 33 | 20 | // ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't | |
| 34 | 21 | // 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)) } | |
| 49 | 23 | ||
| 50 | 24 | // 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)) } | |
| 58 | 26 | ||
| 59 | 27 | // IsRunning returns (pid, true) if a live process with the recorded | |
| 60 | 28 | // pid exists, or (pid, false) if the pid file points to a dead process. | |
| 61 | 29 | // 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)) } | |
| 76 | 31 | ||
| 77 | 32 | // StopProcess asks pid to exit and waits up to graceful for it to stop, | |
| 78 | 33 | // 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 telegram | 1 | 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+stderr | 13 | // 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't | 20 | // 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 recorded | 27 | // 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 | - | } | |||