diff --git a/README.md b/README.md index c775fbe..d2cdb73 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ make down # Stop - Async IRC over plain TCP or TLS (SASL PLAIN auth, IRCv3 CAP negotiation) - Microsoft Teams support via outgoing webhooks (no SDK dependency) +- Telegram support via long-polling (no SDK dependency, SOCKS5 proxied) - Plugin system with `@command` and `@event` decorators - Hot-reload: load, unload, reload plugins at runtime - Admin permission system (hostmask patterns + IRCOP detection + AAD IDs) diff --git a/ROADMAP.md b/ROADMAP.md index 3d9286e..dac1f81 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ - [x] Plugin command unit tests (encode, hash, dns, cidr, defang) - [x] CI pipeline (Gitea Actions, Python 3.11-3.13, ruff + pytest) -## v2.1.0 -- Teams Integration +## v2.1.0 -- Teams + Telegram Integration - [x] Microsoft Teams adapter via outgoing webhooks (no SDK) - [x] `TeamsBot` class with same plugin API as IRC `Bot` @@ -139,6 +139,13 @@ - [x] IRC-only methods as no-ops (join, part, kick, mode, set_topic) - [x] Incoming webhook support for `send()` (proactive messages) - [x] Paste overflow via FlaskPaste (same as IRC) -- [ ] Adaptive Cards for richer formatting -- [ ] Graph API integration for DMs and richer channel access +- [x] Teams `send()` routed through SOCKS5 proxy (bug fix) +- [x] Telegram adapter via long-polling (`getUpdates`, no SDK) +- [x] `TelegramBot` class with same plugin API as IRC `Bot` +- [x] `TelegramMessage` dataclass duck-typed with IRC `Message` +- [x] All Telegram HTTP through SOCKS5 proxy +- [x] Message splitting at 4096-char limit +- [x] `@botusername` suffix stripping in groups +- [ ] Adaptive Cards for richer formatting (Teams) +- [ ] Graph API integration for DMs and richer channel access (Teams) - [ ] Teams event handlers (member join/leave) diff --git a/TASKS.md b/TASKS.md index aa8c43d..bc3fe3d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,6 +1,20 @@ # derp - Tasks -## Current Sprint -- v2.1.0 Teams Integration (2026-02-21) +## Current Sprint -- v2.1.0 Telegram Integration (2026-02-21) + +| Pri | Status | Task | +|-----|--------|------| +| P0 | [x] | Fix `src/derp/teams.py` -- route `send()` through SOCKS5 proxy | +| P0 | [x] | `src/derp/telegram.py` -- TelegramBot, TelegramMessage, long-polling | +| P0 | [x] | `src/derp/config.py` -- `[telegram]` defaults | +| P0 | [x] | `src/derp/cli.py` -- conditionally start TelegramBot | +| P0 | [x] | All Telegram HTTP through SOCKS5 proxy (`derp.http.urlopen`) | +| P0 | [x] | Permission tiers via user IDs (exact match) | +| P0 | [x] | @botusername suffix stripping, message splitting (4096 chars) | +| P1 | [x] | Tests: `test_telegram.py` (75 cases) | +| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, API.md, README.md, ROADMAP.md) | + +## Previous Sprint -- v2.1.0 Teams Integration (2026-02-21) | Pri | Status | Task | |-----|--------|------| diff --git a/TODO.md b/TODO.md index 499b74a..f8ac86c 100644 --- a/TODO.md +++ b/TODO.md @@ -88,10 +88,23 @@ is preserved in git history for reference. - [x] TeamsBot + TeamsMessage (duck-typed with IRC Message) - [x] HMAC-SHA256 webhook validation - [x] Permission tiers via AAD object IDs +- [x] Route `send()` through SOCKS5 proxy (bug fix) - [ ] Adaptive Cards for richer formatting - [ ] Graph API integration for DMs - [ ] Teams event handlers (member join/leave) +## Telegram + +- [x] Telegram adapter via long-polling (no SDK) +- [x] TelegramBot + TelegramMessage (duck-typed with IRC Message) +- [x] All HTTP through SOCKS5 proxy +- [x] Message splitting at 4096-char limit +- [x] @botusername suffix stripping in groups +- [x] Permission tiers via user IDs +- [ ] Inline keyboard support for interactive replies +- [ ] Markdown/HTML formatting mode toggle +- [ ] Webhook mode (for setWebhook instead of getUpdates) + ## Testing - [x] Plugin command unit tests (encode, hash, dns, cidr, defang) diff --git a/docs/API.md b/docs/API.md index 7a55eef..99fd567 100644 --- a/docs/API.md +++ b/docs/API.md @@ -288,6 +288,75 @@ Same stable attributes and methods as `Bot`: --- +## `derp.telegram` -- Telegram Adapter + +Alternative bot adapter for Telegram via long-polling (`getUpdates`). +All HTTP routed through SOCKS5 proxy. Exposes the same plugin API as +`derp.bot.Bot` so protocol-agnostic plugins work without modification. + +### `TelegramMessage` dataclass + +Duck-typed compatible with IRC `Message`: + +| Field | Type | Description | +|-------|------|-------------| +| `raw` | `dict` | Original Telegram Update | +| `nick` | `str \| None` | Sender first_name (or username fallback) | +| `prefix` | `str \| None` | Sender user_id as string (for ACL) | +| `text` | `str \| None` | Message body (stripped of @bot suffix) | +| `target` | `str \| None` | chat_id as string | +| `is_channel` | `bool` | `True` for groups, `False` for DMs | +| `command` | `str` | Always `"PRIVMSG"` (compat shim) | +| `params` | `list[str]` | `[target, text]` | +| `tags` | `dict[str, str]` | Empty dict (no IRCv3 tags) | + +### `TelegramBot` + +Same stable attributes and methods as `Bot`: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `name` | `str` | Always `"telegram"` | +| `config` | `dict` | Merged TOML configuration | +| `nick` | `str` | Bot display name (from `getMe`) | +| `prefix` | `str` | Command prefix (from `[telegram]` or `[bot]`) | +| `state` | `StateStore` | Persistent key-value storage | +| `registry` | `PluginRegistry` | Shared command and event registry | + +**Sending messages** -- same signatures, Telegram API transport: + +| Method | Behaviour | +|--------|-----------| +| `send(target, text)` | `sendMessage` API call (proxied, rate-limited) | +| `reply(msg, text)` | `send(msg.target, text)` | +| `long_reply(msg, lines, *, label="")` | Paste overflow, same logic as IRC | +| `action(target, text)` | Italic Markdown text via `sendMessage` | +| `shorten_url(url)` | Same FlaskPaste integration | + +**Message splitting**: messages > 4096 chars split at line boundaries. + +**IRC no-ops** (debug log, no error): + +`join`, `part`, `kick`, `mode`, `set_topic` + +**Plugin management** -- delegates to shared registry: + +`load_plugins`, `load_plugin`, `reload_plugin`, `unload_plugin` + +**Permission tiers** -- same model, exact user_id string matching: + +`_get_tier(msg)`, `_is_admin(msg)` + +### Helper Functions + +| Function | Signature | Description | +|----------|-----------|-------------| +| `_strip_bot_suffix` | `(text: str, bot_username: str) -> str` | Strip `@username` from command text | +| `_build_telegram_message` | `(update: dict, bot_username: str) -> TelegramMessage \| None` | Parse Telegram Update into message | +| `_split_message` | `(text: str, max_len: int = 4096) -> list[str]` | Split long text at line boundaries | + +--- + ## Handler Signatures All command and event handlers are async functions receiving `bot` and diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index f9f8d98..c0ef64e 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -504,6 +504,23 @@ Teams endpoint: `POST /api/messages`. HMAC-SHA256 auth via `Authorization: HMAC Replies returned as JSON in HTTP response. IRC-only commands (kick, ban, topic) are no-ops. ~90% of plugins work without modification. +## Telegram Integration + +```toml +# config/derp.toml +[telegram] +enabled = true +bot_token = "123456:ABC-DEF..." # from @BotFather +poll_timeout = 30 # long-poll seconds +admins = [123456789] # Telegram user IDs +operators = [] +trusted = [] +``` + +Long-polling via `getUpdates` -- no public endpoint needed. All HTTP +through SOCKS5 proxy. Strips `@botusername` suffix in groups. Messages +split at 4096 chars. IRC-only commands are no-ops. ~90% of plugins work. + ## Plugin Template ```python diff --git a/docs/USAGE.md b/docs/USAGE.md index ffba4a6..0cb215f 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1400,3 +1400,77 @@ The bot returns a JSON response: ``` Multiple reply lines are joined with `\n`. + +## Telegram Integration + +Connect derp to Telegram via long-polling (`getUpdates`). All outbound HTTP +is routed through the SOCKS5 proxy. No public endpoint required, no Telegram +SDK dependency. + +### How It Works + +The bot calls `getUpdates` in a loop with a long-poll timeout (default 30s). +When a message arrives with the configured prefix, it is dispatched through +the shared plugin registry. Replies are sent immediately via `sendMessage`. + +### Configuration + +```toml +[telegram] +enabled = true +bot_token = "123456:ABC-DEF..." # from @BotFather +poll_timeout = 30 # long-poll timeout in seconds +admins = [123456789] # Telegram user IDs (numeric) +operators = [] # Telegram user IDs +trusted = [] # Telegram user IDs +``` + +### Telegram Setup + +1. **Create a bot** via [@BotFather](https://t.me/BotFather): + - `/newbot` and follow the prompts + - Copy the bot token and set `bot_token` in config + +2. **Add the bot** to a group or send it a DM + +3. **Configure permissions** using Telegram user IDs. Use `!whoami` to + discover your numeric user ID. + +### Permission Tiers + +Same 4-tier model as IRC, but matches exact Telegram user IDs (numeric +strings) instead of fnmatch hostmask patterns: + +```toml +[telegram] +admins = [123456789] +operators = [987654321] +trusted = [111222333] +``` + +### Plugin Compatibility + +Same compatibility as Teams -- ~90% of plugins work without modification. + +| Feature | IRC | Telegram | +|---------|-----|----------| +| `bot.reply()` | Sends PRIVMSG | `sendMessage` API call | +| `bot.send()` | Sends PRIVMSG | `sendMessage` API call | +| `bot.action()` | CTCP ACTION | Italic Markdown text | +| `bot.long_reply()` | Paste overflow | Paste overflow (same logic) | +| `bot.state` | Per-server SQLite | Per-server SQLite | +| `bot.join/part/kick/mode` | IRC commands | No-op (logged at debug) | +| Event handlers (JOIN, etc.) | Fired on IRC events | Not triggered | +| Hostmask ACL | fnmatch patterns | Exact user IDs | +| Message limit | 512 bytes (IRC) | 4096 chars (Telegram) | + +### Group Commands + +In groups, Telegram appends `@botusername` to commands. The bot strips +this automatically: `!help@mybot` becomes `!help`. + +### Transport + +All HTTP traffic (API calls, long-polling) routes through the SOCKS5 +proxy at `127.0.0.1:1080` via `derp.http.urlopen`. No direct outbound +connections are made.