diff --git a/README.md b/README.md index a0f3ba6..c775fbe 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,10 @@ make down # Stop ## Features - Async IRC over plain TCP or TLS (SASL PLAIN auth, IRCv3 CAP negotiation) +- Microsoft Teams support via outgoing webhooks (no SDK dependency) - Plugin system with `@command` and `@event` decorators - Hot-reload: load, unload, reload plugins at runtime -- Admin permission system (hostmask patterns + IRCOP detection) +- Admin permission system (hostmask patterns + IRCOP detection + AAD IDs) - Command shorthand: `!h` resolves to `!help` (unambiguous prefix matching) - TOML configuration with sensible defaults - Rate limiting, CTCP responses, auto reconnect diff --git a/ROADMAP.md b/ROADMAP.md index 4a48ae4..3d9286e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,3 +128,17 @@ - [x] `cron` plugin (scheduled bot commands on a timer) - [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 + +- [x] Microsoft Teams adapter via outgoing webhooks (no SDK) +- [x] `TeamsBot` class with same plugin API as IRC `Bot` +- [x] `TeamsMessage` dataclass duck-typed with IRC `Message` +- [x] HMAC-SHA256 webhook signature validation +- [x] Permission tiers via AAD object IDs +- [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 +- [ ] Teams event handlers (member join/leave) diff --git a/TASKS.md b/TASKS.md index 0927979..aa8c43d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,6 +1,19 @@ # derp - Tasks -## Current Sprint -- v2.0.0 Stable API (2026-02-21) +## Current Sprint -- v2.1.0 Teams Integration (2026-02-21) + +| Pri | Status | Task | +|-----|--------|------| +| P0 | [x] | `src/derp/teams.py` -- TeamsBot, TeamsMessage, HTTP handler | +| P0 | [x] | `src/derp/config.py` -- `[teams]` defaults | +| P0 | [x] | `src/derp/cli.py` -- conditionally start TeamsBot alongside IRC bots | +| P0 | [x] | HMAC-SHA256 signature validation (base64 key, `Authorization: HMAC` header) | +| P0 | [x] | Permission tiers via AAD object IDs (exact match, not fnmatch) | +| P0 | [x] | IRC no-ops: join, part, kick, mode, set_topic (debug log) | +| P1 | [x] | Tests: `test_teams.py` (74 cases, 1302 total) | +| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, API.md, README.md, ROADMAP.md) | + +## Previous Sprint -- v2.0.0 Stable API (2026-02-21) | Pri | Status | Task | |-----|--------|------| diff --git a/TODO.md b/TODO.md index e416509..499b74a 100644 --- a/TODO.md +++ b/TODO.md @@ -82,6 +82,16 @@ is preserved in git history for reference. - [x] `shorten` -- manual URL shortening - [x] `cron` -- scheduled bot commands on a timer +## Teams + +- [x] Microsoft Teams adapter via outgoing webhooks +- [x] TeamsBot + TeamsMessage (duck-typed with IRC Message) +- [x] HMAC-SHA256 webhook validation +- [x] Permission tiers via AAD object IDs +- [ ] Adaptive Cards for richer formatting +- [ ] Graph API integration for DMs +- [ ] Teams event handlers (member join/leave) + ## Testing - [x] Plugin command unit tests (encode, hash, dns, cidr, defang) diff --git a/docs/API.md b/docs/API.md index cc2b802..7a55eef 100644 --- a/docs/API.md +++ b/docs/API.md @@ -228,6 +228,66 @@ Wire-format encode/decode for raw DNS queries and responses. --- +## `derp.teams` -- Teams Adapter + +Alternative bot adapter for Microsoft Teams via outgoing/incoming webhooks. +Exposes the same plugin API as `derp.bot.Bot` so protocol-agnostic plugins +work without modification. + +### `TeamsMessage` dataclass + +Duck-typed compatible with IRC `Message`: + +| Field | Type | Description | +|-------|------|-------------| +| `raw` | `dict` | Original Activity JSON | +| `nick` | `str \| None` | Sender display name | +| `prefix` | `str \| None` | Sender AAD object ID (for ACL) | +| `text` | `str \| None` | Message body (stripped of @mention) | +| `target` | `str \| None` | Conversation/channel ID | +| `is_channel` | `bool` | Always `True` (outgoing webhooks) | +| `command` | `str` | Always `"PRIVMSG"` (compat shim) | +| `params` | `list[str]` | `[target, text]` | +| `tags` | `dict[str, str]` | Empty dict (no IRCv3 tags) | +| `_replies` | `list[str]` | Reply buffer (unstable) | + +### `TeamsBot` + +Same stable attributes and methods as `Bot`: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `name` | `str` | Always `"teams"` | +| `config` | `dict` | Merged TOML configuration | +| `nick` | `str` | Bot display name (`teams.bot_name`) | +| `prefix` | `str` | Command prefix (from `[bot]`) | +| `state` | `StateStore` | Persistent key-value storage | +| `registry` | `PluginRegistry` | Shared command and event registry | + +**Sending messages** -- same signatures, different transport: + +| Method | Behaviour | +|--------|-----------| +| `send(target, text)` | POST to incoming webhook URL | +| `reply(msg, text)` | Append to `msg._replies` (HTTP response) | +| `long_reply(msg, lines, *, label="")` | Paste overflow, appends to replies | +| `action(target, text)` | Italic text via incoming webhook | +| `shorten_url(url)` | Same FlaskPaste integration | + +**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 AAD object ID matching: + +`_get_tier(msg)`, `_is_admin(msg)` + +--- + ## Handler Signatures All command and event handlers are async functions receiving `bot` and diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 3470c07..f9f8d98 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -482,6 +482,28 @@ curl -X POST http://127.0.0.1:8080/ \ POST JSON: `{"channel":"#chan","text":"msg"}`. Optional `"action":true`. Auth: HMAC-SHA256 via `X-Signature` header. Starts on IRC connect. +## Teams Integration + +```toml +# config/derp.toml +[teams] +enabled = true +bot_name = "derp" +bind = "127.0.0.1" +port = 8081 +webhook_secret = "base64-secret-from-teams" +incoming_webhook_url = "" # optional, for proactive msgs +admins = ["aad-object-id-uuid"] # AAD object IDs +operators = [] +trusted = [] +``` + +Expose via Cloudflare Tunnel: `cloudflared tunnel --url http://127.0.0.1:8081` + +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. + ## Plugin Template ```python diff --git a/docs/USAGE.md b/docs/USAGE.md index 897e265..ffba4a6 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1301,3 +1301,102 @@ timeout = 10 # HTTP fetch timeout max_urls = 3 # max URLs to preview per message ignore_hosts = [] # additional hostnames to skip ``` + +## Teams Integration + +Connect derp to Microsoft Teams via outgoing webhooks. The bot runs an HTTP +server that receives messages from Teams and replies inline. No Microsoft SDK +required -- raw asyncio HTTP, same pattern as the webhook plugin. + +### How It Works + +1. **Outgoing webhook** (Teams -> bot): Teams POSTs an Activity JSON to the + bot's HTTP endpoint when a user @mentions the bot. The bot dispatches the + command through the shared plugin registry and returns the reply as the + HTTP response body. + +2. **Incoming webhook** (bot -> Teams, optional): For proactive messages + (alerts, subscriptions), the bot POSTs to a Teams incoming webhook URL. + +### Configuration + +```toml +[teams] +enabled = true +bot_name = "derp" # outgoing webhook display name +bind = "127.0.0.1" # HTTP listen address +port = 8081 # HTTP listen port +webhook_secret = "" # HMAC-SHA256 secret from Teams +incoming_webhook_url = "" # for proactive messages (optional) +admins = [] # AAD object IDs (UUID format) +operators = [] # AAD object IDs +trusted = [] # AAD object IDs +``` + +### Teams Setup + +1. **Create an outgoing webhook** in a Teams channel: + - Channel settings -> Connectors -> Outgoing Webhook + - Set the callback URL to your bot's endpoint (e.g. + `https://derp.example.com/api/messages`) + - Copy the HMAC secret and set `webhook_secret` in config + +2. **Expose the bot** via Cloudflare Tunnel or reverse proxy: + ```bash + cloudflared tunnel --url http://127.0.0.1:8081 + ``` + +3. **Configure permissions** using AAD object IDs from the Activity JSON. + The AAD object ID is sent in `from.aadObjectId` on every message. Use + `!whoami` to discover your ID. + +### Permission Tiers + +Same 4-tier model as IRC, but matches exact AAD object IDs instead of +fnmatch hostmask patterns: + +```toml +[teams] +admins = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"] +operators = ["yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"] +trusted = ["zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz"] +``` + +### Plugin Compatibility + +~90% of plugins work on Teams without modification -- any plugin that uses +only `bot.send()`, `bot.reply()`, `bot.state`, `message.text`, `.nick`, +and `.target`. + +| Feature | IRC | Teams | +|---------|-----|-------| +| `bot.reply()` | Sends PRIVMSG | Appends to HTTP response | +| `bot.send()` | Sends PRIVMSG | POSTs to incoming webhook | +| `bot.action()` | CTCP ACTION | Italic text via incoming webhook | +| `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 AAD object IDs | +| Passive monitoring | All channel messages | @mention only | + +### HMAC Verification + +Teams outgoing webhooks sign requests with HMAC-SHA256. The secret is +base64-encoded when you create the webhook. The `Authorization` header +format is `HMAC `. + +If `webhook_secret` is empty, no authentication is performed (useful for +development but not recommended for production). + +### Endpoint + +Single endpoint: `POST /api/messages` + +The bot returns a JSON response: + +```json +{"type": "message", "text": "reply text here"} +``` + +Multiple reply lines are joined with `\n`.