add telegram bot bridge
- commit
- d8a0cba4fc071a242713fe4eac67d596c1fe0c52
- author
- patriceckhart · · 2026-04-18 07:15 UTC
- parent
- 6cced27
- signature
- unsigned
12 files · +1517 -29
M README.md +45 -8
| @@ -2,14 +2,13 @@ | |||
| 2 | 2 | ||
| 3 | 3 | yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |
| 4 | 4 | ||
| 5 | - | one static binary. | |
| 6 | - | two providers atm (anthropic, openai/codex). | |
| 7 | - | four tools (read, write, edit, bash). | |
| 8 | - | three run modes (interactive tui, print, json). | |
| 9 | - | no extensions. | |
| 10 | - | no plugins. | |
| 11 | - | no ceremony. | |
| 12 | - | no community. | |
| 5 | + | - one static binary. | |
| 6 | + | - two providers atm (anthropic, openai/codex). | |
| 7 | + | - four tools (read, write, edit, bash). | |
| 8 | + | - three run modes (interactive tui, print, json). | |
| 9 | + | - built-in telegram bot. | |
| 10 | + | - no extensions atm. | |
| 11 | + | - no community atm. | |
| 13 | 12 | ||
| 14 | 13 | ## install | |
| 15 | 14 | ||
| @@ -52,6 +51,14 @@ run `zot` and type `/login`. pick one of two methods: | |||
| 52 | 51 | ||
| 53 | 52 | > **note on subscription login**: the oauth client ids used are the ones published in anthropic's claude code cli and openai's codex cli. reusing them from a third-party tool is against their terms of service and may be revoked at any time. use it at your own risk; the api-key flow is the safe default. | |
| 54 | 53 | ||
| 54 | + | ### token refresh | |
| 55 | + | ||
| 56 | + | oauth access tokens are short-lived (anthropic ~8h, openai ~30d). zot refreshes them automatically: | |
| 57 | + | ||
| 58 | + | - at every credential lookup, zot checks the stored `expiry` and — if past it (with a 60s safety margin) — hits the provider's `oauth/token` endpoint with the stored `refresh_token`, persists the new `access_token` + `refresh_token` + `expiry` back to `auth.json`, and hands the fresh token to the client. | |
| 59 | + | - the telegram bridge additionally refreshes once per turn so a bot that runs for days keeps working without manual intervention. | |
| 60 | + | - if the refresh itself fails (the `refresh_token` was revoked, or the account was logged out everywhere), the error bubbles up to the caller: the tui shows it in the status line, the bot replies with it in your dm. run `/login` to get a fresh token pair. | |
| 61 | + | ||
| 55 | 62 | all data lives under `$ZOT_HOME`: | |
| 56 | 63 | ||
| 57 | 64 | ``` | |
| @@ -200,6 +207,36 @@ frames containing images are full-repainted (no differential diff) to prevent st | |||
| 200 | 207 | | `pgup` / `pgdn` | scroll one page up / down | | |
| 201 | 208 | | `up` / `down` (editor empty) | scroll three lines up / down — this is how the mouse wheel reaches the scroll logic on most terminals | | |
| 202 | 209 | ||
| 210 | + | ## telegram bot (bridge) | |
| 211 | + | ||
| 212 | + | zot can run as a telegram bot so you can dm it from your phone. it's a built-in subcommand, not a plugin: | |
| 213 | + | ||
| 214 | + | ```bash | |
| 215 | + | zot telegram-bot setup # paste a BotFather token, verify, save | |
| 216 | + | zot telegram-bot run # foreground: long-poll in this terminal (ctrl+c to stop) | |
| 217 | + | zot telegram-bot start # background: detach and return immediately | |
| 218 | + | zot telegram-bot stop # sigterm the background bot (sigkill after 5s) | |
| 219 | + | zot telegram-bot logs -f # tail $ZOT_HOME/logs/bot.log (omit -f to just cat) | |
| 220 | + | zot telegram-bot status # config (token masked) + running/stopped | |
| 221 | + | zot telegram-bot reset # forget the token + paired user | |
| 222 | + | # short alias: `zot tg ...` is accepted for every subcommand | |
| 223 | + | ``` | |
| 224 | + | ||
| 225 | + | the background flavor writes the child's pid to `$ZOT_HOME/bot.pid` and redirects stdout+stderr to `$ZOT_HOME/logs/bot.log`. `zot telegram-bot stop` reads that pid, sends sigterm, waits up to five seconds, then escalates to sigkill if the child is still alive. running two instances at once is refused at startup. | |
| 226 | + | ||
| 227 | + | > **use the installed binary for `start`.** `go run ./cmd/zot telegram-bot start` won't work — `go run` builds a binary in a temp directory and deletes it when it exits, which kills the detached child. run `make install` (or `go build`) first and invoke the installed binary. | |
| 228 | + | ||
| 229 | + | setup flow: | |
| 230 | + | ||
| 231 | + | 1. talk to [@BotFather](https://t.me/BotFather) on telegram, run `/newbot`, copy the token it gives you. | |
| 232 | + | 2. run `zot bot setup` and paste the token when prompted. | |
| 233 | + | 3. run `zot bot run` in the directory you want the agent to operate in. | |
| 234 | + | 4. open your bot on telegram, send `/start`. the first user to do this claims the bridge (stored as `allowed_user_id`); every other user is rejected. | |
| 235 | + | ||
| 236 | + | from then on, any dm you send is forwarded to the agent as a user prompt. attached photos or image/* documents are downloaded and passed to vision-capable models. in-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). config lives in `$ZOT_HOME/bot.json` (mode 0600). | |
| 237 | + | ||
| 238 | + | bot mode respects the usual zot flags — `--provider`, `--model`, `--cwd`, `--reasoning`, `--continue`, `--no-session`, `--no-tools`, etc. run `zot bot run -c --model claude-opus-4-1` to resume the latest session on opus, for example. | |
| 239 | + | ||
| 203 | 240 | ## development | |
| 204 | 241 | ||
| 205 | 242 | ```bash | |
| @@ -2,14 +2,13 @@ | |||||
| 2 | 2 | ||||
| 3 | yet another coding agent harness, lightweight and written (vibe-slopped) in go. | 3 | yet another coding agent harness, lightweight and written (vibe-slopped) in go. | ||
| 4 | 4 | ||||
| 5 | - | one static binary. | 5 | + | - one static binary. |
| 6 | - | two providers atm (anthropic, openai/codex). | 6 | + | - two providers atm (anthropic, openai/codex). |
| 7 | - | four tools (read, write, edit, bash). | 7 | + | - four tools (read, write, edit, bash). |
| 8 | - | three run modes (interactive tui, print, json). | 8 | + | - three run modes (interactive tui, print, json). |
| 9 | - | no extensions. | 9 | + | - built-in telegram bot. |
| 10 | - | no plugins. | 10 | + | - no extensions atm. |
| 11 | - | no ceremony. | 11 | + | - no community atm. |
| 12 | - | no community. | |||
| 13 | 12 | ||||
| 14 | ## install | 13 | ## install | ||
| 15 | 14 | ||||
| @@ -52,6 +51,14 @@ run `zot` and type `/login`. pick one of two methods: | |||||
| 52 | 51 | ||||
| 53 | > **note on subscription login**: the oauth client ids used are the ones published in anthropic's claude code cli and openai's codex cli. reusing them from a third-party tool is against their terms of service and may be revoked at any time. use it at your own risk; the api-key flow is the safe default. | 52 | > **note on subscription login**: the oauth client ids used are the ones published in anthropic's claude code cli and openai's codex cli. reusing them from a third-party tool is against their terms of service and may be revoked at any time. use it at your own risk; the api-key flow is the safe default. | ||
| 54 | 53 | ||||
| 54 | + | ### token refresh | |||
| 55 | + | ||||
| 56 | + | oauth access tokens are short-lived (anthropic ~8h, openai ~30d). zot refreshes them automatically: | |||
| 57 | + | ||||
| 58 | + | - at every credential lookup, zot checks the stored `expiry` and — if past it (with a 60s safety margin) — hits the provider's `oauth/token` endpoint with the stored `refresh_token`, persists the new `access_token` + `refresh_token` + `expiry` back to `auth.json`, and hands the fresh token to the client. | |||
| 59 | + | - the telegram bridge additionally refreshes once per turn so a bot that runs for days keeps working without manual intervention. | |||
| 60 | + | - if the refresh itself fails (the `refresh_token` was revoked, or the account was logged out everywhere), the error bubbles up to the caller: the tui shows it in the status line, the bot replies with it in your dm. run `/login` to get a fresh token pair. | |||
| 61 | + | ||||
| 55 | all data lives under `$ZOT_HOME`: | 62 | all data lives under `$ZOT_HOME`: | ||
| 56 | 63 | ||||
| 57 | ``` | 64 | ``` | ||
| @@ -200,6 +207,36 @@ frames containing images are full-repainted (no differential diff) to prevent st | |||||
| 200 | | `pgup` / `pgdn` | scroll one page up / down | | 207 | | `pgup` / `pgdn` | scroll one page up / down | | ||
| 201 | | `up` / `down` (editor empty) | scroll three lines up / down — this is how the mouse wheel reaches the scroll logic on most terminals | | 208 | | `up` / `down` (editor empty) | scroll three lines up / down — this is how the mouse wheel reaches the scroll logic on most terminals | | ||
| 202 | 209 | ||||
| 210 | + | ## telegram bot (bridge) | |||
| 211 | + | ||||
| 212 | + | zot can run as a telegram bot so you can dm it from your phone. it's a built-in subcommand, not a plugin: | |||
| 213 | + | ||||
| 214 | + | ```bash | |||
| 215 | + | zot telegram-bot setup # paste a BotFather token, verify, save | |||
| 216 | + | zot telegram-bot run # foreground: long-poll in this terminal (ctrl+c to stop) | |||
| 217 | + | zot telegram-bot start # background: detach and return immediately | |||
| 218 | + | zot telegram-bot stop # sigterm the background bot (sigkill after 5s) | |||
| 219 | + | zot telegram-bot logs -f # tail $ZOT_HOME/logs/bot.log (omit -f to just cat) | |||
| 220 | + | zot telegram-bot status # config (token masked) + running/stopped | |||
| 221 | + | zot telegram-bot reset # forget the token + paired user | |||
| 222 | + | # short alias: `zot tg ...` is accepted for every subcommand | |||
| 223 | + | ``` | |||
| 224 | + | ||||
| 225 | + | the background flavor writes the child's pid to `$ZOT_HOME/bot.pid` and redirects stdout+stderr to `$ZOT_HOME/logs/bot.log`. `zot telegram-bot stop` reads that pid, sends sigterm, waits up to five seconds, then escalates to sigkill if the child is still alive. running two instances at once is refused at startup. | |||
| 226 | + | ||||
| 227 | + | > **use the installed binary for `start`.** `go run ./cmd/zot telegram-bot start` won't work — `go run` builds a binary in a temp directory and deletes it when it exits, which kills the detached child. run `make install` (or `go build`) first and invoke the installed binary. | |||
| 228 | + | ||||
| 229 | + | setup flow: | |||
| 230 | + | ||||
| 231 | + | 1. talk to [@BotFather](https://t.me/BotFather) on telegram, run `/newbot`, copy the token it gives you. | |||
| 232 | + | 2. run `zot bot setup` and paste the token when prompted. | |||
| 233 | + | 3. run `zot bot run` in the directory you want the agent to operate in. | |||
| 234 | + | 4. open your bot on telegram, send `/start`. the first user to do this claims the bridge (stored as `allowed_user_id`); every other user is rejected. | |||
| 235 | + | ||||
| 236 | + | from then on, any dm you send is forwarded to the agent as a user prompt. attached photos or image/* documents are downloaded and passed to vision-capable models. in-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). config lives in `$ZOT_HOME/bot.json` (mode 0600). | |||
| 237 | + | ||||
| 238 | + | bot mode respects the usual zot flags — `--provider`, `--model`, `--cwd`, `--reasoning`, `--continue`, `--no-session`, `--no-tools`, etc. run `zot bot run -c --model claude-opus-4-1` to resume the latest session on opus, for example. | |||
| 239 | + | ||||
| 203 | ## development | 240 | ## development | ||
| 204 | 241 | ||||
| 205 | ```bash | 242 | ```bash | ||
M internal/agent/args.go +8 -0
| @@ -188,6 +188,14 @@ usage: | |||
| 188 | 188 | zot "prompt" interactive, pre-filled prompt | |
| 189 | 189 | zot -p "prompt" print final text, exit | |
| 190 | 190 | zot --json "prompt" newline-delimited json events, exit | |
| 191 | + | zot telegram-bot setup configure a telegram bot (from BotFather) | |
| 192 | + | zot telegram-bot run foreground bridge (ctrl+c to stop) | |
| 193 | + | zot telegram-bot start background bridge (detached) | |
| 194 | + | zot telegram-bot stop stop the background bridge | |
| 195 | + | zot telegram-bot logs [-f] tail the background bridge's log | |
| 196 | + | zot telegram-bot status config + running state | |
| 197 | + | zot telegram-bot reset forget saved token | |
| 198 | + | (short alias: zot tg ...) | |
| 191 | 199 | ||
| 192 | 200 | flags: | |
| 193 | 201 | --provider anthropic|openai | |
| @@ -188,6 +188,14 @@ usage: | |||||
| 188 | zot "prompt" interactive, pre-filled prompt | 188 | zot "prompt" interactive, pre-filled prompt | ||
| 189 | zot -p "prompt" print final text, exit | 189 | zot -p "prompt" print final text, exit | ||
| 190 | zot --json "prompt" newline-delimited json events, exit | 190 | zot --json "prompt" newline-delimited json events, exit | ||
| 191 | + | zot telegram-bot setup configure a telegram bot (from BotFather) | |||
| 192 | + | zot telegram-bot run foreground bridge (ctrl+c to stop) | |||
| 193 | + | zot telegram-bot start background bridge (detached) | |||
| 194 | + | zot telegram-bot stop stop the background bridge | |||
| 195 | + | zot telegram-bot logs [-f] tail the background bridge's log | |||
| 196 | + | zot telegram-bot status config + running state | |||
| 197 | + | zot telegram-bot reset forget saved token | |||
| 198 | + | (short alias: zot tg ...) | |||
| 191 | 199 | ||||
| 192 | flags: | 200 | flags: | ||
| 193 | --provider anthropic|openai | 201 | --provider anthropic|openai | ||
A internal/agent/botcmd.go +433 -0
| @@ -0,0 +1,433 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "os/exec" | |
| 11 | + | "os/signal" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | "syscall" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "github.com/patriceckhart/zot/internal/agent/modes/telegram" | |
| 19 | + | "github.com/patriceckhart/zot/internal/core" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // runBotCommand dispatches `zot telegram-bot ...` subcommands. The | |
| 23 | + | // short alias "tg" is also accepted. Returns true if rawArgs begins | |
| 24 | + | // with a recognised subcommand, false otherwise. | |
| 25 | + | func runBotCommand(rawArgs []string, version string) (handled bool, err error) { | |
| 26 | + | if len(rawArgs) == 0 { | |
| 27 | + | return false, nil | |
| 28 | + | } | |
| 29 | + | switch rawArgs[0] { | |
| 30 | + | case "telegram-bot", "tg": | |
| 31 | + | // recognised | |
| 32 | + | default: | |
| 33 | + | return false, nil | |
| 34 | + | } | |
| 35 | + | sub := "" | |
| 36 | + | var tail []string | |
| 37 | + | if len(rawArgs) >= 2 { | |
| 38 | + | sub = rawArgs[1] | |
| 39 | + | tail = rawArgs[2:] | |
| 40 | + | } | |
| 41 | + | switch sub { | |
| 42 | + | case "", "help", "-h", "--help": | |
| 43 | + | printBotHelp() | |
| 44 | + | return true, nil | |
| 45 | + | case "setup": | |
| 46 | + | return true, botSetup(tail) | |
| 47 | + | case "status": | |
| 48 | + | return true, botStatus() | |
| 49 | + | case "reset": | |
| 50 | + | return true, botReset() | |
| 51 | + | case "run": | |
| 52 | + | return true, botRun(tail, version) | |
| 53 | + | case "start": | |
| 54 | + | return true, botStart(tail) | |
| 55 | + | case "stop": | |
| 56 | + | return true, botStop() | |
| 57 | + | case "logs": | |
| 58 | + | return true, botLogs(tail) | |
| 59 | + | default: | |
| 60 | + | printBotHelp() | |
| 61 | + | return true, fmt.Errorf("unknown bot subcommand %q", sub) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // printBotHelp prints usage for `zot bot`. | |
| 66 | + | func printBotHelp() { | |
| 67 | + | fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge | |
| 68 | + | ||
| 69 | + | usage: | |
| 70 | + | zot telegram-bot setup paste a BotFather token, verify, save | |
| 71 | + | zot telegram-bot status show bridge config and whether it's running | |
| 72 | + | zot telegram-bot run [flags] run in the foreground (ctrl+c to stop) | |
| 73 | + | zot telegram-bot start [flags] launch in background, detach, return immediately | |
| 74 | + | zot telegram-bot stop sigterm the running background bot, sigkill if needed | |
| 75 | + | zot telegram-bot logs [--follow] tail the background bot's log file | |
| 76 | + | zot telegram-bot reset forget token + allowed user | |
| 77 | + | ||
| 78 | + | setup flow: | |
| 79 | + | 1. talk to @BotFather on telegram, /newbot, copy the token | |
| 80 | + | 2. run "zot telegram-bot setup" and paste the token | |
| 81 | + | 3. run "zot telegram-bot start" (background) or "zot telegram-bot run" (foreground) | |
| 82 | + | 4. send /start to your bot from telegram; the first sender claims it | |
| 83 | + | ||
| 84 | + | while the bot is running, dm it anything and the message is forwarded | |
| 85 | + | to the agent the same way it would be from the tui. image attachments | |
| 86 | + | (photos or image/* documents) are passed to vision-capable models. | |
| 87 | + | telegram commands the bot handles directly: /help, /status, /stop. | |
| 88 | + | ||
| 89 | + | config & state: | |
| 90 | + | $ZOT_HOME/bot.json # bot token + paired user (mode 0600) | |
| 91 | + | $ZOT_HOME/bot.pid # pid of the running bot (written by run/start) | |
| 92 | + | $ZOT_HOME/logs/bot.log # stdout+stderr from "zot telegram-bot start" | |
| 93 | + | `) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // botSetup interactively reads a bot token, verifies it via getMe, and saves it. | |
| 97 | + | func botSetup(_ []string) error { | |
| 98 | + | cfg, err := telegram.LoadConfig(ZotHome()) | |
| 99 | + | if err != nil { | |
| 100 | + | return err | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | fmt.Print("telegram bot token (from @BotFather): ") | |
| 104 | + | reader := bufio.NewReader(os.Stdin) | |
| 105 | + | line, err := reader.ReadString('\n') | |
| 106 | + | if err != nil { | |
| 107 | + | return err | |
| 108 | + | } | |
| 109 | + | token := strings.TrimSpace(line) | |
| 110 | + | if token == "" { | |
| 111 | + | return fmt.Errorf("no token provided") | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | client := telegram.NewClient(token) | |
| 115 | + | me, err := client.GetMe(context.Background()) | |
| 116 | + | if err != nil { | |
| 117 | + | return fmt.Errorf("token rejected by telegram: %w", err) | |
| 118 | + | } | |
| 119 | + | cfg.BotToken = token | |
| 120 | + | cfg.BotUsername = me.Username | |
| 121 | + | cfg.BotID = me.ID | |
| 122 | + | // Any stored pairing might be for a different bot; clear it. | |
| 123 | + | cfg.AllowedUserID = 0 | |
| 124 | + | cfg.LastUpdateID = 0 | |
| 125 | + | if err := telegram.SaveConfig(ZotHome(), cfg); err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZotHome())) | |
| 129 | + | fmt.Println("next: run `zot telegram-bot run`, then send /start to your bot from telegram.") | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // botStatus prints the current bot config without the token, plus | |
| 134 | + | // whether the background process is alive. | |
| 135 | + | func botStatus() error { | |
| 136 | + | cfg, err := telegram.LoadConfig(ZotHome()) | |
| 137 | + | if err != nil { | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | if cfg.BotToken == "" { | |
| 141 | + | fmt.Println("telegram: not configured (run `zot telegram-bot setup`)") | |
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | maskedTok := maskToken(cfg.BotToken) | |
| 145 | + | fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID) | |
| 146 | + | fmt.Printf("token: %s\n", maskedTok) | |
| 147 | + | if cfg.AllowedUserID == 0 { | |
| 148 | + | fmt.Println("paired with: (unpaired — send /start from telegram to claim)") | |
| 149 | + | } else { | |
| 150 | + | fmt.Printf("paired with: telegram user id %d\n", cfg.AllowedUserID) | |
| 151 | + | } | |
| 152 | + | fmt.Printf("last update: %d\n", cfg.LastUpdateID) | |
| 153 | + | fmt.Printf("config file: %s\n", telegram.ConfigPath(ZotHome())) | |
| 154 | + | ||
| 155 | + | pid, alive, _ := telegram.IsRunning(ZotHome()) | |
| 156 | + | switch { | |
| 157 | + | case alive: | |
| 158 | + | fmt.Printf("process: running (pid %d)\n", pid) | |
| 159 | + | case pid > 0: | |
| 160 | + | fmt.Printf("process: stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZotHome())) | |
| 161 | + | default: | |
| 162 | + | fmt.Println("process: stopped") | |
| 163 | + | } | |
| 164 | + | logPath := telegram.LogPath(ZotHome()) | |
| 165 | + | if fi, err := os.Stat(logPath); err == nil { | |
| 166 | + | fmt.Printf("log file: %s (%d bytes)\n", logPath, fi.Size()) | |
| 167 | + | } | |
| 168 | + | return nil | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // botReset wipes the on-disk bot.json entry. | |
| 172 | + | func botReset() error { | |
| 173 | + | p := telegram.ConfigPath(ZotHome()) | |
| 174 | + | if _, err := os.Stat(p); os.IsNotExist(err) { | |
| 175 | + | fmt.Println("no bot config to reset") | |
| 176 | + | return nil | |
| 177 | + | } | |
| 178 | + | if err := os.Remove(p); err != nil { | |
| 179 | + | return err | |
| 180 | + | } | |
| 181 | + | fmt.Println("removed", p) | |
| 182 | + | return nil | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // botStart launches `zot telegram-bot run` as a detached child process, writes | |
| 186 | + | // its pid to $ZOT_HOME/bot.pid, and returns immediately. Stdout/stderr | |
| 187 | + | // of the child are redirected to $ZOT_HOME/logs/bot.log. | |
| 188 | + | func botStart(rawTail []string) error { | |
| 189 | + | // Refuse to start if another bot is already running. | |
| 190 | + | if pid, alive, _ := telegram.IsRunning(ZotHome()); alive { | |
| 191 | + | return fmt.Errorf("bot is already running (pid %d); use `zot telegram-bot stop` first", pid) | |
| 192 | + | } | |
| 193 | + | _ = telegram.RemovePID(ZotHome()) // clear any stale pid file | |
| 194 | + | ||
| 195 | + | cfg, err := telegram.LoadConfig(ZotHome()) | |
| 196 | + | if err != nil { | |
| 197 | + | return err | |
| 198 | + | } | |
| 199 | + | if cfg.BotToken == "" { | |
| 200 | + | return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first") | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | self, err := os.Executable() | |
| 204 | + | if err != nil { | |
| 205 | + | return fmt.Errorf("locate zot binary: %w", err) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | logPath := telegram.LogPath(ZotHome()) | |
| 209 | + | if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { | |
| 210 | + | return err | |
| 211 | + | } | |
| 212 | + | logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) | |
| 213 | + | if err != nil { | |
| 214 | + | return fmt.Errorf("open log file: %w", err) | |
| 215 | + | } | |
| 216 | + | defer logFile.Close() | |
| 217 | + | ||
| 218 | + | // Refuse to start from a `go run` temp binary: Go deletes the | |
| 219 | + | // binary when `go run` exits, which breaks the detached child. | |
| 220 | + | // Users hit cryptic tls / exec errors on that path; fail clearly. | |
| 221 | + | if strings.Contains(self, string(os.PathSeparator)+"go-build") || | |
| 222 | + | strings.Contains(self, string(os.PathSeparator)+"go-tmp") { | |
| 223 | + | 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) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // Child argv: same flags the user passed to `zot telegram-bot start`, | |
| 227 | + | // mapped to `zot telegram-bot run`. Preserves --provider, --model, --cwd, etc. | |
| 228 | + | args := append([]string{"telegram-bot", "run"}, rawTail...) | |
| 229 | + | cmd := exec.Command(self, args...) | |
| 230 | + | cmd.Stdout = logFile | |
| 231 | + | cmd.Stderr = logFile | |
| 232 | + | cmd.Stdin = nil | |
| 233 | + | // Detach: new session so terminal signals don't reach the child. | |
| 234 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |
| 235 | + | ||
| 236 | + | if err := cmd.Start(); err != nil { | |
| 237 | + | return fmt.Errorf("spawn: %w", err) | |
| 238 | + | } | |
| 239 | + | if err := telegram.WritePID(ZotHome(), cmd.Process.Pid); err != nil { | |
| 240 | + | _ = cmd.Process.Kill() | |
| 241 | + | return fmt.Errorf("write pid: %w", err) | |
| 242 | + | } | |
| 243 | + | // Don't wait() — detach. OS will reparent the child to init when we exit. | |
| 244 | + | go func() { _ = cmd.Process.Release() }() | |
| 245 | + | ||
| 246 | + | fmt.Printf("started zot telegram-bot as pid %d (logs: %s)\n", cmd.Process.Pid, logPath) | |
| 247 | + | fmt.Println("use `zot telegram-bot logs -f` to tail, `zot telegram-bot stop` to stop.") | |
| 248 | + | return nil | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // botStop sends SIGTERM to the running bot (SIGKILL if it doesn't | |
| 252 | + | // exit within 5s) and cleans up the pid file. | |
| 253 | + | func botStop() error { | |
| 254 | + | pid, alive, err := telegram.IsRunning(ZotHome()) | |
| 255 | + | if err != nil { | |
| 256 | + | return err | |
| 257 | + | } | |
| 258 | + | if !alive { | |
| 259 | + | if pid > 0 { | |
| 260 | + | _ = telegram.RemovePID(ZotHome()) | |
| 261 | + | fmt.Printf("no live process; cleared stale pid %d\n", pid) | |
| 262 | + | return nil | |
| 263 | + | } | |
| 264 | + | fmt.Println("bot is not running") | |
| 265 | + | return nil | |
| 266 | + | } | |
| 267 | + | if err := telegram.StopProcess(pid, 5*time.Second); err != nil { | |
| 268 | + | return fmt.Errorf("stop pid %d: %w", pid, err) | |
| 269 | + | } | |
| 270 | + | _ = telegram.RemovePID(ZotHome()) | |
| 271 | + | fmt.Printf("stopped pid %d\n", pid) | |
| 272 | + | return nil | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // botLogs prints (or tails with --follow) the bot log file. | |
| 276 | + | func botLogs(rawTail []string) error { | |
| 277 | + | follow := false | |
| 278 | + | for _, a := range rawTail { | |
| 279 | + | if a == "-f" || a == "--follow" { | |
| 280 | + | follow = true | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | p := telegram.LogPath(ZotHome()) | |
| 284 | + | f, err := os.Open(p) | |
| 285 | + | if errors.Is(err, os.ErrNotExist) { | |
| 286 | + | fmt.Println("no log file at", p) | |
| 287 | + | return nil | |
| 288 | + | } | |
| 289 | + | if err != nil { | |
| 290 | + | return err | |
| 291 | + | } | |
| 292 | + | defer f.Close() | |
| 293 | + | ||
| 294 | + | if _, err := io.Copy(os.Stdout, f); err != nil { | |
| 295 | + | return err | |
| 296 | + | } | |
| 297 | + | if !follow { | |
| 298 | + | return nil | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // Naive tail -f: poll for new bytes until ctrl+c. | |
| 302 | + | sigc := make(chan os.Signal, 1) | |
| 303 | + | signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM) | |
| 304 | + | defer signal.Stop(sigc) | |
| 305 | + | for { | |
| 306 | + | select { | |
| 307 | + | case <-sigc: | |
| 308 | + | return nil | |
| 309 | + | case <-time.After(500 * time.Millisecond): | |
| 310 | + | if _, err := io.Copy(os.Stdout, f); err != nil { | |
| 311 | + | return err | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // botRun starts the polling loop in the foreground. Ctrl+C stops it. | |
| 318 | + | func botRun(rawTail []string, version string) error { | |
| 319 | + | // Parse only a small subset of flags relevant to bot run. We reuse | |
| 320 | + | // the main args parser so --provider/--model/--cwd/--api-key/--reasoning | |
| 321 | + | // behave the same as in the tui. | |
| 322 | + | args, err := ParseArgs(rawTail) | |
| 323 | + | if err != nil { | |
| 324 | + | return err | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | // Bot mode always requires credentials (can't pop a /login dialog). | |
| 328 | + | resolved, err := Resolve(args, true) | |
| 329 | + | if err != nil { | |
| 330 | + | return err | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | cfg, err := telegram.LoadConfig(ZotHome()) | |
| 334 | + | if err != nil { | |
| 335 | + | return err | |
| 336 | + | } | |
| 337 | + | if cfg.BotToken == "" { | |
| 338 | + | return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first") | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | agent := resolved.NewAgent() | |
| 342 | + | ||
| 343 | + | // Session: optional, same model as the tui. Persist so DMs build on | |
| 344 | + | // prior context. --no-session disables. | |
| 345 | + | var sess *core.Session | |
| 346 | + | if !args.NoSess { | |
| 347 | + | s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version) | |
| 348 | + | if serr == nil { | |
| 349 | + | sess = s | |
| 350 | + | defer sess.Close() | |
| 351 | + | } else { | |
| 352 | + | fmt.Fprintln(os.Stderr, "session:", serr) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | b := &telegram.Bot{ | |
| 357 | + | Client: telegram.NewClient(cfg.BotToken), | |
| 358 | + | Agent: agent, | |
| 359 | + | Config: cfg, | |
| 360 | + | ZotHome: ZotHome(), | |
| 361 | + | Save: func(c telegram.Config) error { | |
| 362 | + | return telegram.SaveConfig(ZotHome(), c) | |
| 363 | + | }, | |
| 364 | + | RefreshCreds: func() error { | |
| 365 | + | // Re-run the same resolver the tui uses so we pick up | |
| 366 | + | // refreshed oauth tokens, re-logins, and model switches. | |
| 367 | + | // Only the provider client is swapped — tools, sandbox, | |
| 368 | + | // system prompt, and transcript stay with the existing agent. | |
| 369 | + | next, err := Resolve(args, true) | |
| 370 | + | if err != nil { | |
| 371 | + | return err | |
| 372 | + | } | |
| 373 | + | agent.Client = next.NewClient() | |
| 374 | + | agent.Model = next.Model | |
| 375 | + | return nil | |
| 376 | + | }, | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // Record our pid so `zot telegram-bot status` / `zot telegram-bot stop` can find us, | |
| 380 | + | // regardless of whether we were started directly or via `bot start`. | |
| 381 | + | _ = telegram.WritePID(ZotHome(), os.Getpid()) | |
| 382 | + | defer telegram.RemovePID(ZotHome()) | |
| 383 | + | ||
| 384 | + | // Translate sigterm/sigint into a context cancel so the bot's goroutines | |
| 385 | + | // and the currently-running turn wind down cleanly. | |
| 386 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 387 | + | sigc := make(chan os.Signal, 1) | |
| 388 | + | signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM) | |
| 389 | + | go func() { | |
| 390 | + | <-sigc | |
| 391 | + | cancel() | |
| 392 | + | }() | |
| 393 | + | defer cancel() | |
| 394 | + | return b.Run(ctx) | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | // openOrCreateSessionForBot reuses the same logic as interactive mode | |
| 398 | + | // but never prompts (no TTY picker); falls back to latest or new. | |
| 399 | + | func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, []any, error) { | |
| 400 | + | if args.Continue { | |
| 401 | + | if latest := core.LatestSession(ZotHome(), args.CWD); latest != "" { | |
| 402 | + | s, msgs, err := core.OpenSession(latest) | |
| 403 | + | if err != nil { | |
| 404 | + | return nil, nil, err | |
| 405 | + | } | |
| 406 | + | ag.SetMessages(msgs) | |
| 407 | + | return s, nil, nil | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version) | |
| 411 | + | return s, nil, err | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // maskToken returns "123456:ABC…xyz" so copies of zot telegram-bot status can be | |
| 415 | + | // pasted into bug reports without leaking the full token. | |
| 416 | + | func maskToken(tok string) string { | |
| 417 | + | if len(tok) <= 10 { | |
| 418 | + | return "<hidden>" | |
| 419 | + | } | |
| 420 | + | // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body. | |
| 421 | + | i := strings.IndexByte(tok, ':') | |
| 422 | + | if i < 0 { | |
| 423 | + | return tok[:4] + "…" + tok[len(tok)-4:] | |
| 424 | + | } | |
| 425 | + | body := tok[i+1:] | |
| 426 | + | if len(body) < 8 { | |
| 427 | + | return tok[:i+1] + "<hidden>" | |
| 428 | + | } | |
| 429 | + | return tok[:i+1] + body[:3] + "…" + body[len(body)-3:] | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | // _ compile-time hint so the strconv import stays if we later add numeric parsing. | |
| 433 | + | var _ = strconv.Itoa | |
| @@ -0,0 +1,433 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "os/exec" | |||
| 11 | + | "os/signal" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "strconv" | |||
| 14 | + | "strings" | |||
| 15 | + | "syscall" | |||
| 16 | + | "time" | |||
| 17 | + | ||||
| 18 | + | "github.com/patriceckhart/zot/internal/agent/modes/telegram" | |||
| 19 | + | "github.com/patriceckhart/zot/internal/core" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // runBotCommand dispatches `zot telegram-bot ...` subcommands. The | |||
| 23 | + | // short alias "tg" is also accepted. Returns true if rawArgs begins | |||
| 24 | + | // with a recognised subcommand, false otherwise. | |||
| 25 | + | func runBotCommand(rawArgs []string, version string) (handled bool, err error) { | |||
| 26 | + | if len(rawArgs) == 0 { | |||
| 27 | + | return false, nil | |||
| 28 | + | } | |||
| 29 | + | switch rawArgs[0] { | |||
| 30 | + | case "telegram-bot", "tg": | |||
| 31 | + | // recognised | |||
| 32 | + | default: | |||
| 33 | + | return false, nil | |||
| 34 | + | } | |||
| 35 | + | sub := "" | |||
| 36 | + | var tail []string | |||
| 37 | + | if len(rawArgs) >= 2 { | |||
| 38 | + | sub = rawArgs[1] | |||
| 39 | + | tail = rawArgs[2:] | |||
| 40 | + | } | |||
| 41 | + | switch sub { | |||
| 42 | + | case "", "help", "-h", "--help": | |||
| 43 | + | printBotHelp() | |||
| 44 | + | return true, nil | |||
| 45 | + | case "setup": | |||
| 46 | + | return true, botSetup(tail) | |||
| 47 | + | case "status": | |||
| 48 | + | return true, botStatus() | |||
| 49 | + | case "reset": | |||
| 50 | + | return true, botReset() | |||
| 51 | + | case "run": | |||
| 52 | + | return true, botRun(tail, version) | |||
| 53 | + | case "start": | |||
| 54 | + | return true, botStart(tail) | |||
| 55 | + | case "stop": | |||
| 56 | + | return true, botStop() | |||
| 57 | + | case "logs": | |||
| 58 | + | return true, botLogs(tail) | |||
| 59 | + | default: | |||
| 60 | + | printBotHelp() | |||
| 61 | + | return true, fmt.Errorf("unknown bot subcommand %q", sub) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // printBotHelp prints usage for `zot bot`. | |||
| 66 | + | func printBotHelp() { | |||
| 67 | + | fmt.Fprint(os.Stderr, `zot telegram-bot — telegram bridge | |||
| 68 | + | ||||
| 69 | + | usage: | |||
| 70 | + | zot telegram-bot setup paste a BotFather token, verify, save | |||
| 71 | + | zot telegram-bot status show bridge config and whether it's running | |||
| 72 | + | zot telegram-bot run [flags] run in the foreground (ctrl+c to stop) | |||
| 73 | + | zot telegram-bot start [flags] launch in background, detach, return immediately | |||
| 74 | + | zot telegram-bot stop sigterm the running background bot, sigkill if needed | |||
| 75 | + | zot telegram-bot logs [--follow] tail the background bot's log file | |||
| 76 | + | zot telegram-bot reset forget token + allowed user | |||
| 77 | + | ||||
| 78 | + | setup flow: | |||
| 79 | + | 1. talk to @BotFather on telegram, /newbot, copy the token | |||
| 80 | + | 2. run "zot telegram-bot setup" and paste the token | |||
| 81 | + | 3. run "zot telegram-bot start" (background) or "zot telegram-bot run" (foreground) | |||
| 82 | + | 4. send /start to your bot from telegram; the first sender claims it | |||
| 83 | + | ||||
| 84 | + | while the bot is running, dm it anything and the message is forwarded | |||
| 85 | + | to the agent the same way it would be from the tui. image attachments | |||
| 86 | + | (photos or image/* documents) are passed to vision-capable models. | |||
| 87 | + | telegram commands the bot handles directly: /help, /status, /stop. | |||
| 88 | + | ||||
| 89 | + | config & state: | |||
| 90 | + | $ZOT_HOME/bot.json # bot token + paired user (mode 0600) | |||
| 91 | + | $ZOT_HOME/bot.pid # pid of the running bot (written by run/start) | |||
| 92 | + | $ZOT_HOME/logs/bot.log # stdout+stderr from "zot telegram-bot start" | |||
| 93 | + | `) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // botSetup interactively reads a bot token, verifies it via getMe, and saves it. | |||
| 97 | + | func botSetup(_ []string) error { | |||
| 98 | + | cfg, err := telegram.LoadConfig(ZotHome()) | |||
| 99 | + | if err != nil { | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | fmt.Print("telegram bot token (from @BotFather): ") | |||
| 104 | + | reader := bufio.NewReader(os.Stdin) | |||
| 105 | + | line, err := reader.ReadString('\n') | |||
| 106 | + | if err != nil { | |||
| 107 | + | return err | |||
| 108 | + | } | |||
| 109 | + | token := strings.TrimSpace(line) | |||
| 110 | + | if token == "" { | |||
| 111 | + | return fmt.Errorf("no token provided") | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | client := telegram.NewClient(token) | |||
| 115 | + | me, err := client.GetMe(context.Background()) | |||
| 116 | + | if err != nil { | |||
| 117 | + | return fmt.Errorf("token rejected by telegram: %w", err) | |||
| 118 | + | } | |||
| 119 | + | cfg.BotToken = token | |||
| 120 | + | cfg.BotUsername = me.Username | |||
| 121 | + | cfg.BotID = me.ID | |||
| 122 | + | // Any stored pairing might be for a different bot; clear it. | |||
| 123 | + | cfg.AllowedUserID = 0 | |||
| 124 | + | cfg.LastUpdateID = 0 | |||
| 125 | + | if err := telegram.SaveConfig(ZotHome(), cfg); err != nil { | |||
| 126 | + | return err | |||
| 127 | + | } | |||
| 128 | + | fmt.Printf("\nsaved: @%s (id=%d) to %s\n", me.Username, me.ID, telegram.ConfigPath(ZotHome())) | |||
| 129 | + | fmt.Println("next: run `zot telegram-bot run`, then send /start to your bot from telegram.") | |||
| 130 | + | return nil | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // botStatus prints the current bot config without the token, plus | |||
| 134 | + | // whether the background process is alive. | |||
| 135 | + | func botStatus() error { | |||
| 136 | + | cfg, err := telegram.LoadConfig(ZotHome()) | |||
| 137 | + | if err != nil { | |||
| 138 | + | return err | |||
| 139 | + | } | |||
| 140 | + | if cfg.BotToken == "" { | |||
| 141 | + | fmt.Println("telegram: not configured (run `zot telegram-bot setup`)") | |||
| 142 | + | return nil | |||
| 143 | + | } | |||
| 144 | + | maskedTok := maskToken(cfg.BotToken) | |||
| 145 | + | fmt.Printf("telegram bot: @%s (id=%d)\n", cfg.BotUsername, cfg.BotID) | |||
| 146 | + | fmt.Printf("token: %s\n", maskedTok) | |||
| 147 | + | if cfg.AllowedUserID == 0 { | |||
| 148 | + | fmt.Println("paired with: (unpaired — send /start from telegram to claim)") | |||
| 149 | + | } else { | |||
| 150 | + | fmt.Printf("paired with: telegram user id %d\n", cfg.AllowedUserID) | |||
| 151 | + | } | |||
| 152 | + | fmt.Printf("last update: %d\n", cfg.LastUpdateID) | |||
| 153 | + | fmt.Printf("config file: %s\n", telegram.ConfigPath(ZotHome())) | |||
| 154 | + | ||||
| 155 | + | pid, alive, _ := telegram.IsRunning(ZotHome()) | |||
| 156 | + | switch { | |||
| 157 | + | case alive: | |||
| 158 | + | fmt.Printf("process: running (pid %d)\n", pid) | |||
| 159 | + | case pid > 0: | |||
| 160 | + | fmt.Printf("process: stopped (stale pid %d in %s)\n", pid, telegram.PIDPath(ZotHome())) | |||
| 161 | + | default: | |||
| 162 | + | fmt.Println("process: stopped") | |||
| 163 | + | } | |||
| 164 | + | logPath := telegram.LogPath(ZotHome()) | |||
| 165 | + | if fi, err := os.Stat(logPath); err == nil { | |||
| 166 | + | fmt.Printf("log file: %s (%d bytes)\n", logPath, fi.Size()) | |||
| 167 | + | } | |||
| 168 | + | return nil | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // botReset wipes the on-disk bot.json entry. | |||
| 172 | + | func botReset() error { | |||
| 173 | + | p := telegram.ConfigPath(ZotHome()) | |||
| 174 | + | if _, err := os.Stat(p); os.IsNotExist(err) { | |||
| 175 | + | fmt.Println("no bot config to reset") | |||
| 176 | + | return nil | |||
| 177 | + | } | |||
| 178 | + | if err := os.Remove(p); err != nil { | |||
| 179 | + | return err | |||
| 180 | + | } | |||
| 181 | + | fmt.Println("removed", p) | |||
| 182 | + | return nil | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // botStart launches `zot telegram-bot run` as a detached child process, writes | |||
| 186 | + | // its pid to $ZOT_HOME/bot.pid, and returns immediately. Stdout/stderr | |||
| 187 | + | // of the child are redirected to $ZOT_HOME/logs/bot.log. | |||
| 188 | + | func botStart(rawTail []string) error { | |||
| 189 | + | // Refuse to start if another bot is already running. | |||
| 190 | + | if pid, alive, _ := telegram.IsRunning(ZotHome()); alive { | |||
| 191 | + | return fmt.Errorf("bot is already running (pid %d); use `zot telegram-bot stop` first", pid) | |||
| 192 | + | } | |||
| 193 | + | _ = telegram.RemovePID(ZotHome()) // clear any stale pid file | |||
| 194 | + | ||||
| 195 | + | cfg, err := telegram.LoadConfig(ZotHome()) | |||
| 196 | + | if err != nil { | |||
| 197 | + | return err | |||
| 198 | + | } | |||
| 199 | + | if cfg.BotToken == "" { | |||
| 200 | + | return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first") | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | self, err := os.Executable() | |||
| 204 | + | if err != nil { | |||
| 205 | + | return fmt.Errorf("locate zot binary: %w", err) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | logPath := telegram.LogPath(ZotHome()) | |||
| 209 | + | if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { | |||
| 210 | + | return err | |||
| 211 | + | } | |||
| 212 | + | logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) | |||
| 213 | + | if err != nil { | |||
| 214 | + | return fmt.Errorf("open log file: %w", err) | |||
| 215 | + | } | |||
| 216 | + | defer logFile.Close() | |||
| 217 | + | ||||
| 218 | + | // Refuse to start from a `go run` temp binary: Go deletes the | |||
| 219 | + | // binary when `go run` exits, which breaks the detached child. | |||
| 220 | + | // Users hit cryptic tls / exec errors on that path; fail clearly. | |||
| 221 | + | if strings.Contains(self, string(os.PathSeparator)+"go-build") || | |||
| 222 | + | strings.Contains(self, string(os.PathSeparator)+"go-tmp") { | |||
| 223 | + | 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) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // Child argv: same flags the user passed to `zot telegram-bot start`, | |||
| 227 | + | // mapped to `zot telegram-bot run`. Preserves --provider, --model, --cwd, etc. | |||
| 228 | + | args := append([]string{"telegram-bot", "run"}, rawTail...) | |||
| 229 | + | cmd := exec.Command(self, args...) | |||
| 230 | + | cmd.Stdout = logFile | |||
| 231 | + | cmd.Stderr = logFile | |||
| 232 | + | cmd.Stdin = nil | |||
| 233 | + | // Detach: new session so terminal signals don't reach the child. | |||
| 234 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |||
| 235 | + | ||||
| 236 | + | if err := cmd.Start(); err != nil { | |||
| 237 | + | return fmt.Errorf("spawn: %w", err) | |||
| 238 | + | } | |||
| 239 | + | if err := telegram.WritePID(ZotHome(), cmd.Process.Pid); err != nil { | |||
| 240 | + | _ = cmd.Process.Kill() | |||
| 241 | + | return fmt.Errorf("write pid: %w", err) | |||
| 242 | + | } | |||
| 243 | + | // Don't wait() — detach. OS will reparent the child to init when we exit. | |||
| 244 | + | go func() { _ = cmd.Process.Release() }() | |||
| 245 | + | ||||
| 246 | + | fmt.Printf("started zot telegram-bot as pid %d (logs: %s)\n", cmd.Process.Pid, logPath) | |||
| 247 | + | fmt.Println("use `zot telegram-bot logs -f` to tail, `zot telegram-bot stop` to stop.") | |||
| 248 | + | return nil | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // botStop sends SIGTERM to the running bot (SIGKILL if it doesn't | |||
| 252 | + | // exit within 5s) and cleans up the pid file. | |||
| 253 | + | func botStop() error { | |||
| 254 | + | pid, alive, err := telegram.IsRunning(ZotHome()) | |||
| 255 | + | if err != nil { | |||
| 256 | + | return err | |||
| 257 | + | } | |||
| 258 | + | if !alive { | |||
| 259 | + | if pid > 0 { | |||
| 260 | + | _ = telegram.RemovePID(ZotHome()) | |||
| 261 | + | fmt.Printf("no live process; cleared stale pid %d\n", pid) | |||
| 262 | + | return nil | |||
| 263 | + | } | |||
| 264 | + | fmt.Println("bot is not running") | |||
| 265 | + | return nil | |||
| 266 | + | } | |||
| 267 | + | if err := telegram.StopProcess(pid, 5*time.Second); err != nil { | |||
| 268 | + | return fmt.Errorf("stop pid %d: %w", pid, err) | |||
| 269 | + | } | |||
| 270 | + | _ = telegram.RemovePID(ZotHome()) | |||
| 271 | + | fmt.Printf("stopped pid %d\n", pid) | |||
| 272 | + | return nil | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // botLogs prints (or tails with --follow) the bot log file. | |||
| 276 | + | func botLogs(rawTail []string) error { | |||
| 277 | + | follow := false | |||
| 278 | + | for _, a := range rawTail { | |||
| 279 | + | if a == "-f" || a == "--follow" { | |||
| 280 | + | follow = true | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | p := telegram.LogPath(ZotHome()) | |||
| 284 | + | f, err := os.Open(p) | |||
| 285 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 286 | + | fmt.Println("no log file at", p) | |||
| 287 | + | return nil | |||
| 288 | + | } | |||
| 289 | + | if err != nil { | |||
| 290 | + | return err | |||
| 291 | + | } | |||
| 292 | + | defer f.Close() | |||
| 293 | + | ||||
| 294 | + | if _, err := io.Copy(os.Stdout, f); err != nil { | |||
| 295 | + | return err | |||
| 296 | + | } | |||
| 297 | + | if !follow { | |||
| 298 | + | return nil | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // Naive tail -f: poll for new bytes until ctrl+c. | |||
| 302 | + | sigc := make(chan os.Signal, 1) | |||
| 303 | + | signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM) | |||
| 304 | + | defer signal.Stop(sigc) | |||
| 305 | + | for { | |||
| 306 | + | select { | |||
| 307 | + | case <-sigc: | |||
| 308 | + | return nil | |||
| 309 | + | case <-time.After(500 * time.Millisecond): | |||
| 310 | + | if _, err := io.Copy(os.Stdout, f); err != nil { | |||
| 311 | + | return err | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // botRun starts the polling loop in the foreground. Ctrl+C stops it. | |||
| 318 | + | func botRun(rawTail []string, version string) error { | |||
| 319 | + | // Parse only a small subset of flags relevant to bot run. We reuse | |||
| 320 | + | // the main args parser so --provider/--model/--cwd/--api-key/--reasoning | |||
| 321 | + | // behave the same as in the tui. | |||
| 322 | + | args, err := ParseArgs(rawTail) | |||
| 323 | + | if err != nil { | |||
| 324 | + | return err | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | // Bot mode always requires credentials (can't pop a /login dialog). | |||
| 328 | + | resolved, err := Resolve(args, true) | |||
| 329 | + | if err != nil { | |||
| 330 | + | return err | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | cfg, err := telegram.LoadConfig(ZotHome()) | |||
| 334 | + | if err != nil { | |||
| 335 | + | return err | |||
| 336 | + | } | |||
| 337 | + | if cfg.BotToken == "" { | |||
| 338 | + | return fmt.Errorf("no bot token configured — run `zot telegram-bot setup` first") | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | agent := resolved.NewAgent() | |||
| 342 | + | ||||
| 343 | + | // Session: optional, same model as the tui. Persist so DMs build on | |||
| 344 | + | // prior context. --no-session disables. | |||
| 345 | + | var sess *core.Session | |||
| 346 | + | if !args.NoSess { | |||
| 347 | + | s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version) | |||
| 348 | + | if serr == nil { | |||
| 349 | + | sess = s | |||
| 350 | + | defer sess.Close() | |||
| 351 | + | } else { | |||
| 352 | + | fmt.Fprintln(os.Stderr, "session:", serr) | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | b := &telegram.Bot{ | |||
| 357 | + | Client: telegram.NewClient(cfg.BotToken), | |||
| 358 | + | Agent: agent, | |||
| 359 | + | Config: cfg, | |||
| 360 | + | ZotHome: ZotHome(), | |||
| 361 | + | Save: func(c telegram.Config) error { | |||
| 362 | + | return telegram.SaveConfig(ZotHome(), c) | |||
| 363 | + | }, | |||
| 364 | + | RefreshCreds: func() error { | |||
| 365 | + | // Re-run the same resolver the tui uses so we pick up | |||
| 366 | + | // refreshed oauth tokens, re-logins, and model switches. | |||
| 367 | + | // Only the provider client is swapped — tools, sandbox, | |||
| 368 | + | // system prompt, and transcript stay with the existing agent. | |||
| 369 | + | next, err := Resolve(args, true) | |||
| 370 | + | if err != nil { | |||
| 371 | + | return err | |||
| 372 | + | } | |||
| 373 | + | agent.Client = next.NewClient() | |||
| 374 | + | agent.Model = next.Model | |||
| 375 | + | return nil | |||
| 376 | + | }, | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | // Record our pid so `zot telegram-bot status` / `zot telegram-bot stop` can find us, | |||
| 380 | + | // regardless of whether we were started directly or via `bot start`. | |||
| 381 | + | _ = telegram.WritePID(ZotHome(), os.Getpid()) | |||
| 382 | + | defer telegram.RemovePID(ZotHome()) | |||
| 383 | + | ||||
| 384 | + | // Translate sigterm/sigint into a context cancel so the bot's goroutines | |||
| 385 | + | // and the currently-running turn wind down cleanly. | |||
| 386 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 387 | + | sigc := make(chan os.Signal, 1) | |||
| 388 | + | signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM) | |||
| 389 | + | go func() { | |||
| 390 | + | <-sigc | |||
| 391 | + | cancel() | |||
| 392 | + | }() | |||
| 393 | + | defer cancel() | |||
| 394 | + | return b.Run(ctx) | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | // openOrCreateSessionForBot reuses the same logic as interactive mode | |||
| 398 | + | // but never prompts (no TTY picker); falls back to latest or new. | |||
| 399 | + | func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, []any, error) { | |||
| 400 | + | if args.Continue { | |||
| 401 | + | if latest := core.LatestSession(ZotHome(), args.CWD); latest != "" { | |||
| 402 | + | s, msgs, err := core.OpenSession(latest) | |||
| 403 | + | if err != nil { | |||
| 404 | + | return nil, nil, err | |||
| 405 | + | } | |||
| 406 | + | ag.SetMessages(msgs) | |||
| 407 | + | return s, nil, nil | |||
| 408 | + | } | |||
| 409 | + | } | |||
| 410 | + | s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version) | |||
| 411 | + | return s, nil, err | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | // maskToken returns "123456:ABC…xyz" so copies of zot telegram-bot status can be | |||
| 415 | + | // pasted into bug reports without leaking the full token. | |||
| 416 | + | func maskToken(tok string) string { | |||
| 417 | + | if len(tok) <= 10 { | |||
| 418 | + | return "<hidden>" | |||
| 419 | + | } | |||
| 420 | + | // telegram tokens look like "123456789:ABCD..." — keep the id, mask the body. | |||
| 421 | + | i := strings.IndexByte(tok, ':') | |||
| 422 | + | if i < 0 { | |||
| 423 | + | return tok[:4] + "…" + tok[len(tok)-4:] | |||
| 424 | + | } | |||
| 425 | + | body := tok[i+1:] | |||
| 426 | + | if len(body) < 8 { | |||
| 427 | + | return tok[:i+1] + "<hidden>" | |||
| 428 | + | } | |||
| 429 | + | return tok[:i+1] + body[:3] + "…" + body[len(body)-3:] | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | // _ compile-time hint so the strconv import stays if we later add numeric parsing. | |||
| 433 | + | var _ = strconv.Itoa | |||
M internal/agent/cli.go +6 -0
| @@ -17,6 +17,12 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // Run is the top-level entrypoint for the zot binary. | |
| 19 | 19 | func Run(rawArgs []string, version string) error { | |
| 20 | + | // Subcommand router: `zot bot ...` is handled separately so the | |
| 21 | + | // generic flag parser doesn't reject "bot" as a positional arg. | |
| 22 | + | if handled, err := runBotCommand(rawArgs, version); handled { | |
| 23 | + | return err | |
| 24 | + | } | |
| 25 | + | ||
| 20 | 26 | args, err := ParseArgs(rawArgs) | |
| 21 | 27 | if err != nil { | |
| 22 | 28 | PrintHelp(version) | |
| @@ -17,6 +17,12 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | // Run is the top-level entrypoint for the zot binary. | 18 | // Run is the top-level entrypoint for the zot binary. | ||
| 19 | func Run(rawArgs []string, version string) error { | 19 | func Run(rawArgs []string, version string) error { | ||
| 20 | + | // Subcommand router: `zot bot ...` is handled separately so the | |||
| 21 | + | // generic flag parser doesn't reject "bot" as a positional arg. | |||
| 22 | + | if handled, err := runBotCommand(rawArgs, version); handled { | |||
| 23 | + | return err | |||
| 24 | + | } | |||
| 25 | + | ||||
| 20 | args, err := ParseArgs(rawArgs) | 26 | args, err := ParseArgs(rawArgs) | ||
| 21 | if err != nil { | 27 | if err != nil { | ||
| 22 | PrintHelp(version) | 28 | PrintHelp(version) | ||
M internal/agent/config.go +57 -2
| @@ -2,12 +2,14 @@ | |||
| 2 | 2 | package agent | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "context" | |
| 5 | 6 | "encoding/json" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "os" | |
| 9 | 10 | "path/filepath" | |
| 10 | 11 | "runtime" | |
| 12 | + | "time" | |
| 11 | 13 | ||
| 12 | 14 | "github.com/patriceckhart/zot/internal/auth" | |
| 13 | 15 | ) | |
| @@ -131,15 +133,68 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 131 | 133 | return c.Anthropic.APIKey, "apikey", "", nil | |
| 132 | 134 | } | |
| 133 | 135 | if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" { | |
| 134 | - | return c.Anthropic.OAuth.AccessToken, "oauth", "", nil | |
| 136 | + | tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth) | |
| 137 | + | return tok.AccessToken, "oauth", "", nil | |
| 135 | 138 | } | |
| 136 | 139 | case "openai": | |
| 137 | 140 | if c.OpenAI.APIKey != "" { | |
| 138 | 141 | return c.OpenAI.APIKey, "apikey", "", nil | |
| 139 | 142 | } | |
| 140 | 143 | if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" { | |
| 141 | - | return c.OpenAI.OAuth.AccessToken, "oauth", c.OpenAI.OAuth.AccountID, nil | |
| 144 | + | tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) | |
| 145 | + | return tok.AccessToken, "oauth", tok.AccountID, nil | |
| 142 | 146 | } | |
| 143 | 147 | } | |
| 144 | 148 | return "", "", "", fmt.Errorf("no credential for %s", provider) | |
| 145 | 149 | } | |
| 150 | + | ||
| 151 | + | // refreshIfExpired returns a usable OAuth token for the given provider, | |
| 152 | + | // refreshing it synchronously when it's past (or near) expiry. The | |
| 153 | + | // refreshed token is persisted to auth.json. | |
| 154 | + | // | |
| 155 | + | // Failures return the original token unchanged — the caller then makes | |
| 156 | + | // a request with the stale access_token, which will 401. That's still | |
| 157 | + | // better than crashing at credential-resolution time. | |
| 158 | + | func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) { | |
| 159 | + | if tok == nil { | |
| 160 | + | return &auth.OAuthToken{}, fmt.Errorf("nil token") | |
| 161 | + | } | |
| 162 | + | if !tok.Expired() { | |
| 163 | + | return tok, nil | |
| 164 | + | } | |
| 165 | + | if tok.RefreshToken == "" { | |
| 166 | + | return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | var op auth.OAuthProvider | |
| 170 | + | switch providerName { | |
| 171 | + | case "anthropic": | |
| 172 | + | op = auth.AnthropicOAuth | |
| 173 | + | case "openai": | |
| 174 | + | op = auth.OpenAIOAuth | |
| 175 | + | default: | |
| 176 | + | return tok, fmt.Errorf("unknown provider %q", providerName) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 180 | + | defer cancel() | |
| 181 | + | next, err := op.Refresh(ctx, tok.RefreshToken) | |
| 182 | + | if err != nil { | |
| 183 | + | return tok, fmt.Errorf("refresh %s: %w", providerName, err) | |
| 184 | + | } | |
| 185 | + | // Preserve the refresh token if the server omitted it (Anthropic often does). | |
| 186 | + | if next.RefreshToken == "" { | |
| 187 | + | next.RefreshToken = tok.RefreshToken | |
| 188 | + | } | |
| 189 | + | // Carry over account id (openai) / id_token across refreshes. | |
| 190 | + | if next.AccountID == "" { | |
| 191 | + | next.AccountID = tok.AccountID | |
| 192 | + | } | |
| 193 | + | if next.IDToken == "" { | |
| 194 | + | next.IDToken = tok.IDToken | |
| 195 | + | } | |
| 196 | + | if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil { | |
| 197 | + | return next, fmt.Errorf("persist refreshed token: %w", err) | |
| 198 | + | } | |
| 199 | + | return next, nil | |
| 200 | + | } | |
| @@ -2,12 +2,14 @@ | |||||
| 2 | package agent | 2 | package agent | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "context" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "os" | 9 | "os" | ||
| 9 | "path/filepath" | 10 | "path/filepath" | ||
| 10 | "runtime" | 11 | "runtime" | ||
| 12 | + | "time" | |||
| 11 | 13 | ||||
| 12 | "github.com/patriceckhart/zot/internal/auth" | 14 | "github.com/patriceckhart/zot/internal/auth" | ||
| 13 | ) | 15 | ) | ||
| @@ -131,15 +133,68 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||||
| 131 | return c.Anthropic.APIKey, "apikey", "", nil | 133 | return c.Anthropic.APIKey, "apikey", "", nil | ||
| 132 | } | 134 | } | ||
| 133 | if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" { | 135 | if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" { | ||
| 134 | - | return c.Anthropic.OAuth.AccessToken, "oauth", "", nil | 136 | + | tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth) |
| 137 | + | return tok.AccessToken, "oauth", "", nil | |||
| 135 | } | 138 | } | ||
| 136 | case "openai": | 139 | case "openai": | ||
| 137 | if c.OpenAI.APIKey != "" { | 140 | if c.OpenAI.APIKey != "" { | ||
| 138 | return c.OpenAI.APIKey, "apikey", "", nil | 141 | return c.OpenAI.APIKey, "apikey", "", nil | ||
| 139 | } | 142 | } | ||
| 140 | if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" { | 143 | if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" { | ||
| 141 | - | return c.OpenAI.OAuth.AccessToken, "oauth", c.OpenAI.OAuth.AccountID, nil | 144 | + | tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) |
| 145 | + | return tok.AccessToken, "oauth", tok.AccountID, nil | |||
| 142 | } | 146 | } | ||
| 143 | } | 147 | } | ||
| 144 | return "", "", "", fmt.Errorf("no credential for %s", provider) | 148 | return "", "", "", fmt.Errorf("no credential for %s", provider) | ||
| 145 | } | 149 | } | ||
| 150 | + | ||||
| 151 | + | // refreshIfExpired returns a usable OAuth token for the given provider, | |||
| 152 | + | // refreshing it synchronously when it's past (or near) expiry. The | |||
| 153 | + | // refreshed token is persisted to auth.json. | |||
| 154 | + | // | |||
| 155 | + | // Failures return the original token unchanged — the caller then makes | |||
| 156 | + | // a request with the stale access_token, which will 401. That's still | |||
| 157 | + | // better than crashing at credential-resolution time. | |||
| 158 | + | func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) { | |||
| 159 | + | if tok == nil { | |||
| 160 | + | return &auth.OAuthToken{}, fmt.Errorf("nil token") | |||
| 161 | + | } | |||
| 162 | + | if !tok.Expired() { | |||
| 163 | + | return tok, nil | |||
| 164 | + | } | |||
| 165 | + | if tok.RefreshToken == "" { | |||
| 166 | + | return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | var op auth.OAuthProvider | |||
| 170 | + | switch providerName { | |||
| 171 | + | case "anthropic": | |||
| 172 | + | op = auth.AnthropicOAuth | |||
| 173 | + | case "openai": | |||
| 174 | + | op = auth.OpenAIOAuth | |||
| 175 | + | default: | |||
| 176 | + | return tok, fmt.Errorf("unknown provider %q", providerName) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |||
| 180 | + | defer cancel() | |||
| 181 | + | next, err := op.Refresh(ctx, tok.RefreshToken) | |||
| 182 | + | if err != nil { | |||
| 183 | + | return tok, fmt.Errorf("refresh %s: %w", providerName, err) | |||
| 184 | + | } | |||
| 185 | + | // Preserve the refresh token if the server omitted it (Anthropic often does). | |||
| 186 | + | if next.RefreshToken == "" { | |||
| 187 | + | next.RefreshToken = tok.RefreshToken | |||
| 188 | + | } | |||
| 189 | + | // Carry over account id (openai) / id_token across refreshes. | |||
| 190 | + | if next.AccountID == "" { | |||
| 191 | + | next.AccountID = tok.AccountID | |||
| 192 | + | } | |||
| 193 | + | if next.IDToken == "" { | |||
| 194 | + | next.IDToken = tok.IDToken | |||
| 195 | + | } | |||
| 196 | + | if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil { | |||
| 197 | + | return next, fmt.Errorf("persist refreshed token: %w", err) | |||
| 198 | + | } | |||
| 199 | + | return next, nil | |||
| 200 | + | } | |||
M internal/agent/modes/model_dialog.go +105 -19
| @@ -2,6 +2,8 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "sort" | |
| 6 | + | "strings" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/patriceckhart/zot/internal/provider" | |
| 7 | 9 | "github.com/patriceckhart/zot/internal/tui" | |
| @@ -9,13 +11,17 @@ import ( | |||
| 9 | 11 | ||
| 10 | 12 | // modelDialog is an inline picker for choosing the active model. | |
| 11 | 13 | // It lists all models known to the provider package (baked-in catalog | |
| 12 | - | // + any live entries discovered via /v1/models) and lets the user pick | |
| 13 | - | // one with arrow keys + enter. | |
| 14 | + | // + any live entries discovered via /v1/models) sorted by provider | |
| 15 | + | // then model id, and lets the user pick one with arrow keys + enter. | |
| 16 | + | // Typing characters narrows the list via a fuzzy substring match that | |
| 17 | + | // ignores punctuation (e.g. "opus46" matches "claude-opus-4-6"). | |
| 14 | 18 | type modelDialog struct { | |
| 15 | 19 | active bool | |
| 16 | - | models []provider.Model | |
| 20 | + | all []provider.Model // full catalog, sorted | |
| 21 | + | view []provider.Model // filtered view shown to the user | |
| 17 | 22 | cursor int | |
| 18 | 23 | current string // currently selected model id (highlighted) | |
| 24 | + | query string // live filter text typed by the user | |
| 19 | 25 | } | |
| 20 | 26 | ||
| 21 | 27 | // modelDialogAction is returned by HandleKey. | |
| @@ -34,22 +40,70 @@ func newModelDialog() *modelDialog { | |||
| 34 | 40 | // it can be pre-selected. | |
| 35 | 41 | func (d *modelDialog) Open(current string) { | |
| 36 | 42 | d.active = true | |
| 37 | - | d.models = provider.Active() | |
| 43 | + | d.all = sortedModels(provider.Active()) | |
| 38 | 44 | d.current = current | |
| 45 | + | d.query = "" | |
| 46 | + | d.refilter() | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Close hides the dialog. | |
| 50 | + | func (d *modelDialog) Close() { d.active = false } | |
| 51 | + | ||
| 52 | + | // Active reports whether the dialog is visible and consumes input. | |
| 53 | + | func (d *modelDialog) Active() bool { return d != nil && d.active } | |
| 54 | + | ||
| 55 | + | // refilter rebuilds view from all according to query, and snaps the | |
| 56 | + | // cursor to either the current model (if visible) or the first row. | |
| 57 | + | func (d *modelDialog) refilter() { | |
| 58 | + | needle := normalizeModelQuery(d.query) | |
| 59 | + | if needle == "" { | |
| 60 | + | d.view = append([]provider.Model(nil), d.all...) | |
| 61 | + | } else { | |
| 62 | + | out := make([]provider.Model, 0, len(d.all)) | |
| 63 | + | for _, m := range d.all { | |
| 64 | + | if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) { | |
| 65 | + | out = append(out, m) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | d.view = out | |
| 69 | + | } | |
| 39 | 70 | d.cursor = 0 | |
| 40 | - | for i, m := range d.models { | |
| 41 | - | if m.ID == current { | |
| 71 | + | for i, m := range d.view { | |
| 72 | + | if m.ID == d.current { | |
| 42 | 73 | d.cursor = i | |
| 43 | 74 | break | |
| 44 | 75 | } | |
| 45 | 76 | } | |
| 46 | 77 | } | |
| 47 | 78 | ||
| 48 | - | // Close hides the dialog. | |
| 49 | - | func (d *modelDialog) Close() { d.active = false } | |
| 79 | + | // sortedModels returns a fresh slice sorted by provider, then model id. | |
| 80 | + | func sortedModels(in []provider.Model) []provider.Model { | |
| 81 | + | out := append([]provider.Model(nil), in...) | |
| 82 | + | sort.SliceStable(out, func(i, j int) bool { | |
| 83 | + | if out[i].Provider != out[j].Provider { | |
| 84 | + | return out[i].Provider < out[j].Provider | |
| 85 | + | } | |
| 86 | + | return out[i].ID < out[j].ID | |
| 87 | + | }) | |
| 88 | + | return out | |
| 89 | + | } | |
| 50 | 90 | ||
| 51 | - | // Active reports whether the dialog is visible and consumes input. | |
| 52 | - | func (d *modelDialog) Active() bool { return d != nil && d.active } | |
| 91 | + | // normalizeModelQuery lowercases and strips punctuation so fuzzy | |
| 92 | + | // substring matching works on both the query and haystacks. "opus46" | |
| 93 | + | // and "opus-4-6" both become "opus46". | |
| 94 | + | func normalizeModelQuery(s string) string { | |
| 95 | + | var sb strings.Builder | |
| 96 | + | sb.Grow(len(s)) | |
| 97 | + | for _, r := range s { | |
| 98 | + | switch { | |
| 99 | + | case r >= 'a' && r <= 'z', r >= '0' && r <= '9': | |
| 100 | + | sb.WriteRune(r) | |
| 101 | + | case r >= 'A' && r <= 'Z': | |
| 102 | + | sb.WriteRune(r + ('a' - 'A')) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | return sb.String() | |
| 106 | + | } | |
| 53 | 107 | ||
| 54 | 108 | // Render returns the dialog lines. | |
| 55 | 109 | func (d *modelDialog) Render(th tui.Theme, width int) []string { | |
| @@ -58,12 +112,28 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||
| 58 | 112 | } | |
| 59 | 113 | var lines []string | |
| 60 | 114 | lines = append(lines, frameHeader(th, "model", width)) | |
| 61 | - | lines = append(lines, th.FG256(th.Muted, "pick a model (↑/↓, enter, esc to cancel)")) | |
| 62 | 115 | ||
| 63 | - | // Simple scroll window so very tall catalogs still fit in a short tui. | |
| 116 | + | hint := "pick a model (↑/↓, enter, esc to cancel)" | |
| 117 | + | if d.query != "" { | |
| 118 | + | hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view)) | |
| 119 | + | if len(d.view) != 1 { | |
| 120 | + | hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view)) | |
| 121 | + | } | |
| 122 | + | } else { | |
| 123 | + | hint += " · type to filter" | |
| 124 | + | } | |
| 125 | + | lines = append(lines, th.FG256(th.Muted, hint)) | |
| 126 | + | ||
| 127 | + | if len(d.view) == 0 { | |
| 128 | + | lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query))) | |
| 129 | + | lines = append(lines, frameRule(th, width)) | |
| 130 | + | return lines | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Scroll window so very tall catalogs still fit in a short tui. | |
| 64 | 134 | const visible = 14 | |
| 65 | 135 | start := 0 | |
| 66 | - | end := len(d.models) | |
| 136 | + | end := len(d.view) | |
| 67 | 137 | if end > visible { | |
| 68 | 138 | start = d.cursor - visible/2 | |
| 69 | 139 | if start < 0 { | |
| @@ -76,7 +146,7 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||
| 76 | 146 | } | |
| 77 | 147 | ||
| 78 | 148 | for i := start; i < end; i++ { | |
| 79 | - | m := d.models[i] | |
| 149 | + | m := d.view[i] | |
| 80 | 150 | prov := m.Provider | |
| 81 | 151 | id := m.ID | |
| 82 | 152 | reason := " " | |
| @@ -106,8 +176,8 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||
| 106 | 176 | if start > 0 { | |
| 107 | 177 | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start))) | |
| 108 | 178 | } | |
| 109 | - | if end < len(d.models) { | |
| 110 | - | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.models)-end))) | |
| 179 | + | if end < len(d.view) { | |
| 180 | + | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end))) | |
| 111 | 181 | } | |
| 112 | 182 | ||
| 113 | 183 | lines = append(lines, frameRule(th, width)) | |
| @@ -122,18 +192,34 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction { | |||
| 122 | 192 | d.cursor-- | |
| 123 | 193 | } | |
| 124 | 194 | case tui.KeyDown: | |
| 125 | - | if d.cursor < len(d.models)-1 { | |
| 195 | + | if d.cursor < len(d.view)-1 { | |
| 126 | 196 | d.cursor++ | |
| 127 | 197 | } | |
| 198 | + | case tui.KeyBackspace: | |
| 199 | + | if len(d.query) > 0 { | |
| 200 | + | // Drop one rune from the query. | |
| 201 | + | r := []rune(d.query) | |
| 202 | + | d.query = string(r[:len(r)-1]) | |
| 203 | + | d.refilter() | |
| 204 | + | } | |
| 205 | + | case tui.KeyRune: | |
| 206 | + | if k.Alt || k.Ctrl { | |
| 207 | + | break | |
| 208 | + | } | |
| 209 | + | // Only printable ASCII is useful for narrowing. | |
| 210 | + | if k.Rune >= 0x20 && k.Rune < 0x7f { | |
| 211 | + | d.query += string(k.Rune) | |
| 212 | + | d.refilter() | |
| 213 | + | } | |
| 128 | 214 | case tui.KeyEsc: | |
| 129 | 215 | d.Close() | |
| 130 | 216 | return modelDialogAction{Close: true} | |
| 131 | 217 | case tui.KeyEnter: | |
| 132 | - | if len(d.models) == 0 { | |
| 218 | + | if len(d.view) == 0 { | |
| 133 | 219 | d.Close() | |
| 134 | 220 | return modelDialogAction{Close: true} | |
| 135 | 221 | } | |
| 136 | - | m := d.models[d.cursor] | |
| 222 | + | m := d.view[d.cursor] | |
| 137 | 223 | d.Close() | |
| 138 | 224 | return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID} | |
| 139 | 225 | } | |
| @@ -2,6 +2,8 @@ package modes | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "sort" | |||
| 6 | + | "strings" | |||
| 5 | 7 | ||||
| 6 | "github.com/patriceckhart/zot/internal/provider" | 8 | "github.com/patriceckhart/zot/internal/provider" | ||
| 7 | "github.com/patriceckhart/zot/internal/tui" | 9 | "github.com/patriceckhart/zot/internal/tui" | ||
| @@ -9,13 +11,17 @@ import ( | |||||
| 9 | 11 | ||||
| 10 | // modelDialog is an inline picker for choosing the active model. | 12 | // modelDialog is an inline picker for choosing the active model. | ||
| 11 | // It lists all models known to the provider package (baked-in catalog | 13 | // It lists all models known to the provider package (baked-in catalog | ||
| 12 | - | // + any live entries discovered via /v1/models) and lets the user pick | 14 | + | // + any live entries discovered via /v1/models) sorted by provider |
| 13 | - | // one with arrow keys + enter. | 15 | + | // then model id, and lets the user pick one with arrow keys + enter. |
| 16 | + | // Typing characters narrows the list via a fuzzy substring match that | |||
| 17 | + | // ignores punctuation (e.g. "opus46" matches "claude-opus-4-6"). | |||
| 14 | type modelDialog struct { | 18 | type modelDialog struct { | ||
| 15 | active bool | 19 | active bool | ||
| 16 | - | models []provider.Model | 20 | + | all []provider.Model // full catalog, sorted |
| 21 | + | view []provider.Model // filtered view shown to the user | |||
| 17 | cursor int | 22 | cursor int | ||
| 18 | current string // currently selected model id (highlighted) | 23 | current string // currently selected model id (highlighted) | ||
| 24 | + | query string // live filter text typed by the user | |||
| 19 | } | 25 | } | ||
| 20 | 26 | ||||
| 21 | // modelDialogAction is returned by HandleKey. | 27 | // modelDialogAction is returned by HandleKey. | ||
| @@ -34,22 +40,70 @@ func newModelDialog() *modelDialog { | |||||
| 34 | // it can be pre-selected. | 40 | // it can be pre-selected. | ||
| 35 | func (d *modelDialog) Open(current string) { | 41 | func (d *modelDialog) Open(current string) { | ||
| 36 | d.active = true | 42 | d.active = true | ||
| 37 | - | d.models = provider.Active() | 43 | + | d.all = sortedModels(provider.Active()) |
| 38 | d.current = current | 44 | d.current = current | ||
| 45 | + | d.query = "" | |||
| 46 | + | d.refilter() | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Close hides the dialog. | |||
| 50 | + | func (d *modelDialog) Close() { d.active = false } | |||
| 51 | + | ||||
| 52 | + | // Active reports whether the dialog is visible and consumes input. | |||
| 53 | + | func (d *modelDialog) Active() bool { return d != nil && d.active } | |||
| 54 | + | ||||
| 55 | + | // refilter rebuilds view from all according to query, and snaps the | |||
| 56 | + | // cursor to either the current model (if visible) or the first row. | |||
| 57 | + | func (d *modelDialog) refilter() { | |||
| 58 | + | needle := normalizeModelQuery(d.query) | |||
| 59 | + | if needle == "" { | |||
| 60 | + | d.view = append([]provider.Model(nil), d.all...) | |||
| 61 | + | } else { | |||
| 62 | + | out := make([]provider.Model, 0, len(d.all)) | |||
| 63 | + | for _, m := range d.all { | |||
| 64 | + | if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) { | |||
| 65 | + | out = append(out, m) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | d.view = out | |||
| 69 | + | } | |||
| 39 | d.cursor = 0 | 70 | d.cursor = 0 | ||
| 40 | - | for i, m := range d.models { | 71 | + | for i, m := range d.view { |
| 41 | - | if m.ID == current { | 72 | + | if m.ID == d.current { |
| 42 | d.cursor = i | 73 | d.cursor = i | ||
| 43 | break | 74 | break | ||
| 44 | } | 75 | } | ||
| 45 | } | 76 | } | ||
| 46 | } | 77 | } | ||
| 47 | 78 | ||||
| 48 | - | // Close hides the dialog. | 79 | + | // sortedModels returns a fresh slice sorted by provider, then model id. |
| 49 | - | func (d *modelDialog) Close() { d.active = false } | 80 | + | func sortedModels(in []provider.Model) []provider.Model { |
| 81 | + | out := append([]provider.Model(nil), in...) | |||
| 82 | + | sort.SliceStable(out, func(i, j int) bool { | |||
| 83 | + | if out[i].Provider != out[j].Provider { | |||
| 84 | + | return out[i].Provider < out[j].Provider | |||
| 85 | + | } | |||
| 86 | + | return out[i].ID < out[j].ID | |||
| 87 | + | }) | |||
| 88 | + | return out | |||
| 89 | + | } | |||
| 50 | 90 | ||||
| 51 | - | // Active reports whether the dialog is visible and consumes input. | 91 | + | // normalizeModelQuery lowercases and strips punctuation so fuzzy |
| 52 | - | func (d *modelDialog) Active() bool { return d != nil && d.active } | 92 | + | // substring matching works on both the query and haystacks. "opus46" |
| 93 | + | // and "opus-4-6" both become "opus46". | |||
| 94 | + | func normalizeModelQuery(s string) string { | |||
| 95 | + | var sb strings.Builder | |||
| 96 | + | sb.Grow(len(s)) | |||
| 97 | + | for _, r := range s { | |||
| 98 | + | switch { | |||
| 99 | + | case r >= 'a' && r <= 'z', r >= '0' && r <= '9': | |||
| 100 | + | sb.WriteRune(r) | |||
| 101 | + | case r >= 'A' && r <= 'Z': | |||
| 102 | + | sb.WriteRune(r + ('a' - 'A')) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | return sb.String() | |||
| 106 | + | } | |||
| 53 | 107 | ||||
| 54 | // Render returns the dialog lines. | 108 | // Render returns the dialog lines. | ||
| 55 | func (d *modelDialog) Render(th tui.Theme, width int) []string { | 109 | func (d *modelDialog) Render(th tui.Theme, width int) []string { | ||
| @@ -58,12 +112,28 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||||
| 58 | } | 112 | } | ||
| 59 | var lines []string | 113 | var lines []string | ||
| 60 | lines = append(lines, frameHeader(th, "model", width)) | 114 | lines = append(lines, frameHeader(th, "model", width)) | ||
| 61 | - | lines = append(lines, th.FG256(th.Muted, "pick a model (↑/↓, enter, esc to cancel)")) | |||
| 62 | 115 | ||||
| 63 | - | // Simple scroll window so very tall catalogs still fit in a short tui. | 116 | + | hint := "pick a model (↑/↓, enter, esc to cancel)" |
| 117 | + | if d.query != "" { | |||
| 118 | + | hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view)) | |||
| 119 | + | if len(d.view) != 1 { | |||
| 120 | + | hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view)) | |||
| 121 | + | } | |||
| 122 | + | } else { | |||
| 123 | + | hint += " · type to filter" | |||
| 124 | + | } | |||
| 125 | + | lines = append(lines, th.FG256(th.Muted, hint)) | |||
| 126 | + | ||||
| 127 | + | if len(d.view) == 0 { | |||
| 128 | + | lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query))) | |||
| 129 | + | lines = append(lines, frameRule(th, width)) | |||
| 130 | + | return lines | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Scroll window so very tall catalogs still fit in a short tui. | |||
| 64 | const visible = 14 | 134 | const visible = 14 | ||
| 65 | start := 0 | 135 | start := 0 | ||
| 66 | - | end := len(d.models) | 136 | + | end := len(d.view) |
| 67 | if end > visible { | 137 | if end > visible { | ||
| 68 | start = d.cursor - visible/2 | 138 | start = d.cursor - visible/2 | ||
| 69 | if start < 0 { | 139 | if start < 0 { | ||
| @@ -76,7 +146,7 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||||
| 76 | } | 146 | } | ||
| 77 | 147 | ||||
| 78 | for i := start; i < end; i++ { | 148 | for i := start; i < end; i++ { | ||
| 79 | - | m := d.models[i] | 149 | + | m := d.view[i] |
| 80 | prov := m.Provider | 150 | prov := m.Provider | ||
| 81 | id := m.ID | 151 | id := m.ID | ||
| 82 | reason := " " | 152 | reason := " " | ||
| @@ -106,8 +176,8 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||||
| 106 | if start > 0 { | 176 | if start > 0 { | ||
| 107 | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start))) | 177 | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start))) | ||
| 108 | } | 178 | } | ||
| 109 | - | if end < len(d.models) { | 179 | + | if end < len(d.view) { |
| 110 | - | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.models)-end))) | 180 | + | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end))) |
| 111 | } | 181 | } | ||
| 112 | 182 | ||||
| 113 | lines = append(lines, frameRule(th, width)) | 183 | lines = append(lines, frameRule(th, width)) | ||
| @@ -122,18 +192,34 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction { | |||||
| 122 | d.cursor-- | 192 | d.cursor-- | ||
| 123 | } | 193 | } | ||
| 124 | case tui.KeyDown: | 194 | case tui.KeyDown: | ||
| 125 | - | if d.cursor < len(d.models)-1 { | 195 | + | if d.cursor < len(d.view)-1 { |
| 126 | d.cursor++ | 196 | d.cursor++ | ||
| 127 | } | 197 | } | ||
| 198 | + | case tui.KeyBackspace: | |||
| 199 | + | if len(d.query) > 0 { | |||
| 200 | + | // Drop one rune from the query. | |||
| 201 | + | r := []rune(d.query) | |||
| 202 | + | d.query = string(r[:len(r)-1]) | |||
| 203 | + | d.refilter() | |||
| 204 | + | } | |||
| 205 | + | case tui.KeyRune: | |||
| 206 | + | if k.Alt || k.Ctrl { | |||
| 207 | + | break | |||
| 208 | + | } | |||
| 209 | + | // Only printable ASCII is useful for narrowing. | |||
| 210 | + | if k.Rune >= 0x20 && k.Rune < 0x7f { | |||
| 211 | + | d.query += string(k.Rune) | |||
| 212 | + | d.refilter() | |||
| 213 | + | } | |||
| 128 | case tui.KeyEsc: | 214 | case tui.KeyEsc: | ||
| 129 | d.Close() | 215 | d.Close() | ||
| 130 | return modelDialogAction{Close: true} | 216 | return modelDialogAction{Close: true} | ||
| 131 | case tui.KeyEnter: | 217 | case tui.KeyEnter: | ||
| 132 | - | if len(d.models) == 0 { | 218 | + | if len(d.view) == 0 { |
| 133 | d.Close() | 219 | d.Close() | ||
| 134 | return modelDialogAction{Close: true} | 220 | return modelDialogAction{Close: true} | ||
| 135 | } | 221 | } | ||
| 136 | - | m := d.models[d.cursor] | 222 | + | m := d.view[d.cursor] |
| 137 | d.Close() | 223 | d.Close() | ||
| 138 | return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID} | 224 | return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID} | ||
| 139 | } | 225 | } | ||
A internal/agent/modes/telegram/api.go +264 -0
| @@ -0,0 +1,264 @@ | |||
| 1 | + | package telegram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "mime/multipart" | |
| 10 | + | "net/http" | |
| 11 | + | "net/url" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "time" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Telegram Bot API types used by the bridge. Only the subset we need. | |
| 18 | + | ||
| 19 | + | type User struct { | |
| 20 | + | ID int64 `json:"id"` | |
| 21 | + | IsBot bool `json:"is_bot"` | |
| 22 | + | FirstName string `json:"first_name"` | |
| 23 | + | Username string `json:"username"` | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type Chat struct { | |
| 27 | + | ID int64 `json:"id"` | |
| 28 | + | Type string `json:"type"` // "private" | "group" | ... | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | type PhotoSize struct { | |
| 32 | + | FileID string `json:"file_id"` | |
| 33 | + | FileSize int `json:"file_size,omitempty"` | |
| 34 | + | Width int `json:"width,omitempty"` | |
| 35 | + | Height int `json:"height,omitempty"` | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | type Document struct { | |
| 39 | + | FileID string `json:"file_id"` | |
| 40 | + | FileName string `json:"file_name,omitempty"` | |
| 41 | + | MimeType string `json:"mime_type,omitempty"` | |
| 42 | + | FileSize int `json:"file_size,omitempty"` | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | type Message struct { | |
| 46 | + | MessageID int `json:"message_id"` | |
| 47 | + | From *User `json:"from"` | |
| 48 | + | Chat Chat `json:"chat"` | |
| 49 | + | Date int64 `json:"date"` | |
| 50 | + | Text string `json:"text,omitempty"` | |
| 51 | + | Caption string `json:"caption,omitempty"` | |
| 52 | + | Photo []PhotoSize `json:"photo,omitempty"` | |
| 53 | + | Document *Document `json:"document,omitempty"` | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | type Update struct { | |
| 57 | + | UpdateID int64 `json:"update_id"` | |
| 58 | + | Message *Message `json:"message"` | |
| 59 | + | Edited *Message `json:"edited_message"` | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | type File struct { | |
| 63 | + | FileID string `json:"file_id"` | |
| 64 | + | FilePath string `json:"file_path"` | |
| 65 | + | FileSize int `json:"file_size,omitempty"` | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // apiResponse is Telegram's envelope. | |
| 69 | + | type apiResponse[T any] struct { | |
| 70 | + | OK bool `json:"ok"` | |
| 71 | + | Description string `json:"description"` | |
| 72 | + | Result T `json:"result"` | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Client is a minimal Telegram Bot API client. | |
| 76 | + | type Client struct { | |
| 77 | + | token string | |
| 78 | + | http *http.Client | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func NewClient(token string) *Client { | |
| 82 | + | return &Client{token: token, http: &http.Client{Timeout: 0}} | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (c *Client) baseURL() string { return "https://api.telegram.org/bot" + c.token } | |
| 86 | + | ||
| 87 | + | // GetMe verifies the token and returns the bot's own User. | |
| 88 | + | func (c *Client) GetMe(ctx context.Context) (*User, error) { | |
| 89 | + | var resp apiResponse[User] | |
| 90 | + | if err := c.call(ctx, "getMe", nil, &resp); err != nil { | |
| 91 | + | return nil, err | |
| 92 | + | } | |
| 93 | + | if !resp.OK { | |
| 94 | + | return nil, fmt.Errorf("getMe: %s", resp.Description) | |
| 95 | + | } | |
| 96 | + | return &resp.Result, nil | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // GetUpdates polls for new updates since offset with a long-poll timeout. | |
| 100 | + | func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) { | |
| 101 | + | q := url.Values{} | |
| 102 | + | if offset != 0 { | |
| 103 | + | q.Set("offset", strconv.FormatInt(offset, 10)) | |
| 104 | + | } | |
| 105 | + | q.Set("timeout", strconv.Itoa(timeoutSec)) | |
| 106 | + | q.Set("allowed_updates", `["message","edited_message"]`) | |
| 107 | + | ||
| 108 | + | var resp apiResponse[[]Update] | |
| 109 | + | if err := c.call(ctx, "getUpdates?"+q.Encode(), nil, &resp); err != nil { | |
| 110 | + | return nil, err | |
| 111 | + | } | |
| 112 | + | if !resp.OK { | |
| 113 | + | return nil, fmt.Errorf("getUpdates: %s", resp.Description) | |
| 114 | + | } | |
| 115 | + | return resp.Result, nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // SendMessage sends a plain-text reply. | |
| 119 | + | func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, replyTo int) error { | |
| 120 | + | body := map[string]any{ | |
| 121 | + | "chat_id": chatID, | |
| 122 | + | "text": text, | |
| 123 | + | } | |
| 124 | + | if replyTo > 0 { | |
| 125 | + | body["reply_to_message_id"] = replyTo | |
| 126 | + | } | |
| 127 | + | var resp apiResponse[json.RawMessage] | |
| 128 | + | if err := c.call(ctx, "sendMessage", body, &resp); err != nil { | |
| 129 | + | return err | |
| 130 | + | } | |
| 131 | + | if !resp.OK { | |
| 132 | + | return fmt.Errorf("sendMessage: %s", resp.Description) | |
| 133 | + | } | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // SendChatAction keeps the "typing..." indicator alive. Call every ~4s. | |
| 138 | + | func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string) error { | |
| 139 | + | body := map[string]any{"chat_id": chatID, "action": action} | |
| 140 | + | var resp apiResponse[json.RawMessage] | |
| 141 | + | _ = c.call(ctx, "sendChatAction", body, &resp) // ignore errors; it's advisory | |
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // SendDocument uploads a local file as a document attachment. | |
| 146 | + | func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error { | |
| 147 | + | f, err := openFile(path) | |
| 148 | + | if err != nil { | |
| 149 | + | return err | |
| 150 | + | } | |
| 151 | + | defer f.Close() | |
| 152 | + | ||
| 153 | + | var buf bytes.Buffer | |
| 154 | + | w := multipart.NewWriter(&buf) | |
| 155 | + | _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10)) | |
| 156 | + | if caption != "" { | |
| 157 | + | _ = w.WriteField("caption", caption) | |
| 158 | + | } | |
| 159 | + | part, err := w.CreateFormFile("document", lastPathElem(path)) | |
| 160 | + | if err != nil { | |
| 161 | + | return err | |
| 162 | + | } | |
| 163 | + | if _, err := io.Copy(part, f); err != nil { | |
| 164 | + | return err | |
| 165 | + | } | |
| 166 | + | w.Close() | |
| 167 | + | ||
| 168 | + | req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendDocument", &buf) | |
| 169 | + | if err != nil { | |
| 170 | + | return err | |
| 171 | + | } | |
| 172 | + | req.Header.Set("content-type", w.FormDataContentType()) | |
| 173 | + | resp, err := c.http.Do(req) | |
| 174 | + | if err != nil { | |
| 175 | + | return err | |
| 176 | + | } | |
| 177 | + | defer resp.Body.Close() | |
| 178 | + | if resp.StatusCode >= 400 { | |
| 179 | + | body, _ := io.ReadAll(resp.Body) | |
| 180 | + | return fmt.Errorf("sendDocument http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) | |
| 181 | + | } | |
| 182 | + | return nil | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // GetFile resolves a file_id to a downloadable path. | |
| 186 | + | func (c *Client) GetFile(ctx context.Context, fileID string) (*File, error) { | |
| 187 | + | q := url.Values{} | |
| 188 | + | q.Set("file_id", fileID) | |
| 189 | + | var resp apiResponse[File] | |
| 190 | + | if err := c.call(ctx, "getFile?"+q.Encode(), nil, &resp); err != nil { | |
| 191 | + | return nil, err | |
| 192 | + | } | |
| 193 | + | if !resp.OK { | |
| 194 | + | return nil, fmt.Errorf("getFile: %s", resp.Description) | |
| 195 | + | } | |
| 196 | + | return &resp.Result, nil | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // DownloadFile downloads the file at filePath (from GetFile) into memory. | |
| 200 | + | func (c *Client) DownloadFile(ctx context.Context, filePath string) ([]byte, error) { | |
| 201 | + | u := "https://api.telegram.org/file/bot" + c.token + "/" + filePath | |
| 202 | + | req, err := http.NewRequestWithContext(ctx, "GET", u, nil) | |
| 203 | + | if err != nil { | |
| 204 | + | return nil, err | |
| 205 | + | } | |
| 206 | + | resp, err := c.http.Do(req) | |
| 207 | + | if err != nil { | |
| 208 | + | return nil, err | |
| 209 | + | } | |
| 210 | + | defer resp.Body.Close() | |
| 211 | + | if resp.StatusCode != 200 { | |
| 212 | + | return nil, fmt.Errorf("download %s: http %d", filePath, resp.StatusCode) | |
| 213 | + | } | |
| 214 | + | return io.ReadAll(resp.Body) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // call issues a GET or POST request depending on whether body is nil. | |
| 218 | + | func (c *Client) call(ctx context.Context, endpoint string, body map[string]any, out any) error { | |
| 219 | + | var req *http.Request | |
| 220 | + | var err error | |
| 221 | + | if body == nil { | |
| 222 | + | req, err = http.NewRequestWithContext(ctx, "GET", c.baseURL()+"/"+endpoint, nil) | |
| 223 | + | } else { | |
| 224 | + | b, _ := json.Marshal(body) | |
| 225 | + | req, err = http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/"+endpoint, bytes.NewReader(b)) | |
| 226 | + | if err == nil { | |
| 227 | + | req.Header.Set("content-type", "application/json") | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | if err != nil { | |
| 231 | + | return err | |
| 232 | + | } | |
| 233 | + | resp, err := c.http.Do(req) | |
| 234 | + | if err != nil { | |
| 235 | + | return err | |
| 236 | + | } | |
| 237 | + | defer resp.Body.Close() | |
| 238 | + | // Telegram returns 200 with ok=false for logical errors; read either way. | |
| 239 | + | respBody, _ := io.ReadAll(resp.Body) | |
| 240 | + | if len(respBody) == 0 { | |
| 241 | + | return fmt.Errorf("%s: empty response (status %d)", endpoint, resp.StatusCode) | |
| 242 | + | } | |
| 243 | + | return json.Unmarshal(respBody, out) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // small helpers kept here so api.go has no other deps. | |
| 247 | + | func lastPathElem(p string) string { | |
| 248 | + | for i := len(p) - 1; i >= 0; i-- { | |
| 249 | + | if p[i] == '/' || p[i] == '\\' { | |
| 250 | + | return p[i+1:] | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | return p | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func openFile(path string) (io.ReadCloser, error) { | |
| 257 | + | return osOpen(path) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // overridden in tests. | |
| 261 | + | var osOpen = defaultOpen | |
| 262 | + | ||
| 263 | + | // Sleep is a package-level hook so tests can stub it. | |
| 264 | + | var sleep = func(d time.Duration) { time.Sleep(d) } | |
| @@ -0,0 +1,264 @@ | |||||
| 1 | + | package telegram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "mime/multipart" | |||
| 10 | + | "net/http" | |||
| 11 | + | "net/url" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | "time" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Telegram Bot API types used by the bridge. Only the subset we need. | |||
| 18 | + | ||||
| 19 | + | type User struct { | |||
| 20 | + | ID int64 `json:"id"` | |||
| 21 | + | IsBot bool `json:"is_bot"` | |||
| 22 | + | FirstName string `json:"first_name"` | |||
| 23 | + | Username string `json:"username"` | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | type Chat struct { | |||
| 27 | + | ID int64 `json:"id"` | |||
| 28 | + | Type string `json:"type"` // "private" | "group" | ... | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | type PhotoSize struct { | |||
| 32 | + | FileID string `json:"file_id"` | |||
| 33 | + | FileSize int `json:"file_size,omitempty"` | |||
| 34 | + | Width int `json:"width,omitempty"` | |||
| 35 | + | Height int `json:"height,omitempty"` | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | type Document struct { | |||
| 39 | + | FileID string `json:"file_id"` | |||
| 40 | + | FileName string `json:"file_name,omitempty"` | |||
| 41 | + | MimeType string `json:"mime_type,omitempty"` | |||
| 42 | + | FileSize int `json:"file_size,omitempty"` | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | type Message struct { | |||
| 46 | + | MessageID int `json:"message_id"` | |||
| 47 | + | From *User `json:"from"` | |||
| 48 | + | Chat Chat `json:"chat"` | |||
| 49 | + | Date int64 `json:"date"` | |||
| 50 | + | Text string `json:"text,omitempty"` | |||
| 51 | + | Caption string `json:"caption,omitempty"` | |||
| 52 | + | Photo []PhotoSize `json:"photo,omitempty"` | |||
| 53 | + | Document *Document `json:"document,omitempty"` | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | type Update struct { | |||
| 57 | + | UpdateID int64 `json:"update_id"` | |||
| 58 | + | Message *Message `json:"message"` | |||
| 59 | + | Edited *Message `json:"edited_message"` | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | type File struct { | |||
| 63 | + | FileID string `json:"file_id"` | |||
| 64 | + | FilePath string `json:"file_path"` | |||
| 65 | + | FileSize int `json:"file_size,omitempty"` | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // apiResponse is Telegram's envelope. | |||
| 69 | + | type apiResponse[T any] struct { | |||
| 70 | + | OK bool `json:"ok"` | |||
| 71 | + | Description string `json:"description"` | |||
| 72 | + | Result T `json:"result"` | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Client is a minimal Telegram Bot API client. | |||
| 76 | + | type Client struct { | |||
| 77 | + | token string | |||
| 78 | + | http *http.Client | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func NewClient(token string) *Client { | |||
| 82 | + | return &Client{token: token, http: &http.Client{Timeout: 0}} | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (c *Client) baseURL() string { return "https://api.telegram.org/bot" + c.token } | |||
| 86 | + | ||||
| 87 | + | // GetMe verifies the token and returns the bot's own User. | |||
| 88 | + | func (c *Client) GetMe(ctx context.Context) (*User, error) { | |||
| 89 | + | var resp apiResponse[User] | |||
| 90 | + | if err := c.call(ctx, "getMe", nil, &resp); err != nil { | |||
| 91 | + | return nil, err | |||
| 92 | + | } | |||
| 93 | + | if !resp.OK { | |||
| 94 | + | return nil, fmt.Errorf("getMe: %s", resp.Description) | |||
| 95 | + | } | |||
| 96 | + | return &resp.Result, nil | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // GetUpdates polls for new updates since offset with a long-poll timeout. | |||
| 100 | + | func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) { | |||
| 101 | + | q := url.Values{} | |||
| 102 | + | if offset != 0 { | |||
| 103 | + | q.Set("offset", strconv.FormatInt(offset, 10)) | |||
| 104 | + | } | |||
| 105 | + | q.Set("timeout", strconv.Itoa(timeoutSec)) | |||
| 106 | + | q.Set("allowed_updates", `["message","edited_message"]`) | |||
| 107 | + | ||||
| 108 | + | var resp apiResponse[[]Update] | |||
| 109 | + | if err := c.call(ctx, "getUpdates?"+q.Encode(), nil, &resp); err != nil { | |||
| 110 | + | return nil, err | |||
| 111 | + | } | |||
| 112 | + | if !resp.OK { | |||
| 113 | + | return nil, fmt.Errorf("getUpdates: %s", resp.Description) | |||
| 114 | + | } | |||
| 115 | + | return resp.Result, nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // SendMessage sends a plain-text reply. | |||
| 119 | + | func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, replyTo int) error { | |||
| 120 | + | body := map[string]any{ | |||
| 121 | + | "chat_id": chatID, | |||
| 122 | + | "text": text, | |||
| 123 | + | } | |||
| 124 | + | if replyTo > 0 { | |||
| 125 | + | body["reply_to_message_id"] = replyTo | |||
| 126 | + | } | |||
| 127 | + | var resp apiResponse[json.RawMessage] | |||
| 128 | + | if err := c.call(ctx, "sendMessage", body, &resp); err != nil { | |||
| 129 | + | return err | |||
| 130 | + | } | |||
| 131 | + | if !resp.OK { | |||
| 132 | + | return fmt.Errorf("sendMessage: %s", resp.Description) | |||
| 133 | + | } | |||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // SendChatAction keeps the "typing..." indicator alive. Call every ~4s. | |||
| 138 | + | func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string) error { | |||
| 139 | + | body := map[string]any{"chat_id": chatID, "action": action} | |||
| 140 | + | var resp apiResponse[json.RawMessage] | |||
| 141 | + | _ = c.call(ctx, "sendChatAction", body, &resp) // ignore errors; it's advisory | |||
| 142 | + | return nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // SendDocument uploads a local file as a document attachment. | |||
| 146 | + | func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error { | |||
| 147 | + | f, err := openFile(path) | |||
| 148 | + | if err != nil { | |||
| 149 | + | return err | |||
| 150 | + | } | |||
| 151 | + | defer f.Close() | |||
| 152 | + | ||||
| 153 | + | var buf bytes.Buffer | |||
| 154 | + | w := multipart.NewWriter(&buf) | |||
| 155 | + | _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10)) | |||
| 156 | + | if caption != "" { | |||
| 157 | + | _ = w.WriteField("caption", caption) | |||
| 158 | + | } | |||
| 159 | + | part, err := w.CreateFormFile("document", lastPathElem(path)) | |||
| 160 | + | if err != nil { | |||
| 161 | + | return err | |||
| 162 | + | } | |||
| 163 | + | if _, err := io.Copy(part, f); err != nil { | |||
| 164 | + | return err | |||
| 165 | + | } | |||
| 166 | + | w.Close() | |||
| 167 | + | ||||
| 168 | + | req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendDocument", &buf) | |||
| 169 | + | if err != nil { | |||
| 170 | + | return err | |||
| 171 | + | } | |||
| 172 | + | req.Header.Set("content-type", w.FormDataContentType()) | |||
| 173 | + | resp, err := c.http.Do(req) | |||
| 174 | + | if err != nil { | |||
| 175 | + | return err | |||
| 176 | + | } | |||
| 177 | + | defer resp.Body.Close() | |||
| 178 | + | if resp.StatusCode >= 400 { | |||
| 179 | + | body, _ := io.ReadAll(resp.Body) | |||
| 180 | + | return fmt.Errorf("sendDocument http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) | |||
| 181 | + | } | |||
| 182 | + | return nil | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // GetFile resolves a file_id to a downloadable path. | |||
| 186 | + | func (c *Client) GetFile(ctx context.Context, fileID string) (*File, error) { | |||
| 187 | + | q := url.Values{} | |||
| 188 | + | q.Set("file_id", fileID) | |||
| 189 | + | var resp apiResponse[File] | |||
| 190 | + | if err := c.call(ctx, "getFile?"+q.Encode(), nil, &resp); err != nil { | |||
| 191 | + | return nil, err | |||
| 192 | + | } | |||
| 193 | + | if !resp.OK { | |||
| 194 | + | return nil, fmt.Errorf("getFile: %s", resp.Description) | |||
| 195 | + | } | |||
| 196 | + | return &resp.Result, nil | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // DownloadFile downloads the file at filePath (from GetFile) into memory. | |||
| 200 | + | func (c *Client) DownloadFile(ctx context.Context, filePath string) ([]byte, error) { | |||
| 201 | + | u := "https://api.telegram.org/file/bot" + c.token + "/" + filePath | |||
| 202 | + | req, err := http.NewRequestWithContext(ctx, "GET", u, nil) | |||
| 203 | + | if err != nil { | |||
| 204 | + | return nil, err | |||
| 205 | + | } | |||
| 206 | + | resp, err := c.http.Do(req) | |||
| 207 | + | if err != nil { | |||
| 208 | + | return nil, err | |||
| 209 | + | } | |||
| 210 | + | defer resp.Body.Close() | |||
| 211 | + | if resp.StatusCode != 200 { | |||
| 212 | + | return nil, fmt.Errorf("download %s: http %d", filePath, resp.StatusCode) | |||
| 213 | + | } | |||
| 214 | + | return io.ReadAll(resp.Body) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // call issues a GET or POST request depending on whether body is nil. | |||
| 218 | + | func (c *Client) call(ctx context.Context, endpoint string, body map[string]any, out any) error { | |||
| 219 | + | var req *http.Request | |||
| 220 | + | var err error | |||
| 221 | + | if body == nil { | |||
| 222 | + | req, err = http.NewRequestWithContext(ctx, "GET", c.baseURL()+"/"+endpoint, nil) | |||
| 223 | + | } else { | |||
| 224 | + | b, _ := json.Marshal(body) | |||
| 225 | + | req, err = http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/"+endpoint, bytes.NewReader(b)) | |||
| 226 | + | if err == nil { | |||
| 227 | + | req.Header.Set("content-type", "application/json") | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | if err != nil { | |||
| 231 | + | return err | |||
| 232 | + | } | |||
| 233 | + | resp, err := c.http.Do(req) | |||
| 234 | + | if err != nil { | |||
| 235 | + | return err | |||
| 236 | + | } | |||
| 237 | + | defer resp.Body.Close() | |||
| 238 | + | // Telegram returns 200 with ok=false for logical errors; read either way. | |||
| 239 | + | respBody, _ := io.ReadAll(resp.Body) | |||
| 240 | + | if len(respBody) == 0 { | |||
| 241 | + | return fmt.Errorf("%s: empty response (status %d)", endpoint, resp.StatusCode) | |||
| 242 | + | } | |||
| 243 | + | return json.Unmarshal(respBody, out) | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // small helpers kept here so api.go has no other deps. | |||
| 247 | + | func lastPathElem(p string) string { | |||
| 248 | + | for i := len(p) - 1; i >= 0; i-- { | |||
| 249 | + | if p[i] == '/' || p[i] == '\\' { | |||
| 250 | + | return p[i+1:] | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | return p | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func openFile(path string) (io.ReadCloser, error) { | |||
| 257 | + | return osOpen(path) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // overridden in tests. | |||
| 261 | + | var osOpen = defaultOpen | |||
| 262 | + | ||||
| 263 | + | // Sleep is a package-level hook so tests can stub it. | |||
| 264 | + | var sleep = func(d time.Duration) { time.Sleep(d) } | |||
A internal/agent/modes/telegram/bot.go +407 -0
| @@ -0,0 +1,407 @@ | |||
| 1 | + | package telegram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/internal/core" | |
| 11 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Bot owns the Telegram polling loop and dispatches inbound DMs to | |
| 15 | + | // the agent. It is a long-running goroutine; Run blocks until ctx | |
| 16 | + | // cancels. | |
| 17 | + | type Bot struct { | |
| 18 | + | Client *Client | |
| 19 | + | Agent *core.Agent | |
| 20 | + | Config Config | |
| 21 | + | ZotHome string | |
| 22 | + | // Save persists cfg to bot.json. Called whenever the bot pairs | |
| 23 | + | // with a new allowed user or advances LastUpdateID. | |
| 24 | + | Save func(Config) error | |
| 25 | + | // RefreshCreds is called before every turn to pick up newly | |
| 26 | + | // refreshed OAuth tokens. Optional; when nil, the bot uses the | |
| 27 | + | // credential it was built with. Implementations typically call | |
| 28 | + | // agent.ResolveCredentialFull which auto-refreshes expired tokens. | |
| 29 | + | RefreshCreds func() error | |
| 30 | + | ||
| 31 | + | mu sync.Mutex | |
| 32 | + | busy bool | |
| 33 | + | activeCtx context.CancelFunc | |
| 34 | + | queue []queuedTurn | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // queuedTurn is an inbound DM waiting to become a prompt. | |
| 38 | + | type queuedTurn struct { | |
| 39 | + | chatID int64 | |
| 40 | + | messageID int | |
| 41 | + | prompt string | |
| 42 | + | images []provider.ImageBlock | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Run drives the bot. Returns when ctx is cancelled or GetMe fails. | |
| 46 | + | func (b *Bot) Run(ctx context.Context) error { | |
| 47 | + | if b.Config.BotToken == "" { | |
| 48 | + | return fmt.Errorf("no bot token configured; run `zot bot setup` first") | |
| 49 | + | } | |
| 50 | + | me, err := b.Client.GetMe(ctx) | |
| 51 | + | if err != nil { | |
| 52 | + | return fmt.Errorf("getMe: %w", err) | |
| 53 | + | } | |
| 54 | + | // Keep the stored username/id in sync with the actual bot. | |
| 55 | + | if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username { | |
| 56 | + | b.Config.BotID = me.ID | |
| 57 | + | b.Config.BotUsername = me.Username | |
| 58 | + | _ = b.Save(b.Config) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | fmt.Printf("telegram bridge online as @%s (id=%d)\n", me.Username, me.ID) | |
| 62 | + | if b.Config.AllowedUserID == 0 { | |
| 63 | + | fmt.Println("no user paired yet — send /start to the bot from Telegram to claim it") | |
| 64 | + | } else { | |
| 65 | + | fmt.Printf("paired with telegram user id %d\n", b.Config.AllowedUserID) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | return b.pollLoop(ctx) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // pollLoop long-polls Telegram for updates and dispatches them. | |
| 72 | + | func (b *Bot) pollLoop(ctx context.Context) error { | |
| 73 | + | backoff := time.Second | |
| 74 | + | for { | |
| 75 | + | if err := ctx.Err(); err != nil { | |
| 76 | + | return err | |
| 77 | + | } | |
| 78 | + | updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30) | |
| 79 | + | if err != nil { | |
| 80 | + | if ctx.Err() != nil { | |
| 81 | + | return ctx.Err() | |
| 82 | + | } | |
| 83 | + | fmt.Fprintln(stderr(), "telegram: getUpdates error:", err) | |
| 84 | + | select { | |
| 85 | + | case <-ctx.Done(): | |
| 86 | + | return ctx.Err() | |
| 87 | + | case <-time.After(backoff): | |
| 88 | + | } | |
| 89 | + | if backoff < 30*time.Second { | |
| 90 | + | backoff *= 2 | |
| 91 | + | } | |
| 92 | + | continue | |
| 93 | + | } | |
| 94 | + | backoff = time.Second | |
| 95 | + | for _, u := range updates { | |
| 96 | + | if err := b.handleUpdate(ctx, u); err != nil { | |
| 97 | + | fmt.Fprintln(stderr(), "telegram: handleUpdate:", err) | |
| 98 | + | } | |
| 99 | + | b.Config.LastUpdateID = u.UpdateID | |
| 100 | + | _ = b.Save(b.Config) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // handleUpdate processes a single Telegram update. | |
| 106 | + | func (b *Bot) handleUpdate(ctx context.Context, u Update) error { | |
| 107 | + | msg := u.Message | |
| 108 | + | if msg == nil { | |
| 109 | + | msg = u.Edited | |
| 110 | + | } | |
| 111 | + | if msg == nil || msg.From == nil || msg.From.IsBot { | |
| 112 | + | return nil | |
| 113 | + | } | |
| 114 | + | if msg.Chat.Type != "private" { | |
| 115 | + | return nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Pairing: first user who sends /start claims the bridge. | |
| 119 | + | text := strings.TrimSpace(msg.Text) | |
| 120 | + | if b.Config.AllowedUserID == 0 { | |
| 121 | + | if strings.HasPrefix(text, "/start") { | |
| 122 | + | b.Config.AllowedUserID = msg.From.ID | |
| 123 | + | _ = b.Save(b.Config) | |
| 124 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 125 | + | fmt.Sprintf("paired with @%s. send any message and i'll forward it to zot.", msg.From.Username), | |
| 126 | + | msg.MessageID) | |
| 127 | + | return nil | |
| 128 | + | } | |
| 129 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 130 | + | "this bot isn't paired yet. send /start to claim it.", | |
| 131 | + | msg.MessageID) | |
| 132 | + | return nil | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // Enforce allowed user. | |
| 136 | + | if msg.From.ID != b.Config.AllowedUserID { | |
| 137 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 138 | + | "this bot is paired with a different user.", | |
| 139 | + | msg.MessageID) | |
| 140 | + | return nil | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // Built-in commands that bypass the agent. | |
| 144 | + | switch text { | |
| 145 | + | case "/start", "/help": | |
| 146 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 147 | + | "send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop.", | |
| 148 | + | msg.MessageID) | |
| 149 | + | return nil | |
| 150 | + | case "/status": | |
| 151 | + | return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID) | |
| 152 | + | case "/stop": | |
| 153 | + | b.mu.Lock() | |
| 154 | + | cancel := b.activeCtx | |
| 155 | + | b.mu.Unlock() | |
| 156 | + | if cancel != nil { | |
| 157 | + | cancel() | |
| 158 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, "cancelled the current turn.", msg.MessageID) | |
| 159 | + | } else { | |
| 160 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, "nothing running.", msg.MessageID) | |
| 161 | + | } | |
| 162 | + | return nil | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Build the prompt: combine text + caption; download image attachments. | |
| 166 | + | prompt := strings.TrimSpace(msg.Text) | |
| 167 | + | if msg.Caption != "" { | |
| 168 | + | if prompt != "" { | |
| 169 | + | prompt += "\n" | |
| 170 | + | } | |
| 171 | + | prompt += msg.Caption | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | var images []provider.ImageBlock | |
| 175 | + | if len(msg.Photo) > 0 { | |
| 176 | + | // Photos arrive in multiple sizes; take the largest (last in the slice). | |
| 177 | + | largest := msg.Photo[len(msg.Photo)-1] | |
| 178 | + | if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil { | |
| 179 | + | images = append(images, provider.ImageBlock{MimeType: mime, Data: data}) | |
| 180 | + | } else { | |
| 181 | + | fmt.Fprintln(stderr(), "telegram: download photo:", err) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | if msg.Document != nil && isImageMIME(msg.Document.MimeType) { | |
| 185 | + | if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil { | |
| 186 | + | images = append(images, provider.ImageBlock{MimeType: mime, Data: data}) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | if prompt == "" && len(images) == 0 { | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | b.mu.Lock() | |
| 195 | + | b.queue = append(b.queue, queuedTurn{ | |
| 196 | + | chatID: msg.Chat.ID, | |
| 197 | + | messageID: msg.MessageID, | |
| 198 | + | prompt: prompt, | |
| 199 | + | images: images, | |
| 200 | + | }) | |
| 201 | + | idle := !b.busy | |
| 202 | + | b.mu.Unlock() | |
| 203 | + | ||
| 204 | + | if idle { | |
| 205 | + | go b.drainQueue(ctx) | |
| 206 | + | } | |
| 207 | + | return nil | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // drainQueue runs queued turns one at a time until the queue is empty. | |
| 211 | + | func (b *Bot) drainQueue(parent context.Context) { | |
| 212 | + | for { | |
| 213 | + | b.mu.Lock() | |
| 214 | + | if len(b.queue) == 0 { | |
| 215 | + | b.busy = false | |
| 216 | + | b.activeCtx = nil | |
| 217 | + | b.mu.Unlock() | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | t := b.queue[0] | |
| 221 | + | b.queue = b.queue[1:] | |
| 222 | + | b.busy = true | |
| 223 | + | turnCtx, cancel := context.WithCancel(parent) | |
| 224 | + | b.activeCtx = cancel | |
| 225 | + | b.mu.Unlock() | |
| 226 | + | ||
| 227 | + | if b.RefreshCreds != nil { | |
| 228 | + | if err := b.RefreshCreds(); err != nil { | |
| 229 | + | fmt.Fprintln(stderr(), "telegram: refresh creds:", err) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | b.runTurn(turnCtx, t) | |
| 233 | + | cancel() | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // runTurn sends the queued prompt to the agent and streams the reply. | |
| 238 | + | func (b *Bot) runTurn(ctx context.Context, t queuedTurn) { | |
| 239 | + | stopTyping := b.startTyping(ctx, t.chatID) | |
| 240 | + | defer stopTyping() | |
| 241 | + | ||
| 242 | + | var replyBuilder strings.Builder | |
| 243 | + | var lastAssistantText string | |
| 244 | + | var turnErr error | |
| 245 | + | ||
| 246 | + | sink := func(ev core.AgentEvent) { | |
| 247 | + | switch e := ev.(type) { | |
| 248 | + | case core.EvTextDelta: | |
| 249 | + | replyBuilder.WriteString(e.Delta) | |
| 250 | + | case core.EvAssistantMessage: | |
| 251 | + | var sb strings.Builder | |
| 252 | + | for _, c := range e.Message.Content { | |
| 253 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 254 | + | if sb.Len() > 0 { | |
| 255 | + | sb.WriteString("\n") | |
| 256 | + | } | |
| 257 | + | sb.WriteString(tb.Text) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | if sb.Len() > 0 { | |
| 261 | + | lastAssistantText = sb.String() | |
| 262 | + | } | |
| 263 | + | replyBuilder.Reset() | |
| 264 | + | case core.EvTurnEnd: | |
| 265 | + | if e.Err != nil { | |
| 266 | + | turnErr = e.Err | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | if err := b.Agent.Prompt(ctx, t.prompt, t.images, sink); err != nil { | |
| 272 | + | turnErr = err | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | reply := strings.TrimSpace(lastAssistantText) | |
| 276 | + | if reply == "" { | |
| 277 | + | reply = strings.TrimSpace(replyBuilder.String()) | |
| 278 | + | } | |
| 279 | + | if turnErr != nil && ctx.Err() == nil { | |
| 280 | + | reply = "error: " + turnErr.Error() | |
| 281 | + | } | |
| 282 | + | if reply == "" { | |
| 283 | + | reply = "(no reply)" | |
| 284 | + | } | |
| 285 | + | // Telegram caps messages at 4096 chars. Chunk to be safe. | |
| 286 | + | for _, chunk := range chunkMessage(reply, 4000) { | |
| 287 | + | if err := b.Client.SendMessage(context.Background(), t.chatID, chunk, 0); err != nil { | |
| 288 | + | fmt.Fprintln(stderr(), "telegram: sendMessage:", err) | |
| 289 | + | break | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // startTyping keeps Telegram's "typing..." indicator alive until the | |
| 295 | + | // returned stop function is called. | |
| 296 | + | func (b *Bot) startTyping(ctx context.Context, chatID int64) func() { | |
| 297 | + | tctx, cancel := context.WithCancel(ctx) | |
| 298 | + | go func() { | |
| 299 | + | for { | |
| 300 | + | _ = b.Client.SendChatAction(tctx, chatID, "typing") | |
| 301 | + | select { | |
| 302 | + | case <-tctx.Done(): | |
| 303 | + | return | |
| 304 | + | case <-time.After(4 * time.Second): | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | }() | |
| 308 | + | return cancel | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | // sendStatus describes agent state to the Telegram user. | |
| 312 | + | func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error { | |
| 313 | + | b.mu.Lock() | |
| 314 | + | busy := b.busy | |
| 315 | + | queued := len(b.queue) | |
| 316 | + | b.mu.Unlock() | |
| 317 | + | ||
| 318 | + | state := "idle" | |
| 319 | + | if busy { | |
| 320 | + | state = "working" | |
| 321 | + | } | |
| 322 | + | lines := []string{ | |
| 323 | + | fmt.Sprintf("state: %s", state), | |
| 324 | + | fmt.Sprintf("queued: %d", queued), | |
| 325 | + | fmt.Sprintf("model: %s", b.Agent.Model), | |
| 326 | + | } | |
| 327 | + | cost := b.Agent.Cost() | |
| 328 | + | lines = append(lines, fmt.Sprintf("cost: $%.4f (%d in / %d out)", | |
| 329 | + | cost.CostUSD, cost.InputTokens, cost.OutputTokens)) | |
| 330 | + | return b.Client.SendMessage(ctx, chatID, strings.Join(lines, "\n"), replyTo) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // download fetches a file from Telegram and returns bytes + mime. | |
| 334 | + | func (b *Bot) download(ctx context.Context, fileID, mime string) ([]byte, string, error) { | |
| 335 | + | f, err := b.Client.GetFile(ctx, fileID) | |
| 336 | + | if err != nil { | |
| 337 | + | return nil, "", err | |
| 338 | + | } | |
| 339 | + | data, err := b.Client.DownloadFile(ctx, f.FilePath) | |
| 340 | + | if err != nil { | |
| 341 | + | return nil, "", err | |
| 342 | + | } | |
| 343 | + | if mime == "" { | |
| 344 | + | mime = guessImageMIME(f.FilePath) | |
| 345 | + | } | |
| 346 | + | return data, mime, nil | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | // chunkMessage splits s into chunks no larger than limit runes, on line | |
| 350 | + | // boundaries when possible. | |
| 351 | + | func chunkMessage(s string, limit int) []string { | |
| 352 | + | if len(s) <= limit { | |
| 353 | + | return []string{s} | |
| 354 | + | } | |
| 355 | + | var out []string | |
| 356 | + | lines := strings.Split(s, "\n") | |
| 357 | + | var cur strings.Builder | |
| 358 | + | for _, l := range lines { | |
| 359 | + | if cur.Len()+len(l)+1 > limit && cur.Len() > 0 { | |
| 360 | + | out = append(out, cur.String()) | |
| 361 | + | cur.Reset() | |
| 362 | + | } | |
| 363 | + | if len(l) > limit { | |
| 364 | + | // Line itself too long; hard-split. | |
| 365 | + | for len(l) > limit { | |
| 366 | + | out = append(out, l[:limit]) | |
| 367 | + | l = l[limit:] | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | if cur.Len() > 0 { | |
| 371 | + | cur.WriteString("\n") | |
| 372 | + | } | |
| 373 | + | cur.WriteString(l) | |
| 374 | + | } | |
| 375 | + | if cur.Len() > 0 { | |
| 376 | + | out = append(out, cur.String()) | |
| 377 | + | } | |
| 378 | + | return out | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // isImageMIME returns true for MIME types the model can probably ingest | |
| 382 | + | // as a vision input. | |
| 383 | + | func isImageMIME(m string) bool { | |
| 384 | + | switch strings.ToLower(m) { | |
| 385 | + | case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp": | |
| 386 | + | return true | |
| 387 | + | } | |
| 388 | + | return false | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | // guessImageMIME infers a mime type from a filename suffix. Falls back | |
| 392 | + | // to image/png because telegram photos are always re-encoded to jpeg | |
| 393 | + | // but getFile's file_path may omit the extension. | |
| 394 | + | func guessImageMIME(path string) string { | |
| 395 | + | lower := strings.ToLower(path) | |
| 396 | + | switch { | |
| 397 | + | case strings.HasSuffix(lower, ".png"): | |
| 398 | + | return "image/png" | |
| 399 | + | case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"): | |
| 400 | + | return "image/jpeg" | |
| 401 | + | case strings.HasSuffix(lower, ".gif"): | |
| 402 | + | return "image/gif" | |
| 403 | + | case strings.HasSuffix(lower, ".webp"): | |
| 404 | + | return "image/webp" | |
| 405 | + | } | |
| 406 | + | return "image/jpeg" | |
| 407 | + | } | |
| @@ -0,0 +1,407 @@ | |||||
| 1 | + | package telegram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "sync" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/patriceckhart/zot/internal/core" | |||
| 11 | + | "github.com/patriceckhart/zot/internal/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Bot owns the Telegram polling loop and dispatches inbound DMs to | |||
| 15 | + | // the agent. It is a long-running goroutine; Run blocks until ctx | |||
| 16 | + | // cancels. | |||
| 17 | + | type Bot struct { | |||
| 18 | + | Client *Client | |||
| 19 | + | Agent *core.Agent | |||
| 20 | + | Config Config | |||
| 21 | + | ZotHome string | |||
| 22 | + | // Save persists cfg to bot.json. Called whenever the bot pairs | |||
| 23 | + | // with a new allowed user or advances LastUpdateID. | |||
| 24 | + | Save func(Config) error | |||
| 25 | + | // RefreshCreds is called before every turn to pick up newly | |||
| 26 | + | // refreshed OAuth tokens. Optional; when nil, the bot uses the | |||
| 27 | + | // credential it was built with. Implementations typically call | |||
| 28 | + | // agent.ResolveCredentialFull which auto-refreshes expired tokens. | |||
| 29 | + | RefreshCreds func() error | |||
| 30 | + | ||||
| 31 | + | mu sync.Mutex | |||
| 32 | + | busy bool | |||
| 33 | + | activeCtx context.CancelFunc | |||
| 34 | + | queue []queuedTurn | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // queuedTurn is an inbound DM waiting to become a prompt. | |||
| 38 | + | type queuedTurn struct { | |||
| 39 | + | chatID int64 | |||
| 40 | + | messageID int | |||
| 41 | + | prompt string | |||
| 42 | + | images []provider.ImageBlock | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Run drives the bot. Returns when ctx is cancelled or GetMe fails. | |||
| 46 | + | func (b *Bot) Run(ctx context.Context) error { | |||
| 47 | + | if b.Config.BotToken == "" { | |||
| 48 | + | return fmt.Errorf("no bot token configured; run `zot bot setup` first") | |||
| 49 | + | } | |||
| 50 | + | me, err := b.Client.GetMe(ctx) | |||
| 51 | + | if err != nil { | |||
| 52 | + | return fmt.Errorf("getMe: %w", err) | |||
| 53 | + | } | |||
| 54 | + | // Keep the stored username/id in sync with the actual bot. | |||
| 55 | + | if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username { | |||
| 56 | + | b.Config.BotID = me.ID | |||
| 57 | + | b.Config.BotUsername = me.Username | |||
| 58 | + | _ = b.Save(b.Config) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | fmt.Printf("telegram bridge online as @%s (id=%d)\n", me.Username, me.ID) | |||
| 62 | + | if b.Config.AllowedUserID == 0 { | |||
| 63 | + | fmt.Println("no user paired yet — send /start to the bot from Telegram to claim it") | |||
| 64 | + | } else { | |||
| 65 | + | fmt.Printf("paired with telegram user id %d\n", b.Config.AllowedUserID) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | return b.pollLoop(ctx) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // pollLoop long-polls Telegram for updates and dispatches them. | |||
| 72 | + | func (b *Bot) pollLoop(ctx context.Context) error { | |||
| 73 | + | backoff := time.Second | |||
| 74 | + | for { | |||
| 75 | + | if err := ctx.Err(); err != nil { | |||
| 76 | + | return err | |||
| 77 | + | } | |||
| 78 | + | updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30) | |||
| 79 | + | if err != nil { | |||
| 80 | + | if ctx.Err() != nil { | |||
| 81 | + | return ctx.Err() | |||
| 82 | + | } | |||
| 83 | + | fmt.Fprintln(stderr(), "telegram: getUpdates error:", err) | |||
| 84 | + | select { | |||
| 85 | + | case <-ctx.Done(): | |||
| 86 | + | return ctx.Err() | |||
| 87 | + | case <-time.After(backoff): | |||
| 88 | + | } | |||
| 89 | + | if backoff < 30*time.Second { | |||
| 90 | + | backoff *= 2 | |||
| 91 | + | } | |||
| 92 | + | continue | |||
| 93 | + | } | |||
| 94 | + | backoff = time.Second | |||
| 95 | + | for _, u := range updates { | |||
| 96 | + | if err := b.handleUpdate(ctx, u); err != nil { | |||
| 97 | + | fmt.Fprintln(stderr(), "telegram: handleUpdate:", err) | |||
| 98 | + | } | |||
| 99 | + | b.Config.LastUpdateID = u.UpdateID | |||
| 100 | + | _ = b.Save(b.Config) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // handleUpdate processes a single Telegram update. | |||
| 106 | + | func (b *Bot) handleUpdate(ctx context.Context, u Update) error { | |||
| 107 | + | msg := u.Message | |||
| 108 | + | if msg == nil { | |||
| 109 | + | msg = u.Edited | |||
| 110 | + | } | |||
| 111 | + | if msg == nil || msg.From == nil || msg.From.IsBot { | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
| 114 | + | if msg.Chat.Type != "private" { | |||
| 115 | + | return nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // Pairing: first user who sends /start claims the bridge. | |||
| 119 | + | text := strings.TrimSpace(msg.Text) | |||
| 120 | + | if b.Config.AllowedUserID == 0 { | |||
| 121 | + | if strings.HasPrefix(text, "/start") { | |||
| 122 | + | b.Config.AllowedUserID = msg.From.ID | |||
| 123 | + | _ = b.Save(b.Config) | |||
| 124 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |||
| 125 | + | fmt.Sprintf("paired with @%s. send any message and i'll forward it to zot.", msg.From.Username), | |||
| 126 | + | msg.MessageID) | |||
| 127 | + | return nil | |||
| 128 | + | } | |||
| 129 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |||
| 130 | + | "this bot isn't paired yet. send /start to claim it.", | |||
| 131 | + | msg.MessageID) | |||
| 132 | + | return nil | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // Enforce allowed user. | |||
| 136 | + | if msg.From.ID != b.Config.AllowedUserID { | |||
| 137 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |||
| 138 | + | "this bot is paired with a different user.", | |||
| 139 | + | msg.MessageID) | |||
| 140 | + | return nil | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // Built-in commands that bypass the agent. | |||
| 144 | + | switch text { | |||
| 145 | + | case "/start", "/help": | |||
| 146 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |||
| 147 | + | "send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop.", | |||
| 148 | + | msg.MessageID) | |||
| 149 | + | return nil | |||
| 150 | + | case "/status": | |||
| 151 | + | return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID) | |||
| 152 | + | case "/stop": | |||
| 153 | + | b.mu.Lock() | |||
| 154 | + | cancel := b.activeCtx | |||
| 155 | + | b.mu.Unlock() | |||
| 156 | + | if cancel != nil { | |||
| 157 | + | cancel() | |||
| 158 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, "cancelled the current turn.", msg.MessageID) | |||
| 159 | + | } else { | |||
| 160 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, "nothing running.", msg.MessageID) | |||
| 161 | + | } | |||
| 162 | + | return nil | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Build the prompt: combine text + caption; download image attachments. | |||
| 166 | + | prompt := strings.TrimSpace(msg.Text) | |||
| 167 | + | if msg.Caption != "" { | |||
| 168 | + | if prompt != "" { | |||
| 169 | + | prompt += "\n" | |||
| 170 | + | } | |||
| 171 | + | prompt += msg.Caption | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | var images []provider.ImageBlock | |||
| 175 | + | if len(msg.Photo) > 0 { | |||
| 176 | + | // Photos arrive in multiple sizes; take the largest (last in the slice). | |||
| 177 | + | largest := msg.Photo[len(msg.Photo)-1] | |||
| 178 | + | if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil { | |||
| 179 | + | images = append(images, provider.ImageBlock{MimeType: mime, Data: data}) | |||
| 180 | + | } else { | |||
| 181 | + | fmt.Fprintln(stderr(), "telegram: download photo:", err) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | if msg.Document != nil && isImageMIME(msg.Document.MimeType) { | |||
| 185 | + | if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil { | |||
| 186 | + | images = append(images, provider.ImageBlock{MimeType: mime, Data: data}) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | if prompt == "" && len(images) == 0 { | |||
| 191 | + | return nil | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | b.mu.Lock() | |||
| 195 | + | b.queue = append(b.queue, queuedTurn{ | |||
| 196 | + | chatID: msg.Chat.ID, | |||
| 197 | + | messageID: msg.MessageID, | |||
| 198 | + | prompt: prompt, | |||
| 199 | + | images: images, | |||
| 200 | + | }) | |||
| 201 | + | idle := !b.busy | |||
| 202 | + | b.mu.Unlock() | |||
| 203 | + | ||||
| 204 | + | if idle { | |||
| 205 | + | go b.drainQueue(ctx) | |||
| 206 | + | } | |||
| 207 | + | return nil | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // drainQueue runs queued turns one at a time until the queue is empty. | |||
| 211 | + | func (b *Bot) drainQueue(parent context.Context) { | |||
| 212 | + | for { | |||
| 213 | + | b.mu.Lock() | |||
| 214 | + | if len(b.queue) == 0 { | |||
| 215 | + | b.busy = false | |||
| 216 | + | b.activeCtx = nil | |||
| 217 | + | b.mu.Unlock() | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | t := b.queue[0] | |||
| 221 | + | b.queue = b.queue[1:] | |||
| 222 | + | b.busy = true | |||
| 223 | + | turnCtx, cancel := context.WithCancel(parent) | |||
| 224 | + | b.activeCtx = cancel | |||
| 225 | + | b.mu.Unlock() | |||
| 226 | + | ||||
| 227 | + | if b.RefreshCreds != nil { | |||
| 228 | + | if err := b.RefreshCreds(); err != nil { | |||
| 229 | + | fmt.Fprintln(stderr(), "telegram: refresh creds:", err) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | b.runTurn(turnCtx, t) | |||
| 233 | + | cancel() | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // runTurn sends the queued prompt to the agent and streams the reply. | |||
| 238 | + | func (b *Bot) runTurn(ctx context.Context, t queuedTurn) { | |||
| 239 | + | stopTyping := b.startTyping(ctx, t.chatID) | |||
| 240 | + | defer stopTyping() | |||
| 241 | + | ||||
| 242 | + | var replyBuilder strings.Builder | |||
| 243 | + | var lastAssistantText string | |||
| 244 | + | var turnErr error | |||
| 245 | + | ||||
| 246 | + | sink := func(ev core.AgentEvent) { | |||
| 247 | + | switch e := ev.(type) { | |||
| 248 | + | case core.EvTextDelta: | |||
| 249 | + | replyBuilder.WriteString(e.Delta) | |||
| 250 | + | case core.EvAssistantMessage: | |||
| 251 | + | var sb strings.Builder | |||
| 252 | + | for _, c := range e.Message.Content { | |||
| 253 | + | if tb, ok := c.(provider.TextBlock); ok { | |||
| 254 | + | if sb.Len() > 0 { | |||
| 255 | + | sb.WriteString("\n") | |||
| 256 | + | } | |||
| 257 | + | sb.WriteString(tb.Text) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | if sb.Len() > 0 { | |||
| 261 | + | lastAssistantText = sb.String() | |||
| 262 | + | } | |||
| 263 | + | replyBuilder.Reset() | |||
| 264 | + | case core.EvTurnEnd: | |||
| 265 | + | if e.Err != nil { | |||
| 266 | + | turnErr = e.Err | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | if err := b.Agent.Prompt(ctx, t.prompt, t.images, sink); err != nil { | |||
| 272 | + | turnErr = err | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | reply := strings.TrimSpace(lastAssistantText) | |||
| 276 | + | if reply == "" { | |||
| 277 | + | reply = strings.TrimSpace(replyBuilder.String()) | |||
| 278 | + | } | |||
| 279 | + | if turnErr != nil && ctx.Err() == nil { | |||
| 280 | + | reply = "error: " + turnErr.Error() | |||
| 281 | + | } | |||
| 282 | + | if reply == "" { | |||
| 283 | + | reply = "(no reply)" | |||
| 284 | + | } | |||
| 285 | + | // Telegram caps messages at 4096 chars. Chunk to be safe. | |||
| 286 | + | for _, chunk := range chunkMessage(reply, 4000) { | |||
| 287 | + | if err := b.Client.SendMessage(context.Background(), t.chatID, chunk, 0); err != nil { | |||
| 288 | + | fmt.Fprintln(stderr(), "telegram: sendMessage:", err) | |||
| 289 | + | break | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // startTyping keeps Telegram's "typing..." indicator alive until the | |||
| 295 | + | // returned stop function is called. | |||
| 296 | + | func (b *Bot) startTyping(ctx context.Context, chatID int64) func() { | |||
| 297 | + | tctx, cancel := context.WithCancel(ctx) | |||
| 298 | + | go func() { | |||
| 299 | + | for { | |||
| 300 | + | _ = b.Client.SendChatAction(tctx, chatID, "typing") | |||
| 301 | + | select { | |||
| 302 | + | case <-tctx.Done(): | |||
| 303 | + | return | |||
| 304 | + | case <-time.After(4 * time.Second): | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | }() | |||
| 308 | + | return cancel | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | // sendStatus describes agent state to the Telegram user. | |||
| 312 | + | func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error { | |||
| 313 | + | b.mu.Lock() | |||
| 314 | + | busy := b.busy | |||
| 315 | + | queued := len(b.queue) | |||
| 316 | + | b.mu.Unlock() | |||
| 317 | + | ||||
| 318 | + | state := "idle" | |||
| 319 | + | if busy { | |||
| 320 | + | state = "working" | |||
| 321 | + | } | |||
| 322 | + | lines := []string{ | |||
| 323 | + | fmt.Sprintf("state: %s", state), | |||
| 324 | + | fmt.Sprintf("queued: %d", queued), | |||
| 325 | + | fmt.Sprintf("model: %s", b.Agent.Model), | |||
| 326 | + | } | |||
| 327 | + | cost := b.Agent.Cost() | |||
| 328 | + | lines = append(lines, fmt.Sprintf("cost: $%.4f (%d in / %d out)", | |||
| 329 | + | cost.CostUSD, cost.InputTokens, cost.OutputTokens)) | |||
| 330 | + | return b.Client.SendMessage(ctx, chatID, strings.Join(lines, "\n"), replyTo) | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // download fetches a file from Telegram and returns bytes + mime. | |||
| 334 | + | func (b *Bot) download(ctx context.Context, fileID, mime string) ([]byte, string, error) { | |||
| 335 | + | f, err := b.Client.GetFile(ctx, fileID) | |||
| 336 | + | if err != nil { | |||
| 337 | + | return nil, "", err | |||
| 338 | + | } | |||
| 339 | + | data, err := b.Client.DownloadFile(ctx, f.FilePath) | |||
| 340 | + | if err != nil { | |||
| 341 | + | return nil, "", err | |||
| 342 | + | } | |||
| 343 | + | if mime == "" { | |||
| 344 | + | mime = guessImageMIME(f.FilePath) | |||
| 345 | + | } | |||
| 346 | + | return data, mime, nil | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | // chunkMessage splits s into chunks no larger than limit runes, on line | |||
| 350 | + | // boundaries when possible. | |||
| 351 | + | func chunkMessage(s string, limit int) []string { | |||
| 352 | + | if len(s) <= limit { | |||
| 353 | + | return []string{s} | |||
| 354 | + | } | |||
| 355 | + | var out []string | |||
| 356 | + | lines := strings.Split(s, "\n") | |||
| 357 | + | var cur strings.Builder | |||
| 358 | + | for _, l := range lines { | |||
| 359 | + | if cur.Len()+len(l)+1 > limit && cur.Len() > 0 { | |||
| 360 | + | out = append(out, cur.String()) | |||
| 361 | + | cur.Reset() | |||
| 362 | + | } | |||
| 363 | + | if len(l) > limit { | |||
| 364 | + | // Line itself too long; hard-split. | |||
| 365 | + | for len(l) > limit { | |||
| 366 | + | out = append(out, l[:limit]) | |||
| 367 | + | l = l[limit:] | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | if cur.Len() > 0 { | |||
| 371 | + | cur.WriteString("\n") | |||
| 372 | + | } | |||
| 373 | + | cur.WriteString(l) | |||
| 374 | + | } | |||
| 375 | + | if cur.Len() > 0 { | |||
| 376 | + | out = append(out, cur.String()) | |||
| 377 | + | } | |||
| 378 | + | return out | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // isImageMIME returns true for MIME types the model can probably ingest | |||
| 382 | + | // as a vision input. | |||
| 383 | + | func isImageMIME(m string) bool { | |||
| 384 | + | switch strings.ToLower(m) { | |||
| 385 | + | case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp": | |||
| 386 | + | return true | |||
| 387 | + | } | |||
| 388 | + | return false | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | // guessImageMIME infers a mime type from a filename suffix. Falls back | |||
| 392 | + | // to image/png because telegram photos are always re-encoded to jpeg | |||
| 393 | + | // but getFile's file_path may omit the extension. | |||
| 394 | + | func guessImageMIME(path string) string { | |||
| 395 | + | lower := strings.ToLower(path) | |||
| 396 | + | switch { | |||
| 397 | + | case strings.HasSuffix(lower, ".png"): | |||
| 398 | + | return "image/png" | |||
| 399 | + | case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"): | |||
| 400 | + | return "image/jpeg" | |||
| 401 | + | case strings.HasSuffix(lower, ".gif"): | |||
| 402 | + | return "image/gif" | |||
| 403 | + | case strings.HasSuffix(lower, ".webp"): | |||
| 404 | + | return "image/webp" | |||
| 405 | + | } | |||
| 406 | + | return "image/jpeg" | |||
| 407 | + | } | |||
A internal/agent/modes/telegram/config.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | // Package telegram implements zot's Telegram bot bridge. | |
| 2 | + | // | |
| 3 | + | // It runs in-process, polling Telegram for DMs and forwarding them to | |
| 4 | + | // a core.Agent. Responses stream back as Telegram messages. Images | |
| 5 | + | // sent by the user are downloaded and attached to the next prompt as | |
| 6 | + | // provider.ImageBlock, so vision-capable models see them the same way | |
| 7 | + | // they would via drag-and-drop in the TUI. | |
| 8 | + | // | |
| 9 | + | // State (bot token, allowed user id) lives in $ZOT_HOME/bot.json. | |
| 10 | + | package telegram | |
| 11 | + | ||
| 12 | + | import ( | |
| 13 | + | "encoding/json" | |
| 14 | + | "errors" | |
| 15 | + | "os" | |
| 16 | + | "path/filepath" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Config is the on-disk state for the telegram bridge. | |
| 20 | + | type Config struct { | |
| 21 | + | BotToken string `json:"bot_token,omitempty"` | |
| 22 | + | BotUsername string `json:"bot_username,omitempty"` | |
| 23 | + | BotID int64 `json:"bot_id,omitempty"` | |
| 24 | + | AllowedUserID int64 `json:"allowed_user_id,omitempty"` | |
| 25 | + | LastUpdateID int64 `json:"last_update_id,omitempty"` | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // ConfigPath returns the path to bot.json. | |
| 29 | + | func ConfigPath(zotHome string) string { | |
| 30 | + | return filepath.Join(zotHome, "bot.json") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // LoadConfig reads bot.json, returning a zero Config if it doesn't exist. | |
| 34 | + | func LoadConfig(zotHome string) (Config, error) { | |
| 35 | + | var c Config | |
| 36 | + | b, err := os.ReadFile(ConfigPath(zotHome)) | |
| 37 | + | if errors.Is(err, os.ErrNotExist) { | |
| 38 | + | return c, nil | |
| 39 | + | } | |
| 40 | + | if err != nil { | |
| 41 | + | return c, err | |
| 42 | + | } | |
| 43 | + | if err := json.Unmarshal(b, &c); err != nil { | |
| 44 | + | return c, err | |
| 45 | + | } | |
| 46 | + | return c, nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // SaveConfig writes bot.json atomically. | |
| 50 | + | func SaveConfig(zotHome string, c Config) error { | |
| 51 | + | if err := os.MkdirAll(zotHome, 0o755); err != nil { | |
| 52 | + | return err | |
| 53 | + | } | |
| 54 | + | b, err := json.MarshalIndent(c, "", " ") | |
| 55 | + | if err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | path := ConfigPath(zotHome) | |
| 59 | + | tmp := path + ".tmp" | |
| 60 | + | if err := os.WriteFile(tmp, b, 0o600); err != nil { | |
| 61 | + | return err | |
| 62 | + | } | |
| 63 | + | return os.Rename(tmp, path) | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | // Package telegram implements zot's Telegram bot bridge. | |||
| 2 | + | // | |||
| 3 | + | // It runs in-process, polling Telegram for DMs and forwarding them to | |||
| 4 | + | // a core.Agent. Responses stream back as Telegram messages. Images | |||
| 5 | + | // sent by the user are downloaded and attached to the next prompt as | |||
| 6 | + | // provider.ImageBlock, so vision-capable models see them the same way | |||
| 7 | + | // they would via drag-and-drop in the TUI. | |||
| 8 | + | // | |||
| 9 | + | // State (bot token, allowed user id) lives in $ZOT_HOME/bot.json. | |||
| 10 | + | package telegram | |||
| 11 | + | ||||
| 12 | + | import ( | |||
| 13 | + | "encoding/json" | |||
| 14 | + | "errors" | |||
| 15 | + | "os" | |||
| 16 | + | "path/filepath" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // Config is the on-disk state for the telegram bridge. | |||
| 20 | + | type Config struct { | |||
| 21 | + | BotToken string `json:"bot_token,omitempty"` | |||
| 22 | + | BotUsername string `json:"bot_username,omitempty"` | |||
| 23 | + | BotID int64 `json:"bot_id,omitempty"` | |||
| 24 | + | AllowedUserID int64 `json:"allowed_user_id,omitempty"` | |||
| 25 | + | LastUpdateID int64 `json:"last_update_id,omitempty"` | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // ConfigPath returns the path to bot.json. | |||
| 29 | + | func ConfigPath(zotHome string) string { | |||
| 30 | + | return filepath.Join(zotHome, "bot.json") | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // LoadConfig reads bot.json, returning a zero Config if it doesn't exist. | |||
| 34 | + | func LoadConfig(zotHome string) (Config, error) { | |||
| 35 | + | var c Config | |||
| 36 | + | b, err := os.ReadFile(ConfigPath(zotHome)) | |||
| 37 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 38 | + | return c, nil | |||
| 39 | + | } | |||
| 40 | + | if err != nil { | |||
| 41 | + | return c, err | |||
| 42 | + | } | |||
| 43 | + | if err := json.Unmarshal(b, &c); err != nil { | |||
| 44 | + | return c, err | |||
| 45 | + | } | |||
| 46 | + | return c, nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // SaveConfig writes bot.json atomically. | |||
| 50 | + | func SaveConfig(zotHome string, c Config) error { | |||
| 51 | + | if err := os.MkdirAll(zotHome, 0o755); err != nil { | |||
| 52 | + | return err | |||
| 53 | + | } | |||
| 54 | + | b, err := json.MarshalIndent(c, "", " ") | |||
| 55 | + | if err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | path := ConfigPath(zotHome) | |||
| 59 | + | tmp := path + ".tmp" | |||
| 60 | + | if err := os.WriteFile(tmp, b, 0o600); err != nil { | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 63 | + | return os.Rename(tmp, path) | |||
| 64 | + | } | |||
A internal/agent/modes/telegram/daemon.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | package telegram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strconv" | |
| 9 | + | "strings" | |
| 10 | + | "syscall" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // PIDPath returns the location of the bot's pid file. | |
| 15 | + | func PIDPath(zotHome string) string { | |
| 16 | + | return filepath.Join(zotHome, "bot.pid") | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // LogPath returns the location of the bot's log file (stdout+stderr | |
| 20 | + | // from a detached `zot bot start`). | |
| 21 | + | func LogPath(zotHome string) string { | |
| 22 | + | return filepath.Join(zotHome, "logs", "bot.log") | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // WritePID persists pid to bot.pid. Overwrites any existing file. | |
| 26 | + | func WritePID(zotHome string, pid int) error { | |
| 27 | + | p := PIDPath(zotHome) | |
| 28 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |
| 29 | + | return err | |
| 30 | + | } | |
| 31 | + | return os.WriteFile(p, []byte(strconv.Itoa(pid)+"\n"), 0o644) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't | |
| 35 | + | // exist. Returns an error for any other read/parse failure. | |
| 36 | + | func ReadPID(zotHome string) (int, error) { | |
| 37 | + | b, err := os.ReadFile(PIDPath(zotHome)) | |
| 38 | + | if errors.Is(err, os.ErrNotExist) { | |
| 39 | + | return 0, nil | |
| 40 | + | } | |
| 41 | + | if err != nil { | |
| 42 | + | return 0, err | |
| 43 | + | } | |
| 44 | + | pid, err := strconv.Atoi(strings.TrimSpace(string(b))) | |
| 45 | + | if err != nil { | |
| 46 | + | return 0, fmt.Errorf("parse pid: %w", err) | |
| 47 | + | } | |
| 48 | + | return pid, nil | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // RemovePID deletes the pid file if it exists. | |
| 52 | + | func RemovePID(zotHome string) error { | |
| 53 | + | err := os.Remove(PIDPath(zotHome)) | |
| 54 | + | if errors.Is(err, os.ErrNotExist) { | |
| 55 | + | return nil | |
| 56 | + | } | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // IsRunning returns (pid, true) if a live process with the recorded | |
| 61 | + | // pid exists, or (pid, false) if the pid file points to a dead process. | |
| 62 | + | // Stale pid files are left in place; the caller may remove them. | |
| 63 | + | func IsRunning(zotHome string) (int, bool, error) { | |
| 64 | + | pid, err := ReadPID(zotHome) | |
| 65 | + | if err != nil { | |
| 66 | + | return 0, false, err | |
| 67 | + | } | |
| 68 | + | if pid <= 0 { | |
| 69 | + | return 0, false, nil | |
| 70 | + | } | |
| 71 | + | proc, err := os.FindProcess(pid) | |
| 72 | + | if err != nil { | |
| 73 | + | return pid, false, nil | |
| 74 | + | } | |
| 75 | + | // signal 0 is POSIX's "does the process exist?" probe. On Windows | |
| 76 | + | // os.Process is always usable and Signal(0) returns nil, so we'd | |
| 77 | + | // miss stale pids; acceptable for the macos/linux-first audience. | |
| 78 | + | if err := proc.Signal(syscall.Signal(0)); err != nil { | |
| 79 | + | if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { | |
| 80 | + | return pid, false, nil | |
| 81 | + | } | |
| 82 | + | // Other errors (EPERM) mean the process exists but we can't | |
| 83 | + | // inspect it; treat as running. | |
| 84 | + | return pid, true, nil | |
| 85 | + | } | |
| 86 | + | return pid, true, nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // StopProcess sends SIGTERM to pid and waits up to graceful for it to | |
| 90 | + | // exit, then escalates to SIGKILL. Returns nil if the process is gone. | |
| 91 | + | func StopProcess(pid int, graceful time.Duration) error { | |
| 92 | + | proc, err := os.FindProcess(pid) | |
| 93 | + | if err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | _ = proc.Signal(syscall.SIGTERM) | |
| 97 | + | ||
| 98 | + | deadline := time.Now().Add(graceful) | |
| 99 | + | for time.Now().Before(deadline) { | |
| 100 | + | if err := proc.Signal(syscall.Signal(0)); err != nil { | |
| 101 | + | if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | time.Sleep(100 * time.Millisecond) | |
| 106 | + | } | |
| 107 | + | _ = proc.Kill() | |
| 108 | + | return nil | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package telegram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strconv" | |||
| 9 | + | "strings" | |||
| 10 | + | "syscall" | |||
| 11 | + | "time" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // PIDPath returns the location of the bot's pid file. | |||
| 15 | + | func PIDPath(zotHome string) string { | |||
| 16 | + | return filepath.Join(zotHome, "bot.pid") | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // LogPath returns the location of the bot's log file (stdout+stderr | |||
| 20 | + | // from a detached `zot bot start`). | |||
| 21 | + | func LogPath(zotHome string) string { | |||
| 22 | + | return filepath.Join(zotHome, "logs", "bot.log") | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // WritePID persists pid to bot.pid. Overwrites any existing file. | |||
| 26 | + | func WritePID(zotHome string, pid int) error { | |||
| 27 | + | p := PIDPath(zotHome) | |||
| 28 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |||
| 29 | + | return err | |||
| 30 | + | } | |||
| 31 | + | return os.WriteFile(p, []byte(strconv.Itoa(pid)+"\n"), 0o644) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't | |||
| 35 | + | // exist. Returns an error for any other read/parse failure. | |||
| 36 | + | func ReadPID(zotHome string) (int, error) { | |||
| 37 | + | b, err := os.ReadFile(PIDPath(zotHome)) | |||
| 38 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 39 | + | return 0, nil | |||
| 40 | + | } | |||
| 41 | + | if err != nil { | |||
| 42 | + | return 0, err | |||
| 43 | + | } | |||
| 44 | + | pid, err := strconv.Atoi(strings.TrimSpace(string(b))) | |||
| 45 | + | if err != nil { | |||
| 46 | + | return 0, fmt.Errorf("parse pid: %w", err) | |||
| 47 | + | } | |||
| 48 | + | return pid, nil | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // RemovePID deletes the pid file if it exists. | |||
| 52 | + | func RemovePID(zotHome string) error { | |||
| 53 | + | err := os.Remove(PIDPath(zotHome)) | |||
| 54 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 55 | + | return nil | |||
| 56 | + | } | |||
| 57 | + | return err | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // IsRunning returns (pid, true) if a live process with the recorded | |||
| 61 | + | // pid exists, or (pid, false) if the pid file points to a dead process. | |||
| 62 | + | // Stale pid files are left in place; the caller may remove them. | |||
| 63 | + | func IsRunning(zotHome string) (int, bool, error) { | |||
| 64 | + | pid, err := ReadPID(zotHome) | |||
| 65 | + | if err != nil { | |||
| 66 | + | return 0, false, err | |||
| 67 | + | } | |||
| 68 | + | if pid <= 0 { | |||
| 69 | + | return 0, false, nil | |||
| 70 | + | } | |||
| 71 | + | proc, err := os.FindProcess(pid) | |||
| 72 | + | if err != nil { | |||
| 73 | + | return pid, false, nil | |||
| 74 | + | } | |||
| 75 | + | // signal 0 is POSIX's "does the process exist?" probe. On Windows | |||
| 76 | + | // os.Process is always usable and Signal(0) returns nil, so we'd | |||
| 77 | + | // miss stale pids; acceptable for the macos/linux-first audience. | |||
| 78 | + | if err := proc.Signal(syscall.Signal(0)); err != nil { | |||
| 79 | + | if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { | |||
| 80 | + | return pid, false, nil | |||
| 81 | + | } | |||
| 82 | + | // Other errors (EPERM) mean the process exists but we can't | |||
| 83 | + | // inspect it; treat as running. | |||
| 84 | + | return pid, true, nil | |||
| 85 | + | } | |||
| 86 | + | return pid, true, nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // StopProcess sends SIGTERM to pid and waits up to graceful for it to | |||
| 90 | + | // exit, then escalates to SIGKILL. Returns nil if the process is gone. | |||
| 91 | + | func StopProcess(pid int, graceful time.Duration) error { | |||
| 92 | + | proc, err := os.FindProcess(pid) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return err | |||
| 95 | + | } | |||
| 96 | + | _ = proc.Signal(syscall.SIGTERM) | |||
| 97 | + | ||||
| 98 | + | deadline := time.Now().Add(graceful) | |||
| 99 | + | for time.Now().Before(deadline) { | |||
| 100 | + | if err := proc.Signal(syscall.Signal(0)); err != nil { | |||
| 101 | + | if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | time.Sleep(100 * time.Millisecond) | |||
| 106 | + | } | |||
| 107 | + | _ = proc.Kill() | |||
| 108 | + | return nil | |||
| 109 | + | } | |||
A internal/agent/modes/telegram/fs.go +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | package telegram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "os" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func defaultOpen(path string) (io.ReadCloser, error) { | |
| 9 | + | return os.Open(path) | |
| 10 | + | } | |
| @@ -0,0 +1,10 @@ | |||||
| 1 | + | package telegram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "os" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func defaultOpen(path string) (io.ReadCloser, error) { | |||
| 9 | + | return os.Open(path) | |||
| 10 | + | } | |||
A internal/agent/modes/telegram/io.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | package telegram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "os" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // stderr is a tiny hook so tests can redirect bot logging. | |
| 9 | + | var stderr = func() io.Writer { return os.Stderr } | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | package telegram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "os" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // stderr is a tiny hook so tests can redirect bot logging. | |||
| 9 | + | var stderr = func() io.Writer { return os.Stderr } | |||