|
| 1 | +# Telegram Bot Integration |
| 2 | + |
| 3 | +odek includes a built-in Telegram bot that lets you run agent tasks directly from Telegram. The bot supports long-polling, slash commands, voice messages, photo messages, session persistence, and plan management. |
| 4 | + |
| 5 | +## Architecture |
| 6 | + |
| 7 | +``` |
| 8 | +Telegram Bot API ◄── bot.go (HTTP client) |
| 9 | + │ |
| 10 | + poller.go (long-polling loop) |
| 11 | + │ |
| 12 | + handler.go (message dispatcher) |
| 13 | + │ |
| 14 | + ┌─────┴──────┐ |
| 15 | + │ │ |
| 16 | + commands.go session.go (per-chat agent sessions) |
| 17 | + (slash │ |
| 18 | + commands) plan.go (.md plan files) |
| 19 | + │ |
| 20 | + download.go (voice/photo media) |
| 21 | +``` |
| 22 | + |
| 23 | +The package is self-contained under `internal/telegram/` with 409 tests and 86.9% coverage. All Telegram API calls use the Bot struct, which wraps `net/http` with JSON marshaling and multipart upload support. No external Telegram libraries are used. |
| 24 | + |
| 25 | +## Configuration |
| 26 | + |
| 27 | +### Environment Variables |
| 28 | + |
| 29 | +All configuration flows through `TelegramConfig` and can be set via environment variables: |
| 30 | + |
| 31 | +| Variable | Field | Default | |
| 32 | +|---|---|---| |
| 33 | +| `ODEK_TELEGRAM_BOT_TOKEN` | Token | — (required) | |
| 34 | +| `ODEK_TELEGRAM_ALLOWED_CHATS` | AllowedChats | all | |
| 35 | +| `ODEK_TELEGRAM_ALLOWED_USERS` | AllowedUsers | all | |
| 36 | +| `ODEK_TELEGRAM_BOT_USERNAME` | BotUsername | — | |
| 37 | +| `ODEK_TELEGRAM_POLL_INTERVAL` | PollInterval | 1s | |
| 38 | +| `ODEK_TELEGRAM_POLL_TIMEOUT` | PollTimeout | 30s | |
| 39 | +| `ODEK_TELEGRAM_MAX_MSG_LENGTH` | MaxMsgLength | 4096 | |
| 40 | +| `ODEK_TELEGRAM_DAILY_TOKEN_BUDGET` | DailyTokenBudget | unlimited | |
| 41 | +| `ODEK_TELEGRAM_SESSION_TTL_HOURS` | SessionTTL | 24h | |
| 42 | +| `ODEK_TELEGRAM_FALLBACK_URLS` | FallbackURLs | — | |
| 43 | +| `ODEK_TELEGRAM_LOG_LEVEL` | LogLevel | info | |
| 44 | +| `ODEK_TELEGRAM_LOG_FILE` | LogFile | stderr | |
| 45 | + |
| 46 | +### Config Validation |
| 47 | + |
| 48 | +`ValidateConfig` checks: |
| 49 | +- **Token** must not be empty |
| 50 | +- **PollInterval** must be ≥ 1 |
| 51 | +- **PollTimeout** must be between 1 and 60 |
| 52 | +- **MaxMsgLength** must be between 1 and 4096 |
| 53 | +- **SessionTTL** must be ≥ 1 |
| 54 | + |
| 55 | +## Bot API Client (`bot.go`) |
| 56 | + |
| 57 | +The `Bot` struct is a lightweight Telegram Bot API client built on the standard library. It provides methods for all major API endpoints. |
| 58 | + |
| 59 | +### Core Methods |
| 60 | + |
| 61 | +| Method | Endpoint | Purpose | |
| 62 | +|---|---|---| |
| 63 | +| `GetUpdates` | `getUpdates` | Long-poll for incoming updates | |
| 64 | +| `SendMessage` | `sendMessage` | Send a text message (supports parse mode, reply markup, web page preview) | |
| 65 | +| `EditMessageText` | `editMessageText` | Edit a previously sent message | |
| 66 | +| `SendPhoto` | `sendPhoto` | Send a photo file (multipart upload) | |
| 67 | +| `SendVoice` | `sendVoice` | Send a voice note (multipart upload) | |
| 68 | +| `SendChatAction` | `sendChatAction` | Show typing/recording status in chat | |
| 69 | +| `GetFile` | `getFile` | Get file metadata for download | |
| 70 | +| `DownloadFile` | — | Download file bytes from Telegram's file server | |
| 71 | +| `AnswerCallbackQuery` | `answerCallbackQuery` | Answer inline keyboard callbacks | |
| 72 | +| `SetMyCommands` | `setMyCommands` | Register bot command list | |
| 73 | +| `GetMe` | `getMe` | Health check / bot info | |
| 74 | +| `CheckDailyBudget` | — | Enforce daily token usage limit | |
| 75 | + |
| 76 | +### Underlying HTTP Helpers |
| 77 | + |
| 78 | +- **`doJSON`** — Sends JSON POST requests and unmarshals the `result` field from Telegram's response envelope |
| 79 | +- **`doUpload`** — Sends multipart/form-data POST requests for file uploads (photo, voice) |
| 80 | + |
| 81 | +### Fallback URLs |
| 82 | + |
| 83 | +`SetFallbackURLs` configures alternate Telegram API endpoints. If the primary endpoint is unreachable, the bot falls through to the next URL in the list. This is useful for regions where `api.telegram.org` may be blocked. |
| 84 | + |
| 85 | +### Daily Token Budget |
| 86 | + |
| 87 | +`SetDailyTokenBudget` and `CheckDailyBudget` implement a simple daily token usage tracker: |
| 88 | +- Usage is persisted to `~/.odek/telegram_token_usage_<YYYY-MM-DD>` |
| 89 | +- Budget resets automatically each calendar day |
| 90 | +- Returns an error if the total exceeds the configured budget |
| 91 | +- No-op when budget is 0 (unlimited) |
| 92 | + |
| 93 | +## Long-Polling (`poller.go`) |
| 94 | + |
| 95 | +The `Poller` struct implements Telegram's long-polling update mechanism: |
| 96 | + |
| 97 | +- **Offset tracking**: updates are acknowledged by advancing the offset past the highest received update ID, preventing duplicate processing |
| 98 | +- **Configurable timeout**: default 30s long-poll (Telegram holds the connection open until updates arrive or the timeout expires) |
| 99 | +- **Polling interval**: 1s between poll cycles (configurable via `PollInterval`) |
| 100 | + |
| 101 | +```go |
| 102 | +poller := telegram.NewPoller(bot) |
| 103 | +poller.SetLogger(logger) |
| 104 | + |
| 105 | +for { |
| 106 | + updates, err := poller.Poll(ctx) |
| 107 | + if err != nil { |
| 108 | + continue // log and retry |
| 109 | + } |
| 110 | + for _, update := range updates { |
| 111 | + handler.Handle(ctx, update) |
| 112 | + } |
| 113 | +} |
| 114 | +``` |
| 115 | + |
| 116 | +## Message Handler (`handler.go`) |
| 117 | + |
| 118 | +The `Handler` struct routes incoming updates to the appropriate callback based on message type. It is the bridge between raw Telegram updates and the agent. |
| 119 | + |
| 120 | +### Callbacks |
| 121 | + |
| 122 | +| Callback | Trigger | Signature | |
| 123 | +|---|---|---| |
| 124 | +| `OnTextMessage` | Plain text message | `(chatID int64, text string) (string, error)` | |
| 125 | +| `OnCommand` | Slash command (e.g. `/start`) | `(chatID int64, command, args string) (string, error)` | |
| 126 | +| `OnVoiceMessage` | Voice message (OGG) | `(chatID int64, fileID string) (string, error)` | |
| 127 | +| `OnPhotoMessage` | Photo message | `(chatID int64, fileIDs []string) (string, error)` | |
| 128 | +| `OnCallbackQuery` | Inline keyboard callback | `(chatID int64, callbackData string) (string, error)` | |
| 129 | + |
| 130 | +All callbacks return a response string (may be empty) and an error. The `Handle` method: |
| 131 | +1. Sends `SendChatAction("typing")` immediately |
| 132 | +2. Dispatches to the appropriate callback |
| 133 | +3. Sends the response text back to the chat |
| 134 | +4. Splits long messages exceeding `MaxMsgLength` into chunks |
| 135 | + |
| 136 | +### Access Control |
| 137 | + |
| 138 | +`HandlerConfig` supports: |
| 139 | +- **AllowedChats** — restrict to specific chat IDs (empty = allow all) |
| 140 | +- **AllowedUsers** — restrict to specific user IDs (empty = allow all) |
| 141 | +- **BotUsername** — for `@mention` detection in groups |
| 142 | + |
| 143 | +### Inline Keyboards |
| 144 | + |
| 145 | +The handler uses `sync.Map` for `TelegramApprover` instances, keyed by `chatID`. This allows the agent to send inline keyboard approval requests (yes/no) and receive responses via callback queries. The handler intercepts callback queries matching pending approval requests before dispatching to `OnCallbackQuery`. |
| 146 | + |
| 147 | +## Slash Commands (`commands.go`) |
| 148 | + |
| 149 | +### Built-in Commands |
| 150 | + |
| 151 | +| Command | Description | |
| 152 | +|---|---| |
| 153 | +| `/start` | Welcome message and bot introduction | |
| 154 | +| `/help` | Show all available commands with descriptions | |
| 155 | +| `/new` | Reset the current conversation (clear context) | |
| 156 | +| `/stats` | Show session statistics (turn count, model used, etc.) | |
| 157 | +| `/stop` | Cancel a running agent task | |
| 158 | +| `/mode` | Toggle agent modes (sandbox, verbose) | |
| 159 | +| `/restart` | Gracefully restart the bot process | |
| 160 | +| `/plan <description>` | Create a new plan from a natural language description | |
| 161 | +| `/plans` | List all saved plans | |
| 162 | +| `/plan-view <slug>` | View a specific plan's content | |
| 163 | +| `/plan-delete <slug>` | Delete a saved plan | |
| 164 | +| `/sessions` | List recent conversation sessions | |
| 165 | +| `/resume <session_id>` | Resume a previous session by ID | |
| 166 | +| `/prune [days]` | Clean up old sessions (default: 30 days) | |
| 167 | + |
| 168 | +### Architecture |
| 169 | + |
| 170 | +Commands are registered via `CommandDescriptor` structs in the `DefaultCommands` slice. Each descriptor has: |
| 171 | +- **Command** — the slash command name (without `/`) |
| 172 | +- **Description** — shown in `/help` and registered via `SetMyCommands` |
| 173 | +- **Handler** — function `func(args string) (string, error)` |
| 174 | + |
| 175 | +The handler uses `init()` to populate `DefaultCommands`, avoiding initialization cycles between the command definitions and the handler functions they reference. |
| 176 | + |
| 177 | +## Session Manager (`session.go`) |
| 178 | + |
| 179 | +The `SessionManager` manages per-chat Telegram agent conversations, backed by the existing [`session.Store`](SESSIONS.md). |
| 180 | + |
| 181 | +### How It Works |
| 182 | + |
| 183 | +1. Each Telegram chat gets a session identified by `tg-<chatID>` |
| 184 | +2. Sessions are persisted to `~/.odek/sessions/tg-<chatID>.json` |
| 185 | +3. An in-memory cache (`map[int64]*ChatSession`) avoids redundant disk reads |
| 186 | +4. Session TTL (default 24h) controls inactivity timeout |
| 187 | +5. Active sessions survive bot restarts — on reconnect, the session is loaded from disk |
| 188 | + |
| 189 | +### Key Methods |
| 190 | + |
| 191 | +| Method | Purpose | |
| 192 | +|---|---| |
| 193 | +| `GetOrCreate(chatID)` | Get existing session or create new one | |
| 194 | +| `Append(chatID, messages)` | Add messages to session | |
| 195 | +| `Save(chatID)` | Persist session to disk | |
| 196 | +| `ClearMessages(chatID)` | Clear conversation history (keeps metadata) | |
| 197 | + |
| 198 | +### Clarify Channels |
| 199 | + |
| 200 | +The `clarifyChannels` sync.Map provides per-chat channels for the agent to ask the user questions and receive answers asynchronously. This bridges the gap between the agent's synchronous `clarify` tool call and Telegram's asynchronous message flow. |
| 201 | + |
| 202 | +## Plan Management (`plan.go`) |
| 203 | + |
| 204 | +Plans are stored as markdown files in `~/.odek/plans/<slug>.md`. Each plan is created from a natural language description and persisted for later review. |
| 205 | + |
| 206 | +### Key Functions |
| 207 | + |
| 208 | +| Function | Purpose | |
| 209 | +|---|---| |
| 210 | +| `Slugify(description)` | Convert description to filesystem-safe slug (max 60 chars) | |
| 211 | +| `ListPlans(limit)` | List plans sorted by modification time (newest first) | |
| 212 | +| `ReadPlan(slug)` | Load plan content by exact slug or prefix match | |
| 213 | +| `DeletePlan(slug)` | Delete plan by exact slug or prefix match | |
| 214 | +| `MostRecentPlan()` | Return the most recently modified plan's content | |
| 215 | +| `EnsurePlansDir()` | Create plans directory if it doesn't exist | |
| 216 | + |
| 217 | +### Slug Generation |
| 218 | + |
| 219 | +Slug generation (`slugify`) collapses a description into a lowercase, hyphen-separated identifier: |
| 220 | +- Strips non-alphanumeric characters |
| 221 | +- Collapses whitespace to hyphens |
| 222 | +- Truncates to 60 characters max |
| 223 | +- Falls back to `"plan"` if no valid characters remain |
| 224 | + |
| 225 | +### Prefix Matching |
| 226 | + |
| 227 | +`ReadPlan` and `DeletePlan` support prefix matching: if the given slug uniquely prefixes an existing plan file, it matches. Ambiguous prefixes return an error listing the matching plans. |
| 228 | + |
| 229 | +## Media Download (`download.go`) |
| 230 | + |
| 231 | +Supports downloading voice messages and photos from Telegram to the local filesystem. |
| 232 | + |
| 233 | +### Media Directory |
| 234 | + |
| 235 | +Media files are saved to `~/.odek/media/` (created automatically on first download). |
| 236 | + |
| 237 | +### DownloadVoice |
| 238 | + |
| 239 | +- Gets file metadata via `GetFile` |
| 240 | +- Downloads raw bytes via `DownloadFile` |
| 241 | +- Saves as `voice_<truncated_fileID>.<ext>` (default extension: `.ogg`) |
| 242 | +- Truncates fileID to 16 chars for filenames |
| 243 | + |
| 244 | +### DownloadPhoto |
| 245 | + |
| 246 | +- Takes a slice of `PhotoSize` IDs (Telegram sends multiple sizes) |
| 247 | +- Uses the last (largest) photo size |
| 248 | +- Saves as `photo_<truncated_fileID>.<ext>` (default extension: `.jpg`) |
| 249 | +- Same fileID truncation as voice downloads |
| 250 | + |
| 251 | +## Types (`types.go`) |
| 252 | + |
| 253 | +The package defines Telegram API types used throughout: |
| 254 | + |
| 255 | +| Type | Purpose | |
| 256 | +|---|---| |
| 257 | +| `Bot` | API client struct | |
| 258 | +| `Message` | Incoming/outgoing message | |
| 259 | +| `Update` | Incoming update (message, callback query, etc.) | |
| 260 | +| `User` | Telegram user | |
| 261 | +| `File` | File metadata for download | |
| 262 | +| `PhotoSize` | Photo with dimensions | |
| 263 | +| `BotCommand` | Command descriptor for `SetMyCommands` | |
| 264 | +| `SendOpts` | Optional parameters for `SendMessage` | |
| 265 | +| `ReplyKeyboardMarkup` | Keyboard layout | |
| 266 | +| `InlineKeyboardMarkup` | Inline keyboard layout | |
| 267 | +| `InlineKeyboardButton` | Inline keyboard button | |
| 268 | +| `CallbackQuery` | Inline keyboard callback | |
| 269 | +| `HandlerConfig` | Message handler configuration | |
| 270 | +| `TelegramConfig` | Full bot configuration | |
| 271 | +| `PlanInfo` | Plan file summary | |
| 272 | +| `ChatSession` | Per-chat agent session | |
| 273 | +| `SessionManager` | Session lifecycle manager | |
| 274 | +| `Logger` | Logging interface | |
| 275 | +| `Poller` | Long-polling update fetcher | |
| 276 | + |
| 277 | +## Testing |
| 278 | + |
| 279 | +The Telegram package has **409 tests** with **86.9% coverage**. Tests use: |
| 280 | +- `httptest.NewServer` to mock Telegram API responses |
| 281 | +- HTTP handler functions for each API endpoint (getFile, sendMessage, sendDocument, etc.) |
| 282 | +- `t.TempDir()` + `t.Setenv("HOME", ...)` for filesystem isolation |
| 283 | +- Long fileID truncation tests for voice/photo downloads |
| 284 | +- Plan CRUD tests with prefix matching, ambiguous matches, and error paths |
| 285 | +- Session manager tests with TTL expiry and cache behavior |
| 286 | + |
| 287 | +```bash |
| 288 | +# Run all Telegram tests |
| 289 | +go test ./internal/telegram/ -v -count=1 |
| 290 | + |
| 291 | +# With coverage |
| 292 | +go test -cover ./internal/telegram/ |
| 293 | +``` |
0 commit comments