From 9cdd0c28be6d522e53d176276a784ff4392cce48 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 00:17:58 +0000 Subject: [PATCH] feat: add tlon channel plugin --- CHANGELOG.md | 3 +- docs/channels/index.md | 1 + docs/channels/tlon.md | 133 ++ docs/plugin.md | 10 + extensions/tlon/README.md | 829 +-------- extensions/tlon/index.ts | 2 + extensions/tlon/node_modules/@urbit/aura | 1 + extensions/tlon/node_modules/@urbit/http-api | 1 + extensions/tlon/package.json | 20 +- extensions/tlon/src/channel.js | 360 ---- extensions/tlon/src/channel.ts | 379 ++++ extensions/tlon/src/config-schema.ts | 43 + extensions/tlon/src/core-bridge.js | 100 -- extensions/tlon/src/monitor.js | 1572 ----------------- extensions/tlon/src/monitor/discovery.ts | 71 + extensions/tlon/src/monitor/history.ts | 87 + extensions/tlon/src/monitor/index.ts | 501 ++++++ .../src/monitor/processed-messages.test.ts | 24 + .../tlon/src/monitor/processed-messages.ts | 38 + extensions/tlon/src/monitor/utils.ts | 83 + extensions/tlon/src/onboarding.ts | 213 +++ extensions/tlon/src/runtime.ts | 14 + extensions/tlon/src/targets.ts | 79 + extensions/tlon/src/types.ts | 85 + extensions/tlon/src/urbit/auth.ts | 18 + extensions/tlon/src/urbit/http-api.ts | 36 + extensions/tlon/src/urbit/send.ts | 114 ++ extensions/tlon/src/urbit/sse-client.test.ts | 41 + .../sse-client.ts} | 292 ++- pnpm-lock.yaml | 36 + src/channels/plugins/catalog.test.ts | 36 + src/channels/plugins/catalog.ts | 87 + src/channels/plugins/types.core.ts | 6 + src/cli/channel-options.ts | 19 +- src/cli/channels-cli.ts | 23 +- src/commands/channels/add-mutators.ts | 12 + src/commands/channels/add.ts | 83 +- src/commands/onboard-channels.ts | 6 +- 38 files changed, 2431 insertions(+), 3027 deletions(-) create mode 100644 docs/channels/tlon.md create mode 120000 extensions/tlon/node_modules/@urbit/aura create mode 120000 extensions/tlon/node_modules/@urbit/http-api delete mode 100644 extensions/tlon/src/channel.js create mode 100644 extensions/tlon/src/channel.ts create mode 100644 extensions/tlon/src/config-schema.ts delete mode 100644 extensions/tlon/src/core-bridge.js delete mode 100644 extensions/tlon/src/monitor.js create mode 100644 extensions/tlon/src/monitor/discovery.ts create mode 100644 extensions/tlon/src/monitor/history.ts create mode 100644 extensions/tlon/src/monitor/index.ts create mode 100644 extensions/tlon/src/monitor/processed-messages.test.ts create mode 100644 extensions/tlon/src/monitor/processed-messages.ts create mode 100644 extensions/tlon/src/monitor/utils.ts create mode 100644 extensions/tlon/src/onboarding.ts create mode 100644 extensions/tlon/src/runtime.ts create mode 100644 extensions/tlon/src/targets.ts create mode 100644 extensions/tlon/src/types.ts create mode 100644 extensions/tlon/src/urbit/auth.ts create mode 100644 extensions/tlon/src/urbit/http-api.ts create mode 100644 extensions/tlon/src/urbit/send.ts create mode 100644 extensions/tlon/src/urbit/sse-client.test.ts rename extensions/tlon/src/{urbit-sse-client.js => urbit/sse-client.ts} (51%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4106f7827..82fe6e5a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,12 @@ Docs: https://docs.clawd.bot -## 2026.1.23 +## 2026.1.23 (Unreleased) ### Changes - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. +- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. ### Fixes - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. diff --git a/docs/channels/index.md b/docs/channels/index.md index 00b33ac07..f8fd860c3 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -23,6 +23,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately). +- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). - [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. diff --git a/docs/channels/tlon.md b/docs/channels/tlon.md new file mode 100644 index 000000000..a2436d5e7 --- /dev/null +++ b/docs/channels/tlon.md @@ -0,0 +1,133 @@ +--- +summary: "Tlon/Urbit support status, capabilities, and configuration" +read_when: + - Working on Tlon/Urbit channel features +--- +# Tlon (plugin) + +Tlon is a decentralized messenger built on Urbit. Clawdbot connects to your Urbit ship and can +respond to DMs and group chat messages. Group replies require an @ mention by default and can +be further restricted via allowlists. + +Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback +(URL appended to caption). Reactions, polls, and native media uploads are not supported. + +## Plugin required + +Tlon ships as a plugin and is not bundled with the core install. + +Install via CLI (npm registry): + +```bash +clawdbot plugins install @clawdbot/tlon +``` + +Local checkout (when running from a git repo): + +```bash +clawdbot plugins install ./extensions/tlon +``` + +Details: [Plugins](/plugin) + +## Setup + +1) Install the Tlon plugin. +2) Gather your ship URL and login code. +3) Configure `channels.tlon`. +4) Restart the gateway. +5) DM the bot or mention it in a group channel. + +Minimal config (single account): + +```json5 +{ + channels: { + tlon: { + enabled: true, + ship: "~sampel-palnet", + url: "https://your-ship-host", + code: "lidlut-tabwed-pillex-ridrup" + } + } +} +``` + +## Group channels + +Auto-discovery is enabled by default. You can also pin channels manually: + +```json5 +{ + channels: { + tlon: { + groupChannels: [ + "chat/~host-ship/general", + "chat/~host-ship/support" + ] + } + } +} +``` + +Disable auto-discovery: + +```json5 +{ + channels: { + tlon: { + autoDiscoverChannels: false + } + } +} +``` + +## Access control + +DM allowlist (empty = allow all): + +```json5 +{ + channels: { + tlon: { + dmAllowlist: ["~zod", "~nec"] + } + } +} +``` + +Group authorization (restricted by default): + +```json5 +{ + channels: { + tlon: { + defaultAuthorizedShips: ["~zod"], + authorization: { + channelRules: { + "chat/~host-ship/general": { + mode: "restricted", + allowedShips: ["~zod", "~nec"] + }, + "chat/~host-ship/announcements": { + mode: "open" + } + } + } + } + } +} +``` + +## Delivery targets (CLI/cron) + +Use these with `clawdbot message send` or cron delivery: + +- DM: `~sampel-palnet` or `dm/~sampel-palnet` +- Group: `chat/~host-ship/channel` or `group:~host-ship/channel` + +## Notes + +- Group replies require a mention (e.g. `~your-bot-ship`) to respond. +- Thread replies: if the inbound message is in a thread, Clawdbot replies in-thread. +- Media: `sendMedia` falls back to text + URL (no native upload). diff --git a/docs/plugin.md b/docs/plugin.md index e740591b0..e954b8418 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -145,6 +145,16 @@ Example: } ``` +Clawdbot can also merge **external channel catalogs** (for example, an MPM +registry export). Drop a JSON file at one of: +- `~/.clawdbot/mpm/plugins.json` +- `~/.clawdbot/mpm/catalog.json` +- `~/.clawdbot/plugins/catalog.json` + +Or point `CLAWDBOT_PLUGIN_CATALOG_PATHS` (or `CLAWDBOT_MPM_CATALOG_PATHS`) at +one or more JSON files (comma/semicolon/`PATH`-delimited). Each file should +contain `{ "entries": [ { "name": "@scope/pkg", "clawdbot": { "channel": {...}, "install": {...} } } ] }`. + ## Plugin IDs Default plugin ids: diff --git a/extensions/tlon/README.md b/extensions/tlon/README.md index 0fd7fd8da..aa02cab93 100644 --- a/extensions/tlon/README.md +++ b/extensions/tlon/README.md @@ -1,828 +1,5 @@ -# Clawdbot Tlon/Urbit Integration +# Tlon (Clawdbot plugin) -Complete documentation for integrating Clawdbot with Tlon Messenger (built on Urbit). +Tlon/Urbit channel plugin for Clawdbot. Supports DMs, group mentions, and thread replies. -## Overview - -This extension enables Clawdbot to: -- Monitor and respond to direct messages on Tlon Messenger -- Monitor and respond to group channel messages when mentioned -- Auto-discover available group channels -- Use per-conversation subscriptions for reliable message delivery -- **Automatic AI model fallback** - Seamlessly switches from Anthropic to OpenAI when rate limited (see [FALLBACK.md](./FALLBACK.md)) - -**Ship:** ~sitrul-nacwyl -**Test User:** ~malmur-halmex - -## Architecture - -### Files - -- **`index.js`** - Plugin entry point, registers the Tlon channel adapter -- **`monitor.js`** - Core monitoring logic, handles incoming messages and AI dispatch -- **`urbit-sse-client.js`** - Custom SSE client for Urbit HTTP API -- **`core-bridge.js`** - Dynamic loader for clawdbot core modules -- **`package.json`** - Plugin package definition -- **`FALLBACK.md`** - AI model fallback system documentation - -### How It Works - -1. **Authentication**: Uses ship name + code to authenticate via `/~/login` endpoint -2. **Channel Creation**: Creates Tlon Messenger channel via PUT to `/~/channel/{uid}` -3. **Activation**: Sends "helm-hi" poke to activate channel (required!) -4. **Subscriptions**: - - **DMs**: Individual subscriptions to `/dm/{ship}` for each conversation - - **Groups**: Individual subscriptions to `/{channelNest}` for each channel -5. **SSE Stream**: Opens server-sent events stream for real-time updates -6. **Auto-Reconnection**: Automatically reconnects if SSE stream dies - - Exponential backoff (1s to 30s delays) - - Up to 10 reconnection attempts - - Generates new channel ID on each attempt -7. **Auto-Discovery**: Queries `/groups-ui/v6/init.json` to find all available channels -8. **Dynamic Refresh**: Polls every 2 minutes for new conversations/channels -9. **Message Processing**: When bot is mentioned, routes to AI via clawdbot core -10. **AI Fallback**: Automatically switches providers when rate limited - - Primary: Anthropic Claude Sonnet 4.5 - - Fallbacks: OpenAI GPT-4o, GPT-4 Turbo - - Automatic cooldown management - - See [FALLBACK.md](./FALLBACK.md) for details - -## Configuration - -### 1. Install Dependencies - -```bash -cd ~/.clawdbot/extensions/tlon -npm install -``` - -### 2. Configure Credentials - -Edit `~/.clawdbot/clawdbot.json`: - -```json -{ - "channels": { - "tlon": { - "enabled": true, - "ship": "your-ship-name", - "code": "your-ship-code", - "url": "https://your-ship-name.tlon.network", - "showModelSignature": false, - "dmAllowlist": ["~friend-ship-1", "~friend-ship-2"], - "defaultAuthorizedShips": ["~malmur-halmex"], - "authorization": { - "channelRules": { - "chat/~host-ship/channel-name": { - "mode": "open", - "allowedShips": [] - }, - "chat/~another-host/private-channel": { - "mode": "restricted", - "allowedShips": ["~malmur-halmex", "~sitrul-nacwyl"] - } - } - } - } - } -} -``` - -**Configuration Options:** -- `enabled` - Enable/disable the Tlon channel (default: `false`) -- `ship` - Your Urbit ship name (required) -- `code` - Your ship's login code (required) -- `url` - Your ship's URL (required) -- `showModelSignature` - Append model name to responses (default: `false`) - - When enabled, adds `[Generated by Claude Sonnet 4.5]` to the end of each response - - Useful for transparency about which AI model generated the response -- `dmAllowlist` - Ships allowed to send DMs (optional) - - If omitted or empty, all DMs are accepted (default behavior) - - Ship names can include or omit the `~` prefix - - Example: `["~trusted-friend", "~another-ship"]` - - Blocked DMs are logged for visibility -- `defaultAuthorizedShips` - Ships authorized in new/unconfigured channels (default: `["~malmur-halmex"]`) - - New channels default to `restricted` mode using these ships -- `authorization` - Per-channel access control (optional) - - `channelRules` - Map of channel nest to authorization rules - - `mode`: `"open"` (all ships) or `"restricted"` (allowedShips only) - - `allowedShips`: Array of authorized ships (only for `restricted` mode) - -**For localhost development:** -```json -"url": "http://localhost:8080" -``` - -**For Tlon-hosted ships:** -```json -"url": "https://{ship-name}.tlon.network" -``` - -### 3. Set Environment Variable - -The monitor needs to find clawdbot's core modules. Set the environment variable: - -```bash -export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot -``` - -Or if clawdbot is installed elsewhere: -```bash -export CLAWDBOT_ROOT=$(dirname $(dirname $(readlink -f $(which clawdbot)))) -``` - -**Make it permanent** (add to `~/.zshrc` or `~/.bashrc`): -```bash -echo 'export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot' >> ~/.zshrc -``` - -### 4. Configure AI Authentication - -The bot needs API credentials to generate responses. - -**Option A: Use Claude Code CLI credentials** -```bash -clawdbot agents add main -# Select "Use Claude Code CLI credentials" -``` - -**Option B: Use Anthropic API key** -```bash -clawdbot agents add main -# Enter your API key from console.anthropic.com -``` - -### 5. Start the Gateway - -```bash -CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot clawdbot gateway -``` - -Or create a launch script: - -```bash -cat > ~/start-clawdbot.sh << 'EOF' -#!/bin/bash -export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot -clawdbot gateway -EOF -chmod +x ~/start-clawdbot.sh -``` - -## Usage - -### Testing - -1. Send a DM from another ship to ~sitrul-nacwyl -2. Mention the bot: `~sitrul-nacwyl hello there!` -3. Bot should respond with AI-generated reply - -### Monitoring Logs - -Check gateway logs: -```bash -tail -f /tmp/clawdbot/clawdbot-$(date +%Y-%m-%d).log -``` - -Look for these indicators: -- `[tlon] Successfully authenticated to https://...` -- `[tlon] Auto-discovered N chat channel(s)` -- `[tlon] Connected! All subscriptions active` -- `[tlon] Received DM from ~ship: "..." (mentioned: true)` -- `[tlon] Dispatching to AI for ~ship (DM)` -- `[tlon] Delivered AI reply to ~ship` - -### Group Channels - -The bot automatically discovers and subscribes to all group channels using **delta-based discovery** for efficiency. - -**How Auto-Discovery Works:** -1. **On startup:** Fetches changes from the last 5 days via `/groups-ui/v5/changes/~YYYY.M.D..20.19.51..9b9d.json` -2. **Periodic refresh:** Checks for new channels every 2 minutes -3. **Smart caching:** Only fetches deltas, not full state each time - -**Benefits:** -- Reduced bandwidth usage -- Faster startup (especially for ships with many groups) -- Automatically picks up new channels you join -- Context of recent group activity - -**Manual Configuration:** - -To disable auto-discovery and use specific channels: - -```json -{ - "channels": { - "tlon": { - "enabled": true, - "ship": "your-ship-name", - "code": "your-ship-code", - "url": "https://your-ship-name.tlon.network", - "autoDiscoverChannels": false, - "groupChannels": [ - "chat/~host-ship/channel-name", - "chat/~another-host/another-channel" - ] - } - } -} -``` - -### Model Signatures - -The bot can append the AI model name to each response for transparency. Enable this feature in your config: - -```json -{ - "channels": { - "tlon": { - "enabled": true, - "ship": "your-ship-name", - "code": "your-ship-code", - "url": "https://your-ship-name.tlon.network", - "showModelSignature": true - } - } -} -``` - -**Example output with signature enabled:** -``` -User: ~sitrul-nacwyl explain quantum computing -Bot: Quantum computing uses quantum mechanics principles like superposition - and entanglement to perform calculations... - - [Generated by Claude Sonnet 4.5] -``` - -**Supported model formats:** -- `Claude Opus 4.5` -- `Claude Sonnet 4.5` -- `GPT-4o` -- `GPT-4 Turbo` -- `Gemini 2.0 Flash` - -When using the [AI fallback system](./FALLBACK.md), signatures automatically reflect which model generated the response (e.g., if Anthropic is rate limited and OpenAI is used, the signature will show `GPT-4o`). - -### Channel History Summarization - -The bot can summarize recent channel activity when asked. This is useful for catching up on conversations you missed. - -**Trigger phrases:** -- `~bot-ship summarize this channel` -- `~bot-ship what did I miss?` -- `~bot-ship catch me up` -- `~bot-ship tldr` -- `~bot-ship channel summary` - -**Example:** -``` -User: ~sitrul-nacwyl what did I miss? -Bot: Here's a summary of the last 50 messages: - -Main topics discussed: -1. Discussion about Urbit networking (Ames protocol) -2. Planning for next week's developer meetup -3. Bug reports for the new UI update - -Key decisions: -- Meetup scheduled for Thursday at 3pm EST -- Priority on fixing the scrolling issue - -Notable participants: ~malmur-halmex, ~bolbex-fogdys -``` - -**How it works:** -- Fetches the last 50 messages from the channel -- Sends them to the AI for summarization -- Returns a concise summary with main topics, decisions, and action items - -### Thread Support - -The bot automatically maintains context in threaded conversations. When you mention the bot in a reply thread, it will respond within that thread instead of posting to the main channel. - -**Example:** -``` -Main channel post: - User A: ~sitrul-nacwyl what's the capital of France? - Bot: Paris is the capital of France. - └─ User B (in thread): ~sitrul-nacwyl and what's its population? - └─ Bot (in thread): Paris has a population of approximately 2.2 million... -``` - -**Benefits:** -- Keeps conversations organized -- Reduces noise in main channel -- Maintains conversation context within threads - -**Technical Details:** -The bot handles both top-level posts and thread replies with different data structures: -- Top-level posts: `response.post.r-post.set.essay` -- Thread replies: `response.post.r-post.reply.r-reply.set.memo` - -When replying in a thread, the bot uses the `parent-id` from the incoming message to ensure the reply stays within the same thread. - -**Note:** Thread support is automatic - no configuration needed. - -### Link Summarization - -The bot can fetch and summarize web content when you share links. - -**Example:** -``` -User: ~sitrul-nacwyl can you summarize this https://example.com/article -Bot: This article discusses... [summary of the content] -``` - -**How it works:** -- Bot extracts URLs from rich text messages (including inline links) -- Fetches the web page content -- Summarizes using the WebFetch tool - -### Channel Authorization - -Control which ships can invoke the bot in specific group channels. **New channels default to `restricted` mode** for security. - -#### Default Behavior - -**DMs:** Always open (no restrictions) -**Group Channels:** Restricted by default, only ships in `defaultAuthorizedShips` can invoke the bot - -#### Configuration - -```json -{ - "channels": { - "tlon": { - "enabled": true, - "ship": "sitrul-nacwyl", - "code": "your-code", - "url": "https://sitrul-nacwyl.tlon.network", - "defaultAuthorizedShips": ["~malmur-halmex"], - "authorization": { - "channelRules": { - "chat/~bitpyx-dildus/core": { - "mode": "open" - }, - "chat/~nocsyx-lassul/bongtable": { - "mode": "restricted", - "allowedShips": ["~malmur-halmex", "~sitrul-nacwyl"] - } - } - } - } - } -} -``` - -#### Authorization Modes - -**`open`** - Any ship can invoke the bot when mentioned -- Good for public channels -- No `allowedShips` needed - -**`restricted`** (default) - Only specific ships can invoke the bot -- Good for private/work channels -- Requires `allowedShips` list -- New channels use `defaultAuthorizedShips` if no rule exists - -#### Examples - -**Make a channel public:** -```json -"chat/~bitpyx-dildus/core": { - "mode": "open" -} -``` - -**Restrict to specific users:** -```json -"chat/~nocsyx-lassul/bongtable": { - "mode": "restricted", - "allowedShips": ["~malmur-halmex"] -} -``` - -**New channel (no config):** -- Mode: `restricted` (safe default) -- Allowed ships: `defaultAuthorizedShips` (e.g., `["~malmur-halmex"]`) - -#### Behavior - -**Authorized mention:** -``` -~malmur-halmex: ~sitrul-nacwyl tell me about quantum computing -Bot: [Responds with answer] -``` - -**Unauthorized mention (silently ignored):** -``` -~other-ship: ~sitrul-nacwyl tell me about quantum computing -Bot: [No response, logs show access denied] -``` - -**Check logs:** -```bash -tail -f /tmp/tlon-fallback.log | grep "Access" -``` - -You'll see: -``` -[tlon] ✅ Access granted: ~malmur-halmex in chat/~host/channel (authorized user) -[tlon] ⛔ Access denied: ~other-ship in chat/~host/channel (restricted, allowed: ~malmur-halmex) -``` - -## Technical Deep Dive - -### Urbit HTTP API Flow - -1. **Login** (POST `/~/login`) - - Sends `password={code}` - - Returns authentication cookie in `set-cookie` header - -2. **Channel Creation** (PUT `/~/channel/{channelId}`) - - Channel ID format: `{timestamp}-{random}` - - Body: array of subscription objects - - Response: 204 No Content - -3. **Channel Activation** (PUT `/~/channel/{channelId}`) - - **Critical:** Must send helm-hi poke BEFORE opening SSE stream - - Poke structure: - ```json - { - "id": timestamp, - "action": "poke", - "ship": "sitrul-nacwyl", - "app": "hood", - "mark": "helm-hi", - "json": "Opening API channel" - } - ``` - -4. **SSE Stream** (GET `/~/channel/{channelId}`) - - Headers: `Accept: text/event-stream` - - Returns Server-Sent Events - - Format: - ``` - id: {event-id} - data: {json-payload} - - ``` - -### Subscription Paths - -#### DMs (Chat App) -- **Path:** `/dm/{ship}` -- **App:** `chat` -- **Event Format:** - ```json - { - "id": "~ship/timestamp", - "whom": "~other-ship", - "response": { - "add": { - "memo": { - "author": "~sender-ship", - "sent": 1768742460781, - "content": [ - { - "inline": [ - "text", - {"ship": "~mentioned-ship"}, - "more text", - {"break": null} - ] - } - ] - } - } - } - } - ``` - -#### Group Channels (Channels App) -- **Path:** `/{channelNest}` -- **Channel Nest Format:** `chat/~host-ship/channel-name` -- **App:** `channels` -- **Event Format:** - ```json - { - "response": { - "post": { - "id": "message-id", - "r-post": { - "set": { - "essay": { - "author": "~sender-ship", - "sent": 1768742460781, - "kind": "/chat", - "content": [...] - } - } - } - } - } - } - ``` - -### Text Extraction - -Message content uses inline format with mixed types: -- Strings: plain text -- Objects with `ship`: mentions (e.g., `{"ship": "~sitrul-nacwyl"}`) -- Objects with `break`: line breaks (e.g., `{"break": null}`) - -Example: -```json -{ - "inline": [ - "Hey ", - {"ship": "~sitrul-nacwyl"}, - " how are you?", - {"break": null}, - "This is a new line" - ] -} -``` - -Extracts to: `"Hey ~sitrul-nacwyl how are you?\nThis is a new line"` - -### Mention Detection - -Simple includes check (case-insensitive): -```javascript -const normalizedBotShip = botShipName.startsWith("~") - ? botShipName - : `~${botShipName}`; -return messageText.toLowerCase().includes(normalizedBotShip.toLowerCase()); -``` - -Note: Word boundaries (`\b`) don't work with `~` character. - -## Troubleshooting - -### Issue: "Cannot read properties of undefined (reading 'href')" - -**Cause:** Some clawdbot dependencies (axios, Slack SDK) expect browser globals - -**Fix:** Window.location polyfill is already added to monitor.js (lines 1-18) - -### Issue: "Unable to resolve Clawdbot root" - -**Cause:** core-bridge.js can't find clawdbot installation - -**Fix:** Set `CLAWDBOT_ROOT` environment variable: -```bash -export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot -``` - -### Issue: SSE Stream Returns 403 Forbidden - -**Cause:** Trying to open SSE stream without activating channel first - -**Fix:** Send helm-hi poke before opening stream (urbit-sse-client.js handles this) - -### Issue: No Events Received After Subscribing - -**Cause:** Wrong subscription path or app name - -**Fix:** -- DMs: Use `/dm/{ship}` with `app: "chat"` -- Groups: Use `/{channelNest}` with `app: "channels"` - -### Issue: Messages Show "[object Object]" - -**Cause:** Not handling inline content objects properly - -**Fix:** Text extraction handles mentions and breaks (monitor.js `extractMessageText()`) - -### Issue: Bot Not Detecting Mentions - -**Cause:** Message doesn't contain bot's ship name - -**Debug:** -```bash -tail -f /tmp/clawdbot/clawdbot-*.log | grep "mentioned:" -``` - -Should show: -``` -[tlon] Received DM from ~malmur-halmex: "~sitrul-nacwyl hello..." (mentioned: true) -``` - -### Issue: "No API key found for provider 'anthropic'" - -**Cause:** AI authentication not configured - -**Fix:** Run `clawdbot agents add main` and configure credentials - -### Issue: Gateway Port Already in Use - -**Fix:** -```bash -# Stop existing instance -clawdbot daemon stop - -# Or force kill -lsof -ti:18789 | xargs kill -9 -``` - -### Issue: Bot Stops Responding (SSE Disconnection) - -**Cause:** Urbit SSE stream disconnected (sent "quit" event or stream ended) - -**Symptoms:** -- Logs show: `[SSE] Received event: {"id":X,"response":"quit"}` -- No more incoming SSE events -- Bot appears online but doesn't respond to mentions - -**Fix:** The bot now **automatically reconnects**! Look for these log messages: -``` -[SSE] Stream ended, attempting reconnection... -[SSE] Reconnection attempt 1/10 in 1000ms... -[SSE] Reconnecting with new channel ID: xxx-yyy -[SSE] Reconnection successful! -``` - -**Manual restart if needed:** -```bash -kill $(pgrep -f "clawdbot gateway") -CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot clawdbot gateway -``` - -**Configuration options** (in urbit-sse-client.js constructor): -```javascript -new UrbitSSEClient(url, cookie, { - autoReconnect: true, // Default: true - maxReconnectAttempts: 10, // Default: 10 - reconnectDelay: 1000, // Initial delay: 1s - maxReconnectDelay: 30000, // Max delay: 30s - onReconnect: async (client) => { - // Optional callback for resubscription logic - } -}) -``` - -## Development Notes - -### Testing Without Clawdbot - -You can test the Urbit API directly: - -```javascript -import { UrbitSSEClient } from "./urbit-sse-client.js"; - -const api = new UrbitSSEClient( - "https://sitrul-nacwyl.tlon.network", - "your-cookie-here" -); - -// Subscribe to DMs -await api.subscribe({ - app: "chat", - path: "/dm/malmur-halmex", - event: (data) => console.log("DM:", data), - err: (e) => console.error("Error:", e), - quit: () => console.log("Quit") -}); - -// Connect -await api.connect(); - -// Send a DM -await api.poke({ - app: "chat", - mark: "chat-dm-action", - json: { - ship: "~malmur-halmex", - diff: { - id: `~sitrul-nacwyl/${Date.now()}`, - delta: { - add: { - memo: { - content: [{ inline: ["Hello!"] }], - author: "~sitrul-nacwyl", - sent: Date.now() - }, - kind: null, - time: null - } - } - } - } -}); -``` - -### Debugging SSE Events - -Enable verbose logging in urbit-sse-client.js: - -```javascript -// Line 169-171 -if (parsed.response !== "subscribe" && parsed.response !== "poke") { - console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500)); -} -``` - -Remove the condition to see all events: -```javascript -console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500)); -``` - -### Channel Nest Format - -Format: `{type}/{host-ship}/{channel-name}` - -Examples: -- `chat/~bitpyx-dildus/core` -- `chat/~malmur-halmex/v3aedb3s` -- `chat/~sitrul-nacwyl/tm-wayfinding-group-chat` - -Parse with: -```javascript -const match = channelNest.match(/^([^/]+)\/([^/]+)\/(.+)$/); -const [, type, hostShip, channelName] = match; -``` - -### Auto-Discovery Endpoint - -Query: `GET /~/scry/groups-ui/v6/init.json` - -Response structure: -```json -{ - "groups": { - "group-id": { - "channels": { - "chat/~host/name": { ... }, - "diary/~host/name": { ... } - } - } - } -} -``` - -Filter for chat channels only: -```javascript -if (channelNest.startsWith("chat/")) { - channels.push(channelNest); -} -``` - -## Implementation Timeline - -### Major Milestones - -1. ✅ Plugin structure and registration -2. ✅ Authentication and cookie management -3. ✅ Channel creation and activation (helm-hi poke) -4. ✅ SSE stream connection -5. ✅ DM subscription and event parsing -6. ✅ Group channel support -7. ✅ Auto-discovery of channels -8. ✅ Per-conversation subscriptions -9. ✅ Text extraction (mentions and breaks) -10. ✅ Mention detection -11. ✅ Node.js polyfills (window.location) -12. ✅ Core module integration -13. ⏳ API authentication (user needs to configure) - -### Key Discoveries - -- **Helm-hi requirement:** Must send helm-hi poke before opening SSE stream -- **Subscription paths:** Frontend uses `/v3` globally, but individual `/dm/{ship}` and `/{channelNest}` paths work better -- **Event formats:** V3 API uses `essay` and `memo` structures (not older `writs` format) -- **Inline content:** Mixed array of strings and objects (mentions, breaks) -- **Tilde handling:** Ship mentions already include `~` prefix -- **Word boundaries:** `\b` regex doesn't work with `~` character -- **Browser globals:** axios and Slack SDK need window.location polyfill -- **Module resolution:** Need CLAWDBOT_ROOT for dynamic imports - -## Resources - -- **Tlon Apps GitHub:** https://github.com/tloncorp/tlon-apps -- **Urbit HTTP API:** @urbit/http-api package -- **Tlon Frontend Code:** `/tmp/tlon-apps/packages/shared/src/api/chatApi.ts` -- **Clawdbot Docs:** https://docs.clawd.bot/ -- **Anthropic Provider:** https://docs.clawd.bot/providers/anthropic - -## Future Enhancements - -- [ ] Support for message reactions -- [ ] Support for message editing/deletion -- [ ] Support for attachments/images -- [ ] Typing indicators -- [ ] Read receipts -- [ ] Message threading -- [ ] Channel-specific bot personas -- [ ] Rate limiting -- [ ] Message queuing for offline ships -- [ ] Metrics and monitoring - -## Credits - -Built for integrating Clawdbot with Tlon messenger. - -**Developer:** Claude (Sonnet 4.5) -**Platform:** Tlon Messenger built on Urbit +Docs: https://docs.clawd.bot/channels/tlon diff --git a/extensions/tlon/index.ts b/extensions/tlon/index.ts index 52b82e9dd..d5d27056b 100644 --- a/extensions/tlon/index.ts +++ b/extensions/tlon/index.ts @@ -2,6 +2,7 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { tlonPlugin } from "./src/channel.js"; +import { setTlonRuntime } from "./src/runtime.js"; const plugin = { id: "tlon", @@ -9,6 +10,7 @@ const plugin = { description: "Tlon/Urbit channel plugin", configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { + setTlonRuntime(api.runtime); api.registerChannel({ plugin: tlonPlugin }); }, }; diff --git a/extensions/tlon/node_modules/@urbit/aura b/extensions/tlon/node_modules/@urbit/aura new file mode 120000 index 000000000..8e9400cee --- /dev/null +++ b/extensions/tlon/node_modules/@urbit/aura @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@urbit+aura@2.0.1/node_modules/@urbit/aura \ No newline at end of file diff --git a/extensions/tlon/node_modules/@urbit/http-api b/extensions/tlon/node_modules/@urbit/http-api new file mode 120000 index 000000000..6411dd8e7 --- /dev/null +++ b/extensions/tlon/node_modules/@urbit/http-api @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@urbit+http-api@3.0.0/node_modules/@urbit/http-api \ No newline at end of file diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index c11d45c97..03158015c 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -6,11 +6,25 @@ "clawdbot": { "extensions": [ "./index.ts" - ] + ], + "channel": { + "id": "tlon", + "label": "Tlon", + "selectionLabel": "Tlon (Urbit)", + "docsPath": "/channels/tlon", + "docsLabel": "tlon", + "blurb": "decentralized messaging on Urbit; install the plugin to enable.", + "order": 90, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@clawdbot/tlon", + "localPath": "extensions/tlon", + "defaultChoice": "npm" + } }, "dependencies": { - "@urbit/http-api": "^3.0.0", "@urbit/aura": "^2.0.0", - "eventsource": "^2.0.2" + "@urbit/http-api": "^3.0.0" } } diff --git a/extensions/tlon/src/channel.js b/extensions/tlon/src/channel.js deleted file mode 100644 index c1974f91b..000000000 --- a/extensions/tlon/src/channel.js +++ /dev/null @@ -1,360 +0,0 @@ -import { Urbit } from "@urbit/http-api"; -import { unixToDa, formatUd } from "@urbit/aura"; - -// Polyfill minimal browser globals needed by @urbit/http-api in Node -if (typeof global.window === "undefined") { - global.window = { fetch: global.fetch }; -} -if (typeof global.document === "undefined") { - global.document = { - hidden: true, - addEventListener() {}, - removeEventListener() {}, - }; -} - -// Patch Urbit.prototype.connect for HTTP authentication -const { connect } = Urbit.prototype; -Urbit.prototype.connect = async function patchedConnect() { - const resp = await fetch(`${this.url}/~/login`, { - method: "POST", - body: `password=${this.code}`, - credentials: "include", - }); - - if (resp.status >= 400) { - throw new Error("Login failed with status " + resp.status); - } - - const cookie = resp.headers.get("set-cookie"); - if (cookie) { - const match = /urbauth-~([\w-]+)/.exec(cookie); - if (!this.nodeId && match) { - this.nodeId = match[1]; - } - this.cookie = cookie; - } - await this.getShipName(); - await this.getOurName(); -}; - -/** - * Tlon/Urbit channel plugin for Clawdbot - */ -export const tlonPlugin = { - id: "tlon", - meta: { - id: "tlon", - label: "Tlon", - selectionLabel: "Tlon/Urbit", - docsPath: "/channels/tlon", - docsLabel: "tlon", - blurb: "Decentralized messaging on Urbit", - aliases: ["urbit"], - order: 90, - }, - capabilities: { - chatTypes: ["direct", "group"], - media: false, - }, - reload: { configPrefixes: ["channels.tlon"] }, - config: { - listAccountIds: (cfg) => { - const base = cfg.channels?.tlon; - if (!base) return []; - const accounts = base.accounts || {}; - return [ - ...(base.ship ? ["default"] : []), - ...Object.keys(accounts), - ]; - }, - resolveAccount: (cfg, accountId) => { - const base = cfg.channels?.tlon; - if (!base) { - return { - accountId: accountId || "default", - name: null, - enabled: false, - configured: false, - ship: null, - url: null, - code: null, - }; - } - - const useDefault = !accountId || accountId === "default"; - const account = useDefault ? base : base.accounts?.[accountId]; - - return { - accountId: accountId || "default", - name: account?.name || null, - enabled: account?.enabled !== false, - configured: Boolean(account?.ship && account?.code && account?.url), - ship: account?.ship || null, - url: account?.url || null, - code: account?.code || null, - groupChannels: account?.groupChannels || [], - dmAllowlist: account?.dmAllowlist || [], - notebookChannel: account?.notebookChannel || null, - }; - }, - defaultAccountId: () => "default", - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const useDefault = !accountId || accountId === "default"; - - if (useDefault) { - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - enabled, - }, - }, - }; - } - - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - accounts: { - ...cfg.channels?.tlon?.accounts, - [accountId]: { - ...cfg.channels?.tlon?.accounts?.[accountId], - enabled, - }, - }, - }, - }, - }; - }, - deleteAccount: ({ cfg, accountId }) => { - const useDefault = !accountId || accountId === "default"; - - if (useDefault) { - const { ship, code, url, name, ...rest } = cfg.channels?.tlon || {}; - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: rest, - }, - }; - } - - const { [accountId]: removed, ...remainingAccounts } = - cfg.channels?.tlon?.accounts || {}; - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - accounts: remainingAccounts, - }, - }, - }; - }, - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - ship: account.ship, - url: account.url, - }), - }, - messaging: { - normalizeTarget: (target) => { - // Normalize Urbit ship names - const trimmed = target.trim(); - if (!trimmed.startsWith("~")) { - return `~${trimmed}`; - } - return trimmed; - }, - targetResolver: { - looksLikeId: (target) => { - return /^~?[a-z-]+$/.test(target); - }, - hint: "~sampel-palnet or sampel-palnet", - }, - }, - outbound: { - deliveryMode: "direct", - chunker: (text, limit) => [text], // No chunking for now - textChunkLimit: 10000, - sendText: async ({ cfg, to, text, accountId }) => { - const account = tlonPlugin.config.resolveAccount(cfg, accountId); - - if (!account.configured) { - throw new Error("Tlon account not configured"); - } - - // Authenticate with Urbit - const api = await Urbit.authenticate({ - ship: account.ship.replace(/^~/, ""), - url: account.url, - code: account.code, - verbose: false, - }); - - try { - // Normalize ship name for sending - const toShip = to.startsWith("~") ? to : `~${to}`; - const fromShip = account.ship.startsWith("~") - ? account.ship - : `~${account.ship}`; - - // Construct message in Tlon format - const story = [{ inline: [text] }]; - const sentAt = Date.now(); - const idUd = formatUd(unixToDa(sentAt).toString()); - const id = `${fromShip}/${idUd}`; - - const delta = { - add: { - memo: { - content: story, - author: fromShip, - sent: sentAt, - }, - kind: null, - time: null, - }, - }; - - const action = { - ship: toShip, - diff: { id, delta }, - }; - - // Send via poke - await api.poke({ - app: "chat", - mark: "chat-dm-action", - json: action, - }); - - return { - channel: "tlon", - success: true, - messageId: id, - }; - } finally { - // Clean up connection - try { - await api.delete(); - } catch (e) { - // Ignore cleanup errors - } - } - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => { - // TODO: Tlon/Urbit doesn't support media attachments yet - // For now, send the caption text and include media URL in the message - const messageText = mediaUrl - ? `${text}\n\n[Media: ${mediaUrl}]` - : text; - - // Reuse sendText implementation - return await tlonPlugin.outbound.sendText({ - cfg, - to, - text: messageText, - accountId, - }); - }, - }, - status: { - defaultRuntime: { - accountId: "default", - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - collectStatusIssues: (accounts) => { - return accounts.flatMap((account) => { - if (!account.configured) { - return [{ - channel: "tlon", - accountId: account.accountId, - kind: "config", - message: "Account not configured (missing ship, code, or url)", - }]; - } - return []; - }); - }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - ship: snapshot.ship ?? null, - url: snapshot.url ?? null, - }), - probeAccount: async ({ account }) => { - if (!account.configured) { - return { ok: false, error: "Not configured" }; - } - - try { - const api = await Urbit.authenticate({ - ship: account.ship.replace(/^~/, ""), - url: account.url, - code: account.code, - verbose: false, - }); - - try { - await api.getOurName(); - return { ok: true }; - } finally { - await api.delete(); - } - } catch (error) { - return { ok: false, error: error.message }; - } - }, - buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - ship: account.ship, - url: account.url, - probe, - }), - }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - ctx.setStatus({ - accountId: account.accountId, - ship: account.ship, - url: account.url, - }); - ctx.log?.info( - `[${account.accountId}] starting Tlon provider for ${account.ship}` - ); - - // Lazy import to avoid circular dependencies - const { monitorTlonProvider } = await import("./monitor.js"); - - return monitorTlonProvider({ - account, - accountId: account.accountId, - cfg: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - }); - }, - }, -}; - -// Export tlonPlugin for use by index.ts -export { tlonPlugin }; diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts new file mode 100644 index 000000000..e4c949452 --- /dev/null +++ b/extensions/tlon/src/channel.ts @@ -0,0 +1,379 @@ +import type { + ChannelOutboundAdapter, + ChannelPlugin, + ChannelSetupInput, + ClawdbotConfig, +} from "clawdbot/plugin-sdk"; +import { + applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "clawdbot/plugin-sdk"; + +import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; +import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; +import { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.js"; +import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js"; +import { monitorTlonProvider } from "./monitor/index.js"; +import { tlonChannelConfigSchema } from "./config-schema.js"; +import { tlonOnboardingAdapter } from "./onboarding.js"; + +const TLON_CHANNEL_ID = "tlon" as const; + +type TlonSetupInput = ChannelSetupInput & { + ship?: string; + url?: string; + code?: string; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; +}; + +function applyTlonSetupConfig(params: { + cfg: ClawdbotConfig; + accountId: string; + input: TlonSetupInput; +}): ClawdbotConfig { + const { cfg, accountId, input } = params; + const useDefault = accountId === DEFAULT_ACCOUNT_ID; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: "tlon", + accountId, + name: input.name, + }); + const base = namedConfig.channels?.tlon ?? {}; + + const payload = { + ...(input.ship ? { ship: input.ship } : {}), + ...(input.url ? { url: input.url } : {}), + ...(input.code ? { code: input.code } : {}), + ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), + ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), + ...(typeof input.autoDiscoverChannels === "boolean" + ? { autoDiscoverChannels: input.autoDiscoverChannels } + : {}), + }; + + if (useDefault) { + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + tlon: { + ...base, + enabled: true, + ...payload, + }, + }, + }; + } + + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + tlon: { + ...base, + enabled: base.enabled ?? true, + accounts: { + ...(base as { accounts?: Record }).accounts, + [accountId]: { + ...((base as { accounts?: Record> }).accounts?.[ + accountId + ] ?? {}), + enabled: true, + ...payload, + }, + }, + }, + }, + }; +} + +const tlonOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + textChunkLimit: 10000, + resolveTarget: ({ to }) => { + const parsed = parseTlonTarget(to ?? ""); + if (!parsed) { + return { + ok: false, + error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`), + }; + } + if (parsed.kind === "dm") { + return { ok: true, to: parsed.ship }; + } + return { ok: true, to: parsed.nest }; + }, + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { + const account = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined); + if (!account.configured || !account.ship || !account.url || !account.code) { + throw new Error("Tlon account not configured"); + } + + const parsed = parseTlonTarget(to); + if (!parsed) { + throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); + } + + ensureUrbitConnectPatched(); + const api = await Urbit.authenticate({ + ship: account.ship.replace(/^~/, ""), + url: account.url, + code: account.code, + verbose: false, + }); + + try { + const fromShip = normalizeShip(account.ship); + if (parsed.kind === "dm") { + return await sendDm({ + api, + fromShip, + toShip: parsed.ship, + text, + }); + } + const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; + return await sendGroupMessage({ + api, + fromShip, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + text, + replyToId: replyId, + }); + } finally { + try { + await api.delete(); + } catch { + // ignore cleanup errors + } + } + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => { + const mergedText = buildMediaText(text, mediaUrl); + return await tlonOutbound.sendText({ + cfg, + to, + text: mergedText, + accountId, + replyToId, + threadId, + }); + }, +}; + +export const tlonPlugin: ChannelPlugin = { + id: TLON_CHANNEL_ID, + meta: { + id: TLON_CHANNEL_ID, + label: "Tlon", + selectionLabel: "Tlon (Urbit)", + docsPath: "/channels/tlon", + docsLabel: "tlon", + blurb: "Decentralized messaging on Urbit", + aliases: ["urbit"], + order: 90, + }, + capabilities: { + chatTypes: ["direct", "group", "thread"], + media: false, + reply: true, + threads: true, + }, + onboarding: tlonOnboardingAdapter, + reload: { configPrefixes: ["channels.tlon"] }, + configSchema: tlonChannelConfigSchema, + config: { + listAccountIds: (cfg) => listTlonAccountIds(cfg as ClawdbotConfig), + resolveAccount: (cfg, accountId) => resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined), + defaultAccountId: () => "default", + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const useDefault = !accountId || accountId === "default"; + if (useDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...(cfg.channels?.tlon ?? {}), + enabled, + }, + }, + } as ClawdbotConfig; + } + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...(cfg.channels?.tlon ?? {}), + accounts: { + ...(cfg.channels?.tlon?.accounts ?? {}), + [accountId]: { + ...(cfg.channels?.tlon?.accounts?.[accountId] ?? {}), + enabled, + }, + }, + }, + }, + } as ClawdbotConfig; + }, + deleteAccount: ({ cfg, accountId }) => { + const useDefault = !accountId || accountId === "default"; + if (useDefault) { + const { ship, code, url, name, ...rest } = cfg.channels?.tlon ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: rest, + }, + } as ClawdbotConfig; + } + const { [accountId]: removed, ...remainingAccounts } = cfg.channels?.tlon?.accounts ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...(cfg.channels?.tlon ?? {}), + accounts: remainingAccounts, + }, + }, + } as ClawdbotConfig; + }, + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + ship: account.ship, + url: account.url, + }), + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as ClawdbotConfig, + channelKey: "tlon", + accountId, + name, + }), + validateInput: ({ cfg, accountId, input }) => { + const setupInput = input as TlonSetupInput; + const resolved = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined); + const ship = setupInput.ship?.trim() || resolved.ship; + const url = setupInput.url?.trim() || resolved.url; + const code = setupInput.code?.trim() || resolved.code; + if (!ship) return "Tlon requires --ship."; + if (!url) return "Tlon requires --url."; + if (!code) return "Tlon requires --code."; + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => + applyTlonSetupConfig({ + cfg: cfg as ClawdbotConfig, + accountId, + input: input as TlonSetupInput, + }), + }, + messaging: { + normalizeTarget: (target) => { + const parsed = parseTlonTarget(target); + if (!parsed) return target.trim(); + if (parsed.kind === "dm") return parsed.ship; + return parsed.nest; + }, + targetResolver: { + looksLikeId: (target) => Boolean(parseTlonTarget(target)), + hint: formatTargetHint(), + }, + }, + outbound: tlonOutbound, + status: { + defaultRuntime: { + accountId: "default", + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => { + return accounts.flatMap((account) => { + if (!account.configured) { + return [ + { + channel: TLON_CHANNEL_ID, + accountId: account.accountId, + kind: "config", + message: "Account not configured (missing ship, code, or url)", + }, + ]; + } + return []; + }); + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + ship: snapshot.ship ?? null, + url: snapshot.url ?? null, + }), + probeAccount: async ({ account }) => { + if (!account.configured || !account.ship || !account.url || !account.code) { + return { ok: false, error: "Not configured" }; + } + try { + ensureUrbitConnectPatched(); + const api = await Urbit.authenticate({ + ship: account.ship.replace(/^~/, ""), + url: account.url, + code: account.code, + verbose: false, + }); + try { + await api.getOurName(); + return { ok: true }; + } finally { + await api.delete(); + } + } catch (error: any) { + return { ok: false, error: error?.message ?? String(error) }; + } + }, + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + ship: account.ship, + url: account.url, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + ship: account.ship, + url: account.url, + }); + ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`); + return monitorTlonProvider({ + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + accountId: account.accountId, + }); + }, + }, +}; diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts new file mode 100644 index 000000000..13c7cd7c0 --- /dev/null +++ b/extensions/tlon/src/config-schema.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { buildChannelConfigSchema } from "clawdbot/plugin-sdk"; + +const ShipSchema = z.string().min(1); +const ChannelNestSchema = z.string().min(1); + +export const TlonChannelRuleSchema = z.object({ + mode: z.enum(["restricted", "open"]).optional(), + allowedShips: z.array(ShipSchema).optional(), +}); + +export const TlonAuthorizationSchema = z.object({ + channelRules: z.record(TlonChannelRuleSchema).optional(), +}); + +export const TlonAccountSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + ship: ShipSchema.optional(), + url: z.string().optional(), + code: z.string().optional(), + groupChannels: z.array(ChannelNestSchema).optional(), + dmAllowlist: z.array(ShipSchema).optional(), + autoDiscoverChannels: z.boolean().optional(), + showModelSignature: z.boolean().optional(), +}); + +export const TlonConfigSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + ship: ShipSchema.optional(), + url: z.string().optional(), + code: z.string().optional(), + groupChannels: z.array(ChannelNestSchema).optional(), + dmAllowlist: z.array(ShipSchema).optional(), + autoDiscoverChannels: z.boolean().optional(), + showModelSignature: z.boolean().optional(), + authorization: TlonAuthorizationSchema.optional(), + defaultAuthorizedShips: z.array(ShipSchema).optional(), + accounts: z.record(TlonAccountSchema).optional(), +}); + +export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema); diff --git a/extensions/tlon/src/core-bridge.js b/extensions/tlon/src/core-bridge.js deleted file mode 100644 index 634ef3dd8..000000000 --- a/extensions/tlon/src/core-bridge.js +++ /dev/null @@ -1,100 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -let coreRootCache = null; -let coreDepsPromise = null; - -function findPackageRoot(startDir, name) { - let dir = startDir; - for (;;) { - const pkgPath = path.join(dir, "package.json"); - try { - if (fs.existsSync(pkgPath)) { - const raw = fs.readFileSync(pkgPath, "utf8"); - const pkg = JSON.parse(raw); - if (pkg.name === name) return dir; - } - } catch { - // ignore parse errors - } - const parent = path.dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -function resolveClawdbotRoot() { - if (coreRootCache) return coreRootCache; - const override = process.env.CLAWDBOT_ROOT?.trim(); - if (override) { - coreRootCache = override; - return override; - } - - const candidates = new Set(); - if (process.argv[1]) { - candidates.add(path.dirname(process.argv[1])); - } - candidates.add(process.cwd()); - try { - const urlPath = fileURLToPath(import.meta.url); - candidates.add(path.dirname(urlPath)); - } catch { - // ignore - } - - for (const start of candidates) { - const found = findPackageRoot(start, "clawdbot"); - if (found) { - coreRootCache = found; - return found; - } - } - - throw new Error( - "Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.", - ); -} - -async function importCoreModule(relativePath) { - const root = resolveClawdbotRoot(); - const distPath = path.join(root, "dist", relativePath); - if (!fs.existsSync(distPath)) { - throw new Error( - `Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`, - ); - } - return await import(pathToFileURL(distPath).href); -} - -export async function loadCoreChannelDeps() { - if (coreDepsPromise) return coreDepsPromise; - - coreDepsPromise = (async () => { - const [ - chunk, - envelope, - dispatcher, - routing, - inboundContext, - ] = await Promise.all([ - importCoreModule("auto-reply/chunk.js"), - importCoreModule("auto-reply/envelope.js"), - importCoreModule("auto-reply/reply/provider-dispatcher.js"), - importCoreModule("routing/resolve-route.js"), - importCoreModule("auto-reply/reply/inbound-context.js"), - ]); - - return { - chunkMarkdownText: chunk.chunkMarkdownText, - formatAgentEnvelope: envelope.formatAgentEnvelope, - dispatchReplyWithBufferedBlockDispatcher: - dispatcher.dispatchReplyWithBufferedBlockDispatcher, - resolveAgentRoute: routing.resolveAgentRoute, - finalizeInboundContext: inboundContext.finalizeInboundContext, - }; - })(); - - return coreDepsPromise; -} diff --git a/extensions/tlon/src/monitor.js b/extensions/tlon/src/monitor.js deleted file mode 100644 index 8cfcf54ea..000000000 --- a/extensions/tlon/src/monitor.js +++ /dev/null @@ -1,1572 +0,0 @@ -// Polyfill window.location for Node.js environment -// Required because some clawdbot dependencies (axios, Slack SDK) expect browser globals -if (typeof global.window === "undefined") { - global.window = {}; -} -if (!global.window.location) { - global.window.location = { - href: "http://localhost", - origin: "http://localhost", - protocol: "http:", - host: "localhost", - hostname: "localhost", - port: "", - pathname: "/", - search: "", - hash: "", - }; -} - -import { unixToDa, formatUd } from "@urbit/aura"; -import { UrbitSSEClient } from "./urbit-sse-client.js"; -import { loadCoreChannelDeps } from "./core-bridge.js"; - -console.log("[tlon] ====== monitor.js v2 loaded with action.post.reply structure ======"); - -/** - * Formats model name for display in signature - * Converts "anthropic/claude-sonnet-4-5" to "Claude Sonnet 4.5" - */ -function formatModelName(modelString) { - if (!modelString) return "AI"; - - // Remove provider prefix (e.g., "anthropic/", "openai/") - const modelName = modelString.includes("/") - ? modelString.split("/")[1] - : modelString; - - // Convert common model names to friendly format - const modelMappings = { - "claude-opus-4-5": "Claude Opus 4.5", - "claude-sonnet-4-5": "Claude Sonnet 4.5", - "claude-sonnet-3-5": "Claude Sonnet 3.5", - "gpt-4o": "GPT-4o", - "gpt-4-turbo": "GPT-4 Turbo", - "gpt-4": "GPT-4", - "gemini-2.0-flash": "Gemini 2.0 Flash", - "gemini-pro": "Gemini Pro", - }; - - return modelMappings[modelName] || modelName - .replace(/-/g, " ") - .split(" ") - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -} - -/** - * Authenticate and get cookie - */ -async function authenticate(url, code) { - const resp = await fetch(`${url}/~/login`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `password=${code}`, - }); - - if (!resp.ok) { - throw new Error(`Login failed with status ${resp.status}`); - } - - // Read and discard the token body - await resp.text(); - - // Extract cookie - const cookie = resp.headers.get("set-cookie"); - if (!cookie) { - throw new Error("No authentication cookie received"); - } - - return cookie; -} - -/** - * Sends a direct message via Urbit - */ -async function sendDm(api, fromShip, toShip, text) { - const story = [{ inline: [text] }]; - const sentAt = Date.now(); - const idUd = formatUd(unixToDa(sentAt).toString()); - const id = `${fromShip}/${idUd}`; - - const delta = { - add: { - memo: { - content: story, - author: fromShip, - sent: sentAt, - }, - kind: null, - time: null, - }, - }; - - const action = { - ship: toShip, - diff: { id, delta }, - }; - - await api.poke({ - app: "chat", - mark: "chat-dm-action", - json: action, - }); - - return { channel: "tlon", success: true, messageId: id }; -} - -/** - * Format a numeric ID with dots every 3 digits (Urbit @ud format) - * Example: "170141184507780357587090523864791252992" -> "170.141.184.507.780.357.587.090.523.864.791.252.992" - */ -function formatUdId(id) { - if (!id) return id; - const idStr = String(id); - // Insert dots every 3 characters from the left - return idStr.replace(/\B(?=(\d{3})+(?!\d))/g, '.'); -} - -/** - * Sends a message to a group channel - * @param {string} replyTo - Optional parent post ID for threading - */ -async function sendGroupMessage(api, fromShip, hostShip, channelName, text, replyTo = null, runtime = null) { - const story = [{ inline: [text] }]; - const sentAt = Date.now(); - - // Format reply ID with dots for Urbit @ud format - const formattedReplyTo = replyTo ? formatUdId(replyTo) : null; - - const action = { - channel: { - nest: `chat/${hostShip}/${channelName}`, - action: formattedReplyTo ? { - // Reply action for threading (wraps reply in post like official client) - post: { - reply: { - id: formattedReplyTo, - action: { - add: { - content: story, - author: fromShip, - sent: sentAt, - } - } - } - } - } : { - // Regular post action - post: { - add: { - content: story, - author: fromShip, - sent: sentAt, - kind: "/chat", - blob: null, - meta: null, - }, - }, - }, - }, - }; - - runtime?.log?.(`[tlon] 📤 Sending message: replyTo=${replyTo} (formatted: ${formattedReplyTo}), text="${text.substring(0, 100)}...", nest=chat/${hostShip}/${channelName}`); - runtime?.log?.(`[tlon] 📤 Action type: ${formattedReplyTo ? 'REPLY (thread)' : 'POST (main channel)'}`); - runtime?.log?.(`[tlon] 📤 Full action structure: ${JSON.stringify(action, null, 2)}`); - - try { - const pokeResult = await api.poke({ - app: "channels", - mark: "channel-action-1", - json: action, - }); - - runtime?.log?.(`[tlon] 📤 Poke succeeded: ${JSON.stringify(pokeResult)}`); - return { channel: "tlon", success: true, messageId: `${fromShip}/${sentAt}` }; - } catch (error) { - runtime?.error?.(`[tlon] 📤 Poke FAILED: ${error.message}`); - runtime?.error?.(`[tlon] 📤 Error details: ${JSON.stringify(error)}`); - throw error; - } -} - -/** - * Checks if the bot's ship is mentioned in a message - */ -function isBotMentioned(messageText, botShipName) { - if (!messageText || !botShipName) return false; - - // Normalize bot ship name (ensure it has ~) - const normalizedBotShip = botShipName.startsWith("~") - ? botShipName - : `~${botShipName}`; - - // Escape special regex characters - const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - - // Check for mention - ship name should be at start, after whitespace, or standalone - const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i"); - return mentionPattern.test(messageText); -} - -/** - * Parses commands related to notebook operations - * @param {string} messageText - The message to parse - * @returns {Object|null} Command info or null if no command detected - */ -function parseNotebookCommand(messageText) { - const text = messageText.toLowerCase().trim(); - - // Save to notebook patterns - const savePatterns = [ - /save (?:this|that) to (?:my )?notes?/i, - /save to (?:my )?notes?/i, - /save to notebook/i, - /add to (?:my )?diary/i, - /save (?:this|that) to (?:my )?diary/i, - /save to (?:my )?diary/i, - /save (?:this|that)/i, - ]; - - for (const pattern of savePatterns) { - if (pattern.test(text)) { - return { - type: "save_to_notebook", - title: extractTitle(messageText), - }; - } - } - - // List notebook patterns - const listPatterns = [ - /(?:list|show) (?:my )?(?:notes?|notebook|diary)/i, - /what(?:'s| is) in (?:my )?(?:notes?|notebook|diary)/i, - /check (?:my )?(?:notes?|notebook|diary)/i, - ]; - - for (const pattern of listPatterns) { - if (pattern.test(text)) { - return { - type: "list_notebook", - }; - } - } - - return null; -} - -/** - * Extracts a title from a save command - * @param {string} text - The message text - * @returns {string|null} Extracted title or null - */ -function extractTitle(text) { - // Try to extract title from "as [title]" or "with title [title]" - const asMatch = /(?:as|with title)\s+["']([^"']+)["']/i.exec(text); - if (asMatch) return asMatch[1]; - - const asMatch2 = /(?:as|with title)\s+(.+?)(?:\.|$)/i.exec(text); - if (asMatch2) return asMatch2[1].trim(); - - return null; -} - -/** - * Sends a post to an Urbit diary channel - * @param {Object} api - Authenticated Urbit API instance - * @param {Object} account - Account configuration - * @param {string} diaryChannel - Diary channel in format "diary/~host/channel-id" - * @param {string} title - Post title - * @param {string} content - Post content - * @returns {Promise<{essayId: string, sentAt: number}>} - */ -async function sendDiaryPost(api, account, diaryChannel, title, content) { - // Parse channel format: "diary/~host/channel-id" - const match = /^diary\/~?([a-z-]+)\/([a-z0-9]+)$/i.exec(diaryChannel); - - if (!match) { - throw new Error(`Invalid diary channel format: ${diaryChannel}. Expected: diary/~host/channel-id`); - } - - const host = match[1]; - const channelId = match[2]; - const nest = `diary/~${host}/${channelId}`; - - // Construct essay (diary entry) format - const sentAt = Date.now(); - const idUd = formatUd(unixToDa(sentAt).toString()); - const fromShip = account.ship.startsWith("~") ? account.ship : `~${account.ship}`; - const essayId = `${fromShip}/${idUd}`; - - const action = { - channel: { - nest, - action: { - post: { - add: { - content: [{ inline: [content] }], - sent: sentAt, - kind: "/diary", - author: fromShip, - blob: null, - meta: { - title: title || "Saved Note", - image: "", - description: "", - cover: "", - }, - }, - }, - }, - }, - }; - - await api.poke({ - app: "channels", - mark: "channel-action-1", - json: action, - }); - - return { essayId, sentAt }; -} - -/** - * Fetches diary entries from an Urbit diary channel - * @param {Object} api - Authenticated Urbit API instance - * @param {string} diaryChannel - Diary channel in format "diary/~host/channel-id" - * @param {number} limit - Maximum number of entries to fetch (default: 10) - * @returns {Promise} Array of diary entries with { id, title, content, author, sent } - */ -async function fetchDiaryEntries(api, diaryChannel, limit = 10) { - // Parse channel format: "diary/~host/channel-id" - const match = /^diary\/~?([a-z-]+)\/([a-z0-9]+)$/i.exec(diaryChannel); - - if (!match) { - throw new Error(`Invalid diary channel format: ${diaryChannel}. Expected: diary/~host/channel-id`); - } - - const host = match[1]; - const channelId = match[2]; - const nest = `diary/~${host}/${channelId}`; - - try { - // Scry the diary channel for posts - const response = await api.scry({ - app: "channels", - path: `/channel/${nest}/posts/newest/${limit}`, - }); - - if (!response || !response.posts) { - return []; - } - - // Extract and format diary entries - const entries = Object.entries(response.posts).map(([id, post]) => { - const essay = post.essay || {}; - - // Extract text content from prose blocks - let content = ""; - if (essay.content && Array.isArray(essay.content)) { - content = essay.content - .map((block) => { - if (block.block?.prose?.inline) { - return block.block.prose.inline.join(""); - } - return ""; - }) - .join("\n"); - } - - return { - id, - title: essay.title || "Untitled", - content, - author: essay.author || "unknown", - sent: essay.sent || 0, - }; - }); - - // Sort by sent time (newest first) - return entries.sort((a, b) => b.sent - a.sent); - } catch (error) { - console.error(`[tlon] Error fetching diary entries from ${nest}:`, error); - throw error; - } -} - -/** - * Checks if a ship is allowed to send DMs to the bot - */ -function isDmAllowed(senderShip, account) { - // If dmAllowlist is not configured or empty, allow all - if (!account.dmAllowlist || !Array.isArray(account.dmAllowlist) || account.dmAllowlist.length === 0) { - return true; - } - - // Normalize ship names for comparison (ensure ~ prefix) - const normalizedSender = senderShip.startsWith("~") - ? senderShip - : `~${senderShip}`; - - const normalizedAllowlist = account.dmAllowlist - .map((ship) => ship.startsWith("~") ? ship : `~${ship}`); - - // Check if sender is in allowlist - return normalizedAllowlist.includes(normalizedSender); -} - -/** - * Extracts text content from Tlon message structure - */ -function extractMessageText(content) { - if (!content || !Array.isArray(content)) return ""; - - return content - .map((block) => { - if (block.inline && Array.isArray(block.inline)) { - return block.inline - .map((item) => { - if (typeof item === "string") return item; - if (item && typeof item === "object") { - if (item.ship) return item.ship; // Ship mention - if (item.break !== undefined) return "\n"; // Line break - if (item.link && item.link.href) return item.link.href; // URL link - // Skip other objects (images, etc.) - } - return ""; - }) - .join(""); - } - return ""; - }) - .join("\n") - .trim(); -} - -/** - * Parses a channel nest identifier - * Format: chat/~host-ship/channel-name - */ -function parseChannelNest(nest) { - if (!nest) return null; - const parts = nest.split("/"); - if (parts.length !== 3 || parts[0] !== "chat") return null; - - return { - hostShip: parts[1], - channelName: parts[2], - }; -} - -/** - * Message cache for channel history (for faster access) - * Structure: Map> - */ -const messageCache = new Map(); -const MAX_CACHED_MESSAGES = 100; - -/** - * Adds a message to the cache - */ -function cacheMessage(channelNest, message) { - if (!messageCache.has(channelNest)) { - messageCache.set(channelNest, []); - } - - const cache = messageCache.get(channelNest); - cache.unshift(message); // Add to front (most recent) - - // Keep only last MAX_CACHED_MESSAGES - if (cache.length > MAX_CACHED_MESSAGES) { - cache.pop(); - } -} - -/** - * Fetches channel history from Urbit via scry - * Format: /channels/v4//posts/newest//outline.json - * Returns pagination object: { newest, posts: {...}, total, newer, older } - */ -async function fetchChannelHistory(api, channelNest, count = 50, runtime) { - try { - const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`; - runtime?.log?.(`[tlon] Fetching history: ${scryPath}`); - - const data = await api.scry(scryPath); - runtime?.log?.(`[tlon] Scry returned data type: ${Array.isArray(data) ? 'array' : typeof data}, keys: ${typeof data === 'object' ? Object.keys(data).slice(0, 5).join(', ') : 'N/A'}`); - - if (!data) { - runtime?.log?.(`[tlon] Data is null`); - return []; - } - - // Extract posts from pagination object - let posts = []; - if (Array.isArray(data)) { - // Direct array of posts - posts = data; - } else if (data.posts && typeof data.posts === 'object') { - // Pagination object with posts property (keyed by ID) - posts = Object.values(data.posts); - runtime?.log?.(`[tlon] Extracted ${posts.length} posts from pagination object`); - } else if (typeof data === 'object') { - // Fallback: treat as keyed object - posts = Object.values(data); - } - - runtime?.log?.(`[tlon] Processing ${posts.length} posts`); - - // Extract posts from outline format - const messages = posts.map(item => { - // Handle both post and r-post structures - const essay = item.essay || item['r-post']?.set?.essay; - const seal = item.seal || item['r-post']?.set?.seal; - - return { - author: essay?.author || 'unknown', - content: extractMessageText(essay?.content || []), - timestamp: essay?.sent || Date.now(), - id: seal?.id, - }; - }).filter(msg => msg.content); // Filter out empty messages - - runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`); - return messages; - } catch (error) { - runtime?.log?.(`[tlon] Error fetching channel history: ${error.message}`); - console.error(`[tlon] Error fetching channel history: ${error.message}`, error.stack); - return []; - } -} - -/** - * Gets recent channel history (tries cache first, then scry) - */ -async function getChannelHistory(api, channelNest, count = 50, runtime) { - // Try cache first for speed - const cache = messageCache.get(channelNest) || []; - if (cache.length >= count) { - runtime?.log?.(`[tlon] Using cached messages (${cache.length} available)`); - return cache.slice(0, count); - } - - runtime?.log?.(`[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`); - // Fall back to scry for full history - return await fetchChannelHistory(api, channelNest, count, runtime); -} - -/** - * Detects if a message is a summarization request - */ -function isSummarizationRequest(messageText) { - const patterns = [ - /summarize\s+(this\s+)?(channel|chat|conversation)/i, - /what\s+did\s+i\s+miss/i, - /catch\s+me\s+up/i, - /channel\s+summary/i, - /tldr/i, - ]; - return patterns.some(pattern => pattern.test(messageText)); -} - -/** - * Formats a date for the groups-ui changes endpoint - * Format: ~YYYY.M.D..HH.MM.SS..XXXX (only date changes, time/hex stay constant) - */ -function formatChangesDate(daysAgo = 5) { - const now = new Date(); - const targetDate = new Date(now - (daysAgo * 24 * 60 * 60 * 1000)); - const year = targetDate.getFullYear(); - const month = targetDate.getMonth() + 1; - const day = targetDate.getDate(); - // Keep time and hex constant as per Urbit convention - return `~${year}.${month}.${day}..20.19.51..9b9d`; -} - -/** - * Fetches changes from groups-ui since a specific date - * Returns delta data that can be used to efficiently discover new channels - */ -async function fetchGroupChanges(api, runtime, daysAgo = 5) { - try { - const changeDate = formatChangesDate(daysAgo); - runtime.log?.(`[tlon] Fetching group changes since ${daysAgo} days ago (${changeDate})...`); - - const changes = await api.scry(`/groups-ui/v5/changes/${changeDate}.json`); - - if (changes) { - runtime.log?.(`[tlon] Successfully fetched changes data`); - return changes; - } - - return null; - } catch (error) { - runtime.log?.(`[tlon] Failed to fetch changes (falling back to full init): ${error.message}`); - return null; - } -} - -/** - * Fetches all channels the ship has access to - * Returns an array of channel nest identifiers (e.g., "chat/~host-ship/channel-name") - * Tries changes endpoint first for efficiency, falls back to full init - */ -async function fetchAllChannels(api, runtime) { - try { - runtime.log?.(`[tlon] Attempting auto-discovery of group channels...`); - - // Try delta-based changes first (more efficient) - const changes = await fetchGroupChanges(api, runtime, 5); - - let initData; - if (changes) { - // We got changes, but still need to extract channel info - // For now, fall back to full init since changes format varies - runtime.log?.(`[tlon] Changes data received, using full init for channel extraction`); - initData = await api.scry("/groups-ui/v6/init.json"); - } else { - // No changes data, use full init - initData = await api.scry("/groups-ui/v6/init.json"); - } - - const channels = []; - - // Extract chat channels from the groups data structure - if (initData && initData.groups) { - for (const [groupKey, groupData] of Object.entries(initData.groups)) { - if (groupData.channels) { - for (const channelNest of Object.keys(groupData.channels)) { - // Only include chat channels (not diary, heap, etc.) - if (channelNest.startsWith("chat/")) { - channels.push(channelNest); - } - } - } - } - } - - if (channels.length > 0) { - runtime.log?.(`[tlon] Auto-discovered ${channels.length} chat channel(s)`); - runtime.log?.(`[tlon] Channels: ${channels.slice(0, 5).join(", ")}${channels.length > 5 ? "..." : ""}`); - } else { - runtime.log?.(`[tlon] No chat channels found via auto-discovery`); - runtime.log?.(`[tlon] Add channels manually to config: channels.tlon.groupChannels`); - } - - return channels; - } catch (error) { - runtime.log?.(`[tlon] Auto-discovery failed: ${error.message}`); - runtime.log?.(`[tlon] To monitor group channels, add them to config: channels.tlon.groupChannels`); - runtime.log?.(`[tlon] Example: ["chat/~host-ship/channel-name"]`); - return []; - } -} - -/** - * Monitors Tlon/Urbit for incoming DMs and group messages - */ -export async function monitorTlonProvider(opts = {}) { - const runtime = opts.runtime ?? { - log: console.log, - error: console.error, - }; - - const account = opts.account; - if (!account) { - throw new Error("Tlon account configuration required"); - } - - runtime.log?.(`[tlon] Account config: ${JSON.stringify({ - showModelSignature: account.showModelSignature, - ship: account.ship, - hasCode: !!account.code, - hasUrl: !!account.url - })}`); - - const botShipName = account.ship.startsWith("~") - ? account.ship - : `~${account.ship}`; - - runtime.log?.(`[tlon] Starting monitor for ${botShipName}`); - - // Authenticate with Urbit - let api; - let cookie; - try { - runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`); - runtime.log?.(`[tlon] Ship: ${account.ship.replace(/^~/, "")}`); - - cookie = await authenticate(account.url, account.code); - runtime.log?.(`[tlon] Successfully authenticated to ${account.url}`); - - // Create custom SSE client - api = new UrbitSSEClient(account.url, cookie); - } catch (error) { - runtime.error?.(`[tlon] Failed to authenticate: ${error.message}`); - throw error; - } - - // Get list of group channels to monitor - let groupChannels = []; - - // Try auto-discovery first (unless explicitly disabled) - if (account.autoDiscoverChannels !== false) { - try { - const discoveredChannels = await fetchAllChannels(api, runtime); - if (discoveredChannels.length > 0) { - groupChannels = discoveredChannels; - runtime.log?.(`[tlon] Auto-discovered ${groupChannels.length} channel(s)`); - } - } catch (error) { - runtime.error?.(`[tlon] Auto-discovery failed: ${error.message}`); - } - } - - // Fall back to manual config if auto-discovery didn't find anything - if (groupChannels.length === 0 && account.groupChannels && account.groupChannels.length > 0) { - groupChannels = account.groupChannels; - runtime.log?.(`[tlon] Using manual groupChannels config: ${groupChannels.join(", ")}`); - } - - if (groupChannels.length > 0) { - runtime.log?.( - `[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}` - ); - } else { - runtime.log?.(`[tlon] No group channels to monitor (DMs only)`); - } - - // Keep track of processed message IDs to avoid duplicates - const processedMessages = new Set(); - - /** - * Handler for incoming DM messages - */ - const handleIncomingDM = async (update) => { - try { - runtime.log?.(`[tlon] DM handler called with update: ${JSON.stringify(update).substring(0, 200)}`); - - // Handle new DM event format: response.add.memo or response.reply.delta.add.memo (for threads) - let memo = update?.response?.add?.memo; - let parentId = null; - let replyId = null; - - // Check if this is a thread reply - if (!memo && update?.response?.reply) { - memo = update?.response?.reply?.delta?.add?.memo; - parentId = update.id; // The parent post ID - replyId = update?.response?.reply?.id; // The reply message ID - runtime.log?.(`[tlon] Thread reply detected, parent: ${parentId}, reply: ${replyId}`); - } - - if (!memo) { - runtime.log?.(`[tlon] DM update has no memo in response.add or response.reply`); - return; - } - - const messageId = replyId || update.id; - if (processedMessages.has(messageId)) return; - processedMessages.add(messageId); - - const senderShip = memo.author?.startsWith("~") - ? memo.author - : `~${memo.author}`; - - const messageText = extractMessageText(memo.content); - if (!messageText) return; - - // Determine which user's DM cache to use (the other party, not the bot) - const otherParty = senderShip === botShipName ? update.whom : senderShip; - const dmCacheKey = `dm/${otherParty}`; - - // Cache all DM messages (including bot's own) for history retrieval - if (!messageCache.has(dmCacheKey)) { - messageCache.set(dmCacheKey, []); - } - const cache = messageCache.get(dmCacheKey); - cache.unshift({ - id: messageId, - author: senderShip, - content: messageText, - timestamp: memo.sent || Date.now(), - }); - // Keep only last 50 messages - if (cache.length > 50) { - cache.length = 50; - } - - // Don't respond to our own messages - if (senderShip === botShipName) return; - - // Check DM access control - if (!isDmAllowed(senderShip, account)) { - runtime.log?.( - `[tlon] Blocked DM from ${senderShip}: not in allowed list` - ); - return; - } - - runtime.log?.( - `[tlon] Received DM from ${senderShip}: "${messageText.slice(0, 50)}..."${parentId ? ' (thread reply)' : ''}` - ); - - // All DMs are processed (no mention check needed) - - await processMessage({ - messageId, - senderShip, - messageText, - isGroup: false, - timestamp: memo.sent || Date.now(), - parentId, // Pass parentId for thread replies - }); - } catch (error) { - runtime.error?.(`[tlon] Error handling DM: ${error.message}`); - } - }; - - /** - * Handler for incoming group channel messages - */ - const handleIncomingGroupMessage = (channelNest) => async (update) => { - try { - runtime.log?.(`[tlon] Group handler called for ${channelNest} with update: ${JSON.stringify(update).substring(0, 200)}`); - const parsed = parseChannelNest(channelNest); - if (!parsed) return; - - const { hostShip, channelName } = parsed; - - // Handle both top-level posts and thread replies - // Top-level: response.post.r-post.set.essay - // Thread reply: response.post.r-post.reply.r-reply.set.memo - const essay = update?.response?.post?.["r-post"]?.set?.essay; - const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo; - - if (!essay && !memo) { - runtime.log?.(`[tlon] Group update has neither essay nor memo`); - return; - } - - // Use memo for thread replies, essay for top-level posts - const content = memo || essay; - const isThreadReply = !!memo; - - // For thread replies, use the reply ID, not the parent post ID - const messageId = isThreadReply - ? update.response.post["r-post"]?.reply?.id - : update.response.post.id; - - if (processedMessages.has(messageId)) { - runtime.log?.(`[tlon] Skipping duplicate message ${messageId}`); - return; - } - processedMessages.add(messageId); - - const senderShip = content.author?.startsWith("~") - ? content.author - : `~${content.author}`; - - // Don't respond to our own messages - if (senderShip === botShipName) return; - - const messageText = extractMessageText(content.content); - if (!messageText) return; - - // Cache this message for history/summarization - cacheMessage(channelNest, { - author: senderShip, - content: messageText, - timestamp: content.sent || Date.now(), - id: messageId, - }); - - // Check if bot is mentioned - const mentioned = isBotMentioned(messageText, botShipName); - - runtime.log?.( - `[tlon] Received group message in ${channelNest} from ${senderShip}: "${messageText.slice(0, 50)}..." (mentioned: ${mentioned})` - ); - - // Only process if bot is mentioned - if (!mentioned) return; - - // Check channel authorization - const tlonConfig = opts.cfg?.channels?.tlon; - const authorization = tlonConfig?.authorization || {}; - const channelRules = authorization.channelRules || {}; - const defaultAuthorizedShips = tlonConfig?.defaultAuthorizedShips || ["~malmur-halmex"]; - - // Get channel rule or use default (restricted) - const channelRule = channelRules[channelNest]; - const mode = channelRule?.mode || "restricted"; // Default to restricted - const allowedShips = channelRule?.allowedShips || defaultAuthorizedShips; - - // Normalize sender ship (ensure it has ~) - const normalizedSender = senderShip.startsWith("~") ? senderShip : `~${senderShip}`; - - // Check authorization for restricted channels - if (mode === "restricted") { - const isAuthorized = allowedShips.some(ship => { - const normalizedAllowed = ship.startsWith("~") ? ship : `~${ship}`; - return normalizedAllowed === normalizedSender; - }); - - if (!isAuthorized) { - runtime.log?.( - `[tlon] ⛔ Access denied: ${normalizedSender} in ${channelNest} (restricted, allowed: ${allowedShips.join(", ")})` - ); - return; - } - - runtime.log?.( - `[tlon] ✅ Access granted: ${normalizedSender} in ${channelNest} (authorized user)` - ); - } else { - runtime.log?.( - `[tlon] ✅ Access granted: ${normalizedSender} in ${channelNest} (open channel)` - ); - } - - // Extract seal data for thread support - // For thread replies, seal is in a different location - const seal = isThreadReply - ? update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal - : update?.response?.post?.["r-post"]?.set?.seal; - - // For thread replies, all messages in the thread share the same parent-id - // We reply to the parent-id to keep our message in the same thread - const parentId = seal?.["parent-id"] || seal?.parent || null; - const postType = update?.response?.post?.["r-post"]?.set?.type; - - runtime.log?.( - `[tlon] Message type: ${isThreadReply ? "thread reply" : "top-level post"}, parentId: ${parentId}, messageId: ${seal?.id}` - ); - - await processMessage({ - messageId, - senderShip, - messageText, - isGroup: true, - groupChannel: channelNest, - groupName: `${hostShip}/${channelName}`, - timestamp: content.sent || Date.now(), - parentId, // Reply to parent-id to stay in the thread - postType, - seal, - }); - } catch (error) { - runtime.error?.( - `[tlon] Error handling group message in ${channelNest}: ${error.message}` - ); - } - }; - - // Load core channel deps - const deps = await loadCoreChannelDeps(); - - /** - * Process a message and generate AI response - */ - const processMessage = async (params) => { - let { - messageId, - senderShip, - messageText, - isGroup, - groupChannel, - groupName, - timestamp, - parentId, // Parent post ID to reply to (for threading) - postType, - seal, - } = params; - - runtime.log?.(`[tlon] processMessage called for ${senderShip}, isGroup: ${isGroup}, message: "${messageText.substring(0, 50)}"`); - - // Check if this is a summarization request - if (isGroup && isSummarizationRequest(messageText)) { - runtime.log?.(`[tlon] Detected summarization request in ${groupChannel}`); - try { - const history = await getChannelHistory(api, groupChannel, 50, runtime); - if (history.length === 0) { - const noHistoryMsg = "I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue."; - if (isGroup) { - const parsed = parseChannelNest(groupChannel); - if (parsed) { - await sendGroupMessage( - api, - botShipName, - parsed.hostShip, - parsed.channelName, - noHistoryMsg, - null, - runtime - ); - } - } else { - await sendDm(api, botShipName, senderShip, noHistoryMsg); - } - return; - } - - // Format history for AI - const historyText = history - .map(msg => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`) - .join("\n"); - - const summaryPrompt = `Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\nProvide a concise summary highlighting:\n1. Main topics discussed\n2. Key decisions or conclusions\n3. Action items if any\n4. Notable participants`; - - // Override message text with summary prompt - messageText = summaryPrompt; - runtime.log?.(`[tlon] Generating summary for ${history.length} messages`); - } catch (error) { - runtime.error?.(`[tlon] Error generating summary: ${error.message}`); - const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error.message}`; - if (isGroup) { - const parsed = parseChannelNest(groupChannel); - if (parsed) { - await sendGroupMessage( - api, - botShipName, - parsed.hostShip, - parsed.channelName, - errorMsg, - null, - runtime - ); - } - } else { - await sendDm(api, botShipName, senderShip, errorMsg); - } - return; - } - } - - // Check if this is a notebook command - const notebookCommand = parseNotebookCommand(messageText); - if (notebookCommand) { - runtime.log?.(`[tlon] Detected notebook command: ${notebookCommand.type}`); - - // Check if notebookChannel is configured - const notebookChannel = account.notebookChannel; - if (!notebookChannel) { - const errorMsg = "Notebook feature is not configured. Please add a 'notebookChannel' to your Tlon account config (e.g., diary/~malmur-halmex/v2u22f1d)."; - if (isGroup) { - const parsed = parseChannelNest(groupChannel); - if (parsed) { - await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, errorMsg, parentId, runtime); - } - } else { - await sendDm(api, botShipName, senderShip, errorMsg); - } - return; - } - - // Handle save command - if (notebookCommand.type === "save_to_notebook") { - try { - let noteContent = null; - let noteTitle = notebookCommand.title; - - // If replying to a message (thread), save the parent message - if (parentId) { - runtime.log?.(`[tlon] Fetching parent message ${parentId} to save`); - - // For DMs, use messageCache directly since DM history scry isn't available - if (!isGroup) { - const dmCacheKey = `dm/${senderShip}`; - const cache = messageCache.get(dmCacheKey) || []; - const parentMsg = cache.find(msg => msg.id === parentId || msg.id.includes(parentId)); - - if (parentMsg) { - noteContent = parentMsg.content; - if (!noteTitle) { - // Generate title from first line or first 60 chars of content - const firstLine = noteContent.split('\n')[0]; - noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine; - } - } else { - noteContent = "Could not find parent message in cache"; - noteTitle = noteTitle || "Note"; - } - } else { - const history = await getChannelHistory(api, groupChannel, 50, runtime); - const parentMsg = history.find(msg => msg.id === parentId || msg.id.includes(parentId)); - - if (parentMsg) { - noteContent = parentMsg.content; - if (!noteTitle) { - // Generate title from first line or first 60 chars of content - const firstLine = noteContent.split('\n')[0]; - noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine; - } - } else { - noteContent = "Could not find parent message"; - noteTitle = noteTitle || "Note"; - } - } - } else { - // No parent - fetch last bot message - if (!isGroup) { - const dmCacheKey = `dm/${senderShip}`; - const cache = messageCache.get(dmCacheKey) || []; - const lastBotMsg = cache.find(msg => msg.author === botShipName); - - if (lastBotMsg) { - noteContent = lastBotMsg.content; - if (!noteTitle) { - // Generate title from first line or first 60 chars of content - const firstLine = noteContent.split('\n')[0]; - noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine; - } - } else { - noteContent = "No recent bot message found in cache"; - noteTitle = noteTitle || "Note"; - } - } else { - const history = await getChannelHistory(api, groupChannel, 10, runtime); - const lastBotMsg = history.find(msg => msg.author === botShipName); - - if (lastBotMsg) { - noteContent = lastBotMsg.content; - if (!noteTitle) { - // Generate title from first line or first 60 chars of content - const firstLine = noteContent.split('\n')[0]; - noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine; - } - } else { - noteContent = "No recent bot message found"; - noteTitle = noteTitle || "Note"; - } - } - } - - const { essayId, sentAt } = await sendDiaryPost( - api, - account, - notebookChannel, - noteTitle, - noteContent - ); - - const successMsg = `✓ Saved to notebook as "${noteTitle}"`; - runtime.log?.(`[tlon] Saved note ${essayId} to ${notebookChannel}`); - - if (isGroup) { - const parsed = parseChannelNest(groupChannel); - if (parsed) { - await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, successMsg, parentId, runtime); - } - } else { - await sendDm(api, botShipName, senderShip, successMsg); - } - } catch (error) { - runtime.error?.(`[tlon] Error saving to notebook: ${error.message}`); - const errorMsg = `Failed to save to notebook: ${error.message}`; - if (isGroup) { - const parsed = parseChannelNest(groupChannel); - if (parsed) { - await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, errorMsg, parentId, runtime); - } - } else { - await sendDm(api, botShipName, senderShip, errorMsg); - } - } - return; - } - - // Handle list command (placeholder for now) - if (notebookCommand.type === "list_notebook") { - const placeholderMsg = "List notebook handler not yet implemented."; - if (isGroup) { - const parsed = parseChannelNest(groupChannel); - if (parsed) { - await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, placeholderMsg, parentId, runtime); - } - } else { - await sendDm(api, botShipName, senderShip, placeholderMsg); - } - return; - } - - return; // Don't send to AI for notebook commands - } - - try { - // Resolve agent route - const route = deps.resolveAgentRoute({ - cfg: opts.cfg, - channel: "tlon", - accountId: opts.accountId, - peer: { - kind: isGroup ? "group" : "dm", - id: isGroup ? groupChannel : senderShip, - }, - }); - - // Format message for AI - const fromLabel = isGroup - ? `${senderShip} in ${groupName}` - : senderShip; - - // Add Tlon identity context to help AI recognize when it's being addressed - // The AI knows itself as "bearclawd" but in Tlon it's addressed as the ship name - const identityNote = `[Note: In Tlon/Urbit, you are known as ${botShipName}. When users mention ${botShipName}, they are addressing you directly.]\n\n`; - const messageWithIdentity = identityNote + messageText; - - const body = deps.formatAgentEnvelope({ - channel: "Tlon", - from: fromLabel, - timestamp, - body: messageWithIdentity, - }); - - // Create inbound context - // For thread replies, append parent ID to session key to create separate conversation context - const sessionKeySuffix = parentId ? `:thread:${parentId}` : ''; - const finalSessionKey = `${route.sessionKey}${sessionKeySuffix}`; - - runtime.log?.( - `[tlon] 🔑 Session key construction: base="${route.sessionKey}", suffix="${sessionKeySuffix}", final="${finalSessionKey}"` - ); - - const ctxPayload = deps.finalizeInboundContext({ - Body: body, - RawBody: messageText, - CommandBody: messageText, - From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`, - To: `tlon:${botShipName}`, - SessionKey: finalSessionKey, - AccountId: route.accountId, - ChatType: isGroup ? "group" : "direct", - ConversationLabel: fromLabel, - SenderName: senderShip, - SenderId: senderShip, - Provider: "tlon", - Surface: "tlon", - MessageSid: messageId, - OriginatingChannel: "tlon", - OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`, - }); - - runtime.log?.( - `[tlon] 📋 Context payload keys: ${Object.keys(ctxPayload).join(', ')}` - ); - runtime.log?.( - `[tlon] 📋 Message body: "${body.substring(0, 100)}${body.length > 100 ? '...' : ''}"` - ); - - // Log transcript details - if (ctxPayload.Transcript && ctxPayload.Transcript.length > 0) { - runtime.log?.( - `[tlon] 📜 Transcript has ${ctxPayload.Transcript.length} message(s)` - ); - // Log last few messages for debugging - const recentMessages = ctxPayload.Transcript.slice(-3); - recentMessages.forEach((msg, idx) => { - runtime.log?.( - `[tlon] 📜 Transcript[-${3-idx}]: role=${msg.role}, content length=${JSON.stringify(msg.content).length}` - ); - }); - } else { - runtime.log?.( - `[tlon] 📜 Transcript is empty or missing` - ); - } - - // Log key fields that affect AI behavior - runtime.log?.( - `[tlon] 📝 BodyForAgent: "${ctxPayload.BodyForAgent?.substring(0, 100)}${(ctxPayload.BodyForAgent?.length || 0) > 100 ? '...' : ''}"` - ); - runtime.log?.( - `[tlon] 📝 ThreadStarterBody: "${ctxPayload.ThreadStarterBody?.substring(0, 100) || 'null'}${(ctxPayload.ThreadStarterBody?.length || 0) > 100 ? '...' : ''}"` - ); - runtime.log?.( - `[tlon] 📝 CommandAuthorized: ${ctxPayload.CommandAuthorized}` - ); - - // Dispatch to AI and get response - const dispatchStartTime = Date.now(); - runtime.log?.( - `[tlon] Dispatching to AI for ${senderShip} (${isGroup ? `group: ${groupName}` : 'DM'})` - ); - runtime.log?.( - `[tlon] 🚀 Dispatch details: sessionKey="${finalSessionKey}", isThreadReply=${!!parentId}, messageText="${messageText.substring(0, 50)}..."` - ); - - const dispatchResult = await deps.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: opts.cfg, - dispatcherOptions: { - deliver: async (payload) => { - runtime.log?.(`[tlon] 🎯 Deliver callback invoked! isThreadReply=${!!parentId}, parentId=${parentId}`); - const dispatchDuration = Date.now() - dispatchStartTime; - runtime.log?.(`[tlon] 📦 Payload keys: ${Object.keys(payload).join(', ')}, text length: ${payload.text?.length || 0}`); - let replyText = payload.text; - - if (!replyText) { - runtime.log?.(`[tlon] No reply text in AI response (took ${dispatchDuration}ms)`); - return; - } - - // Add model signature if enabled - const tlonConfig = opts.cfg?.channels?.tlon; - const showSignature = tlonConfig?.showModelSignature ?? false; - runtime.log?.(`[tlon] showModelSignature config: ${showSignature} (from cfg.channels.tlon)`); - runtime.log?.(`[tlon] Full payload keys: ${Object.keys(payload).join(', ')}`); - runtime.log?.(`[tlon] Full route keys: ${Object.keys(route).join(', ')}`); - runtime.log?.(`[tlon] opts.cfg.agents: ${JSON.stringify(opts.cfg?.agents?.defaults?.model)}`); - if (showSignature) { - const modelInfo = payload.metadata?.model || payload.model || route.model || opts.cfg?.agents?.defaults?.model?.primary; - runtime.log?.(`[tlon] Model info: ${JSON.stringify({ - payloadMetadataModel: payload.metadata?.model, - payloadModel: payload.model, - routeModel: route.model, - cfgModel: opts.cfg?.agents?.defaults?.model?.primary, - resolved: modelInfo - })}`); - if (modelInfo) { - const modelName = formatModelName(modelInfo); - runtime.log?.(`[tlon] Adding signature: ${modelName}`); - replyText = `${replyText}\n\n_[Generated by ${modelName}]_`; - } else { - runtime.log?.(`[tlon] No model info found, using fallback`); - replyText = `${replyText}\n\n_[Generated by AI]_`; - } - } - - runtime.log?.( - `[tlon] AI response received (took ${dispatchDuration}ms), sending to Tlon...` - ); - - // Debug delivery path - runtime.log?.(`[tlon] 🔍 Delivery debug: isGroup=${isGroup}, groupChannel=${groupChannel}, senderShip=${senderShip}, parentId=${parentId}`); - - // Send reply back to Tlon - if (isGroup) { - const parsed = parseChannelNest(groupChannel); - runtime.log?.(`[tlon] 🔍 Parsed channel nest: ${JSON.stringify(parsed)}`); - if (parsed) { - // Reply in thread if this message is part of a thread - if (parentId) { - runtime.log?.(`[tlon] Replying in thread (parent: ${parentId})`); - } - await sendGroupMessage( - api, - botShipName, - parsed.hostShip, - parsed.channelName, - replyText, - parentId, // Pass parentId to reply in the thread - runtime - ); - const threadInfo = parentId ? ` (in thread)` : ''; - runtime.log?.(`[tlon] Delivered AI reply to group ${groupName}${threadInfo}`); - } else { - runtime.log?.(`[tlon] ⚠️ Failed to parse channel nest: ${groupChannel}`); - } - } else { - await sendDm(api, botShipName, senderShip, replyText); - runtime.log?.(`[tlon] Delivered AI reply to ${senderShip}`); - } - }, - onError: (err, info) => { - const dispatchDuration = Date.now() - dispatchStartTime; - runtime.error?.( - `[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}` - ); - runtime.error?.(`[tlon] Error type: ${err?.constructor?.name || 'Unknown'}`); - runtime.error?.(`[tlon] Error details: ${JSON.stringify(info, null, 2)}`); - if (err?.stack) { - runtime.error?.(`[tlon] Stack trace: ${err.stack}`); - } - }, - }, - }); - - const totalDuration = Date.now() - dispatchStartTime; - runtime.log?.( - `[tlon] AI dispatch completed for ${senderShip} (total: ${totalDuration}ms), result keys: ${dispatchResult ? Object.keys(dispatchResult).join(', ') : 'null'}` - ); - runtime.log?.(`[tlon] Dispatch result: ${JSON.stringify(dispatchResult)}`); - } catch (error) { - runtime.error?.(`[tlon] Error processing message: ${error.message}`); - runtime.error?.(`[tlon] Stack trace: ${error.stack}`); - } - }; - - // Track currently subscribed channels for dynamic updates - const subscribedChannels = new Set(); // Start empty, add after successful subscription - const subscribedDMs = new Set(); - - /** - * Subscribe to a group channel - */ - async function subscribeToChannel(channelNest) { - if (subscribedChannels.has(channelNest)) { - return; // Already subscribed - } - - const parsed = parseChannelNest(channelNest); - if (!parsed) { - runtime.error?.( - `[tlon] Invalid channel format: ${channelNest} (expected: chat/~host-ship/channel-name)` - ); - return; - } - - try { - await api.subscribe({ - app: "channels", - path: `/${channelNest}`, - event: handleIncomingGroupMessage(channelNest), - err: (error) => { - runtime.error?.( - `[tlon] Group subscription error for ${channelNest}: ${error}` - ); - }, - quit: () => { - runtime.log?.(`[tlon] Group subscription ended for ${channelNest}`); - subscribedChannels.delete(channelNest); - }, - }); - subscribedChannels.add(channelNest); - runtime.log?.(`[tlon] Subscribed to group channel: ${channelNest}`); - } catch (error) { - runtime.error?.(`[tlon] Failed to subscribe to ${channelNest}: ${error.message}`); - } - } - - /** - * Subscribe to a DM conversation - */ - async function subscribeToDM(dmShip) { - if (subscribedDMs.has(dmShip)) { - return; // Already subscribed - } - - try { - await api.subscribe({ - app: "chat", - path: `/dm/${dmShip}`, - event: handleIncomingDM, - err: (error) => { - runtime.error?.(`[tlon] DM subscription error for ${dmShip}: ${error}`); - }, - quit: () => { - runtime.log?.(`[tlon] DM subscription ended for ${dmShip}`); - subscribedDMs.delete(dmShip); - }, - }); - subscribedDMs.add(dmShip); - runtime.log?.(`[tlon] Subscribed to DM with ${dmShip}`); - } catch (error) { - runtime.error?.(`[tlon] Failed to subscribe to DM with ${dmShip}: ${error.message}`); - } - } - - /** - * Discover and subscribe to new channels - */ - async function refreshChannelSubscriptions() { - try { - // Check for new DMs - const dmShips = await api.scry("/chat/dm.json"); - for (const dmShip of dmShips) { - await subscribeToDM(dmShip); - } - - // Check for new group channels (if auto-discovery is enabled) - if (account.autoDiscoverChannels !== false) { - const discoveredChannels = await fetchAllChannels(api, runtime); - - // Find truly new channels (not already subscribed) - const newChannels = discoveredChannels.filter(c => !subscribedChannels.has(c)); - - if (newChannels.length > 0) { - runtime.log?.(`[tlon] 🆕 Discovered ${newChannels.length} new channel(s):`); - newChannels.forEach(c => runtime.log?.(`[tlon] - ${c}`)); - } - - // Subscribe to all discovered channels (including new ones) - for (const channelNest of discoveredChannels) { - await subscribeToChannel(channelNest); - } - } - } catch (error) { - runtime.error?.(`[tlon] Channel refresh failed: ${error.message}`); - } - } - - // Subscribe to incoming messages - try { - runtime.log?.(`[tlon] Subscribing to updates...`); - - // Get list of DM ships and subscribe to each one - let dmShips = []; - try { - dmShips = await api.scry("/chat/dm.json"); - runtime.log?.(`[tlon] Found ${dmShips.length} DM conversation(s)`); - } catch (error) { - runtime.error?.(`[tlon] Failed to fetch DM list: ${error.message}`); - } - - // Subscribe to each DM individually - for (const dmShip of dmShips) { - await subscribeToDM(dmShip); - } - - // Subscribe to each group channel - for (const channelNest of groupChannels) { - await subscribeToChannel(channelNest); - } - - runtime.log?.(`[tlon] All subscriptions registered, connecting to SSE stream...`); - - // Connect to Urbit and start the SSE stream - await api.connect(); - - runtime.log?.(`[tlon] Connected! All subscriptions active`); - - // Start dynamic channel discovery (poll every 2 minutes) - const POLL_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes - const pollInterval = setInterval(() => { - if (!opts.abortSignal?.aborted) { - runtime.log?.(`[tlon] Checking for new channels...`); - refreshChannelSubscriptions().catch((error) => { - runtime.error?.(`[tlon] Channel refresh error: ${error.message}`); - }); - } - }, POLL_INTERVAL_MS); - - runtime.log?.(`[tlon] Dynamic channel discovery enabled (checking every 2 minutes)`); - - // Keep the monitor running until aborted - if (opts.abortSignal) { - await new Promise((resolve) => { - opts.abortSignal.addEventListener("abort", () => { - clearInterval(pollInterval); - resolve(); - }, { - once: true, - }); - }); - } else { - // If no abort signal, wait indefinitely - await new Promise(() => {}); - } - } catch (error) { - if (opts.abortSignal?.aborted) { - runtime.log?.(`[tlon] Monitor stopped`); - return; - } - throw error; - } finally { - // Cleanup - try { - await api.close(); - } catch (e) { - runtime.error?.(`[tlon] Cleanup error: ${e.message}`); - } - } -} diff --git a/extensions/tlon/src/monitor/discovery.ts b/extensions/tlon/src/monitor/discovery.ts new file mode 100644 index 000000000..05bab008b --- /dev/null +++ b/extensions/tlon/src/monitor/discovery.ts @@ -0,0 +1,71 @@ +import type { RuntimeEnv } from "clawdbot/plugin-sdk"; + +import { formatChangesDate } from "./utils.js"; + +export async function fetchGroupChanges( + api: { scry: (path: string) => Promise }, + runtime: RuntimeEnv, + daysAgo = 5, +) { + try { + const changeDate = formatChangesDate(daysAgo); + runtime.log?.(`[tlon] Fetching group changes since ${daysAgo} days ago (${changeDate})...`); + const changes = await api.scry(`/groups-ui/v5/changes/${changeDate}.json`); + if (changes) { + runtime.log?.("[tlon] Successfully fetched changes data"); + return changes; + } + return null; + } catch (error: any) { + runtime.log?.(`[tlon] Failed to fetch changes (falling back to full init): ${error?.message ?? String(error)}`); + return null; + } +} + +export async function fetchAllChannels( + api: { scry: (path: string) => Promise }, + runtime: RuntimeEnv, +): Promise { + try { + runtime.log?.("[tlon] Attempting auto-discovery of group channels..."); + const changes = await fetchGroupChanges(api, runtime, 5); + + let initData: any; + if (changes) { + runtime.log?.("[tlon] Changes data received, using full init for channel extraction"); + initData = await api.scry("/groups-ui/v6/init.json"); + } else { + initData = await api.scry("/groups-ui/v6/init.json"); + } + + const channels: string[] = []; + if (initData && initData.groups) { + for (const groupData of Object.values(initData.groups as Record)) { + if (groupData && typeof groupData === "object" && groupData.channels) { + for (const channelNest of Object.keys(groupData.channels)) { + if (channelNest.startsWith("chat/")) { + channels.push(channelNest); + } + } + } + } + } + + if (channels.length > 0) { + runtime.log?.(`[tlon] Auto-discovered ${channels.length} chat channel(s)`); + runtime.log?.( + `[tlon] Channels: ${channels.slice(0, 5).join(", ")}${channels.length > 5 ? "..." : ""}`, + ); + } else { + runtime.log?.("[tlon] No chat channels found via auto-discovery"); + runtime.log?.("[tlon] Add channels manually to config: channels.tlon.groupChannels"); + } + + return channels; + } catch (error: any) { + runtime.log?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`); + runtime.log?.("[tlon] To monitor group channels, add them to config: channels.tlon.groupChannels"); + runtime.log?.("[tlon] Example: [\"chat/~host-ship/channel-name\"]"); + return []; + } +} diff --git a/extensions/tlon/src/monitor/history.ts b/extensions/tlon/src/monitor/history.ts new file mode 100644 index 000000000..137d46d6c --- /dev/null +++ b/extensions/tlon/src/monitor/history.ts @@ -0,0 +1,87 @@ +import type { RuntimeEnv } from "clawdbot/plugin-sdk"; + +import { extractMessageText } from "./utils.js"; + +export type TlonHistoryEntry = { + author: string; + content: string; + timestamp: number; + id?: string; +}; + +const messageCache = new Map(); +const MAX_CACHED_MESSAGES = 100; + +export function cacheMessage(channelNest: string, message: TlonHistoryEntry) { + if (!messageCache.has(channelNest)) { + messageCache.set(channelNest, []); + } + const cache = messageCache.get(channelNest); + if (!cache) return; + cache.unshift(message); + if (cache.length > MAX_CACHED_MESSAGES) { + cache.pop(); + } +} + +export async function fetchChannelHistory( + api: { scry: (path: string) => Promise }, + channelNest: string, + count = 50, + runtime?: RuntimeEnv, +): Promise { + try { + const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`; + runtime?.log?.(`[tlon] Fetching history: ${scryPath}`); + + const data: any = await api.scry(scryPath); + if (!data) return []; + + let posts: any[] = []; + if (Array.isArray(data)) { + posts = data; + } else if (data.posts && typeof data.posts === "object") { + posts = Object.values(data.posts); + } else if (typeof data === "object") { + posts = Object.values(data); + } + + const messages = posts + .map((item) => { + const essay = item.essay || item["r-post"]?.set?.essay; + const seal = item.seal || item["r-post"]?.set?.seal; + + return { + author: essay?.author || "unknown", + content: extractMessageText(essay?.content || []), + timestamp: essay?.sent || Date.now(), + id: seal?.id, + } as TlonHistoryEntry; + }) + .filter((msg) => msg.content); + + runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`); + return messages; + } catch (error: any) { + runtime?.log?.(`[tlon] Error fetching channel history: ${error?.message ?? String(error)}`); + return []; + } +} + +export async function getChannelHistory( + api: { scry: (path: string) => Promise }, + channelNest: string, + count = 50, + runtime?: RuntimeEnv, +): Promise { + const cache = messageCache.get(channelNest) ?? []; + if (cache.length >= count) { + runtime?.log?.(`[tlon] Using cached messages (${cache.length} available)`); + return cache.slice(0, count); + } + + runtime?.log?.( + `[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`, + ); + return await fetchChannelHistory(api, channelNest, count, runtime); +} diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts new file mode 100644 index 000000000..26ea1407d --- /dev/null +++ b/extensions/tlon/src/monitor/index.ts @@ -0,0 +1,501 @@ +import { format } from "node:util"; + +import type { RuntimeEnv, ReplyPayload, ClawdbotConfig } from "clawdbot/plugin-sdk"; + +import { getTlonRuntime } from "../runtime.js"; +import { resolveTlonAccount } from "../types.js"; +import { normalizeShip, parseChannelNest } from "../targets.js"; +import { authenticate } from "../urbit/auth.js"; +import { UrbitSSEClient } from "../urbit/sse-client.js"; +import { sendDm, sendGroupMessage } from "../urbit/send.js"; +import { cacheMessage, getChannelHistory } from "./history.js"; +import { createProcessedMessageTracker } from "./processed-messages.js"; +import { + extractMessageText, + formatModelName, + isBotMentioned, + isDmAllowed, + isSummarizationRequest, +} from "./utils.js"; +import { fetchAllChannels } from "./discovery.js"; + +export type MonitorTlonOpts = { + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + accountId?: string | null; +}; + +type ChannelAuthorization = { + mode?: "restricted" | "open"; + allowedShips?: string[]; +}; + +function resolveChannelAuthorization( + cfg: ClawdbotConfig, + channelNest: string, +): { mode: "restricted" | "open"; allowedShips: string[] } { + const tlonConfig = cfg.channels?.tlon as + | { + authorization?: { channelRules?: Record }; + defaultAuthorizedShips?: string[]; + } + | undefined; + const rules = tlonConfig?.authorization?.channelRules ?? {}; + const rule = rules[channelNest]; + const allowedShips = rule?.allowedShips ?? tlonConfig?.defaultAuthorizedShips ?? []; + const mode = rule?.mode ?? "restricted"; + return { mode, allowedShips }; +} + +export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { + const core = getTlonRuntime(); + const cfg = core.config.loadConfig() as ClawdbotConfig; + if (cfg.channels?.tlon?.enabled === false) return; + + const logger = core.logging.getChildLogger({ module: "tlon-auto-reply" }); + const formatRuntimeMessage = (...args: Parameters) => format(...args); + const runtime: RuntimeEnv = opts.runtime ?? { + log: (...args) => { + logger.info(formatRuntimeMessage(...args)); + }, + error: (...args) => { + logger.error(formatRuntimeMessage(...args)); + }, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; + + const account = resolveTlonAccount(cfg, opts.accountId ?? undefined); + if (!account.enabled) return; + if (!account.configured || !account.ship || !account.url || !account.code) { + throw new Error("Tlon account not configured (ship/url/code required)"); + } + + const botShipName = normalizeShip(account.ship); + runtime.log?.(`[tlon] Starting monitor for ${botShipName}`); + + let api: UrbitSSEClient | null = null; + try { + runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`); + const cookie = await authenticate(account.url, account.code); + api = new UrbitSSEClient(account.url, cookie, { + ship: botShipName, + logger: { + log: (message) => runtime.log?.(message), + error: (message) => runtime.error?.(message), + }, + }); + } catch (error: any) { + runtime.error?.(`[tlon] Failed to authenticate: ${error?.message ?? String(error)}`); + throw error; + } + + const processedTracker = createProcessedMessageTracker(2000); + let groupChannels: string[] = []; + + if (account.autoDiscoverChannels !== false) { + try { + const discoveredChannels = await fetchAllChannels(api, runtime); + if (discoveredChannels.length > 0) { + groupChannels = discoveredChannels; + } + } catch (error: any) { + runtime.error?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`); + } + } + + if (groupChannels.length === 0 && account.groupChannels.length > 0) { + groupChannels = account.groupChannels; + runtime.log?.(`[tlon] Using manual groupChannels config: ${groupChannels.join(", ")}`); + } + + if (groupChannels.length > 0) { + runtime.log?.( + `[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}`, + ); + } else { + runtime.log?.("[tlon] No group channels to monitor (DMs only)"); + } + + const handleIncomingDM = async (update: any) => { + try { + const memo = update?.response?.add?.memo; + if (!memo) return; + + const messageId = update.id as string | undefined; + if (!processedTracker.mark(messageId)) return; + + const senderShip = normalizeShip(memo.author ?? ""); + if (!senderShip || senderShip === botShipName) return; + + const messageText = extractMessageText(memo.content); + if (!messageText) return; + + if (!isDmAllowed(senderShip, account.dmAllowlist)) { + runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`); + return; + } + + await processMessage({ + messageId: messageId ?? "", + senderShip, + messageText, + isGroup: false, + timestamp: memo.sent || Date.now(), + }); + } catch (error: any) { + runtime.error?.(`[tlon] Error handling DM: ${error?.message ?? String(error)}`); + } + }; + + const handleIncomingGroupMessage = (channelNest: string) => async (update: any) => { + try { + const parsed = parseChannelNest(channelNest); + if (!parsed) return; + + const essay = update?.response?.post?.["r-post"]?.set?.essay; + const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo; + if (!essay && !memo) return; + + const content = memo || essay; + const isThreadReply = Boolean(memo); + const messageId = isThreadReply + ? update?.response?.post?.["r-post"]?.reply?.id + : update?.response?.post?.id; + + if (!processedTracker.mark(messageId)) return; + + const senderShip = normalizeShip(content.author ?? ""); + if (!senderShip || senderShip === botShipName) return; + + const messageText = extractMessageText(content.content); + if (!messageText) return; + + cacheMessage(channelNest, { + author: senderShip, + content: messageText, + timestamp: content.sent || Date.now(), + id: messageId, + }); + + const mentioned = isBotMentioned(messageText, botShipName); + if (!mentioned) return; + + const { mode, allowedShips } = resolveChannelAuthorization(cfg, channelNest); + if (mode === "restricted") { + if (allowedShips.length === 0) { + runtime.log?.(`[tlon] Access denied: ${senderShip} in ${channelNest} (no allowlist)`); + return; + } + const normalizedAllowed = allowedShips.map(normalizeShip); + if (!normalizedAllowed.includes(senderShip)) { + runtime.log?.( + `[tlon] Access denied: ${senderShip} in ${channelNest} (allowed: ${allowedShips.join(", ")})`, + ); + return; + } + } + + const seal = isThreadReply + ? update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal + : update?.response?.post?.["r-post"]?.set?.seal; + + const parentId = seal?.["parent-id"] || seal?.parent || null; + + await processMessage({ + messageId: messageId ?? "", + senderShip, + messageText, + isGroup: true, + groupChannel: channelNest, + groupName: `${parsed.hostShip}/${parsed.channelName}`, + timestamp: content.sent || Date.now(), + parentId, + }); + } catch (error: any) { + runtime.error?.(`[tlon] Error handling group message: ${error?.message ?? String(error)}`); + } + }; + + const processMessage = async (params: { + messageId: string; + senderShip: string; + messageText: string; + isGroup: boolean; + groupChannel?: string; + groupName?: string; + timestamp: number; + parentId?: string | null; + }) => { + const { messageId, senderShip, isGroup, groupChannel, groupName, timestamp, parentId } = params; + let messageText = params.messageText; + + if (isGroup && groupChannel && isSummarizationRequest(messageText)) { + try { + const history = await getChannelHistory(api!, groupChannel, 50, runtime); + if (history.length === 0) { + const noHistoryMsg = + "I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue."; + if (isGroup) { + const parsed = parseChannelNest(groupChannel); + if (parsed) { + await sendGroupMessage({ + api: api!, + fromShip: botShipName, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + text: noHistoryMsg, + }); + } + } else { + await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: noHistoryMsg }); + } + return; + } + + const historyText = history + .map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`) + .join("\n"); + + messageText = + `Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` + + "Provide a concise summary highlighting:\n" + + "1. Main topics discussed\n" + + "2. Key decisions or conclusions\n" + + "3. Action items if any\n" + + "4. Notable participants"; + } catch (error: any) { + const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error?.message ?? String(error)}`; + if (isGroup && groupChannel) { + const parsed = parseChannelNest(groupChannel); + if (parsed) { + await sendGroupMessage({ + api: api!, + fromShip: botShipName, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + text: errorMsg, + }); + } + } else { + await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: errorMsg }); + } + return; + } + } + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "tlon", + accountId: opts.accountId ?? undefined, + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup ? groupChannel ?? senderShip : senderShip, + }, + }); + + const fromLabel = isGroup ? `${senderShip} in ${groupName}` : senderShip; + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Tlon", + from: fromLabel, + timestamp, + body: messageText, + }); + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: messageText, + CommandBody: messageText, + From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`, + To: `tlon:${botShipName}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: fromLabel, + SenderName: senderShip, + SenderId: senderShip, + Provider: "tlon", + Surface: "tlon", + MessageSid: messageId, + OriginatingChannel: "tlon", + OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`, + }); + + const dispatchStartTime = Date.now(); + + const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix; + const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId); + + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + responsePrefix, + humanDelay, + deliver: async (payload: ReplyPayload) => { + let replyText = payload.text; + if (!replyText) return; + + const showSignature = account.showModelSignature ?? cfg.channels?.tlon?.showModelSignature ?? false; + if (showSignature) { + const modelInfo = + payload.metadata?.model || payload.model || route.model || cfg.agents?.defaults?.model?.primary; + replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`; + } + + if (isGroup && groupChannel) { + const parsed = parseChannelNest(groupChannel); + if (!parsed) return; + await sendGroupMessage({ + api: api!, + fromShip: botShipName, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + text: replyText, + replyToId: parentId ?? undefined, + }); + } else { + await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: replyText }); + } + }, + onError: (err, info) => { + const dispatchDuration = Date.now() - dispatchStartTime; + runtime.error?.( + `[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`, + ); + }, + }, + }); + }; + + const subscribedChannels = new Set(); + const subscribedDMs = new Set(); + + async function subscribeToChannel(channelNest: string) { + if (subscribedChannels.has(channelNest)) return; + const parsed = parseChannelNest(channelNest); + if (!parsed) { + runtime.error?.(`[tlon] Invalid channel format: ${channelNest}`); + return; + } + + try { + await api!.subscribe({ + app: "channels", + path: `/${channelNest}`, + event: handleIncomingGroupMessage(channelNest), + err: (error) => { + runtime.error?.(`[tlon] Group subscription error for ${channelNest}: ${String(error)}`); + }, + quit: () => { + runtime.log?.(`[tlon] Group subscription ended for ${channelNest}`); + subscribedChannels.delete(channelNest); + }, + }); + subscribedChannels.add(channelNest); + runtime.log?.(`[tlon] Subscribed to group channel: ${channelNest}`); + } catch (error: any) { + runtime.error?.(`[tlon] Failed to subscribe to ${channelNest}: ${error?.message ?? String(error)}`); + } + } + + async function subscribeToDM(dmShip: string) { + if (subscribedDMs.has(dmShip)) return; + try { + await api!.subscribe({ + app: "chat", + path: `/dm/${dmShip}`, + event: handleIncomingDM, + err: (error) => { + runtime.error?.(`[tlon] DM subscription error for ${dmShip}: ${String(error)}`); + }, + quit: () => { + runtime.log?.(`[tlon] DM subscription ended for ${dmShip}`); + subscribedDMs.delete(dmShip); + }, + }); + subscribedDMs.add(dmShip); + runtime.log?.(`[tlon] Subscribed to DM with ${dmShip}`); + } catch (error: any) { + runtime.error?.(`[tlon] Failed to subscribe to DM with ${dmShip}: ${error?.message ?? String(error)}`); + } + } + + async function refreshChannelSubscriptions() { + try { + const dmShips = await api!.scry("/chat/dm.json"); + if (Array.isArray(dmShips)) { + for (const dmShip of dmShips) { + await subscribeToDM(dmShip); + } + } + + if (account.autoDiscoverChannels !== false) { + const discoveredChannels = await fetchAllChannels(api!, runtime); + for (const channelNest of discoveredChannels) { + await subscribeToChannel(channelNest); + } + } + } catch (error: any) { + runtime.error?.(`[tlon] Channel refresh failed: ${error?.message ?? String(error)}`); + } + } + + try { + runtime.log?.("[tlon] Subscribing to updates..."); + + let dmShips: string[] = []; + try { + const dmList = await api!.scry("/chat/dm.json"); + if (Array.isArray(dmList)) { + dmShips = dmList; + runtime.log?.(`[tlon] Found ${dmShips.length} DM conversation(s)`); + } + } catch (error: any) { + runtime.error?.(`[tlon] Failed to fetch DM list: ${error?.message ?? String(error)}`); + } + + for (const dmShip of dmShips) { + await subscribeToDM(dmShip); + } + + for (const channelNest of groupChannels) { + await subscribeToChannel(channelNest); + } + + runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream..."); + await api!.connect(); + runtime.log?.("[tlon] Connected! All subscriptions active"); + + const pollInterval = setInterval(() => { + if (!opts.abortSignal?.aborted) { + refreshChannelSubscriptions().catch((error) => { + runtime.error?.(`[tlon] Channel refresh error: ${error?.message ?? String(error)}`); + }); + } + }, 2 * 60 * 1000); + + if (opts.abortSignal) { + await new Promise((resolve) => { + opts.abortSignal.addEventListener( + "abort", + () => { + clearInterval(pollInterval); + resolve(null); + }, + { once: true }, + ); + }); + } else { + await new Promise(() => {}); + } + } finally { + try { + await api?.close(); + } catch (error: any) { + runtime.error?.(`[tlon] Cleanup error: ${error?.message ?? String(error)}`); + } + } +} diff --git a/extensions/tlon/src/monitor/processed-messages.test.ts b/extensions/tlon/src/monitor/processed-messages.test.ts new file mode 100644 index 000000000..2dd99fff9 --- /dev/null +++ b/extensions/tlon/src/monitor/processed-messages.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { createProcessedMessageTracker } from "./processed-messages.js"; + +describe("createProcessedMessageTracker", () => { + it("dedupes and evicts oldest entries", () => { + const tracker = createProcessedMessageTracker(3); + + expect(tracker.mark("a")).toBe(true); + expect(tracker.mark("a")).toBe(false); + expect(tracker.has("a")).toBe(true); + + tracker.mark("b"); + tracker.mark("c"); + expect(tracker.size()).toBe(3); + + tracker.mark("d"); + expect(tracker.size()).toBe(3); + expect(tracker.has("a")).toBe(false); + expect(tracker.has("b")).toBe(true); + expect(tracker.has("c")).toBe(true); + expect(tracker.has("d")).toBe(true); + }); +}); diff --git a/extensions/tlon/src/monitor/processed-messages.ts b/extensions/tlon/src/monitor/processed-messages.ts new file mode 100644 index 000000000..83050008c --- /dev/null +++ b/extensions/tlon/src/monitor/processed-messages.ts @@ -0,0 +1,38 @@ +export type ProcessedMessageTracker = { + mark: (id?: string | null) => boolean; + has: (id?: string | null) => boolean; + size: () => number; +}; + +export function createProcessedMessageTracker(limit = 2000): ProcessedMessageTracker { + const seen = new Set(); + const order: string[] = []; + + const mark = (id?: string | null) => { + const trimmed = id?.trim(); + if (!trimmed) return true; + if (seen.has(trimmed)) return false; + seen.add(trimmed); + order.push(trimmed); + if (order.length > limit) { + const overflow = order.length - limit; + for (let i = 0; i < overflow; i += 1) { + const oldest = order.shift(); + if (oldest) seen.delete(oldest); + } + } + return true; + }; + + const has = (id?: string | null) => { + const trimmed = id?.trim(); + if (!trimmed) return false; + return seen.has(trimmed); + }; + + return { + mark, + has, + size: () => seen.size, + }; +} diff --git a/extensions/tlon/src/monitor/utils.ts b/extensions/tlon/src/monitor/utils.ts new file mode 100644 index 000000000..df3ade439 --- /dev/null +++ b/extensions/tlon/src/monitor/utils.ts @@ -0,0 +1,83 @@ +import { normalizeShip } from "../targets.js"; + +export function formatModelName(modelString?: string | null): string { + if (!modelString) return "AI"; + const modelName = modelString.includes("/") ? modelString.split("/")[1] : modelString; + const modelMappings: Record = { + "claude-opus-4-5": "Claude Opus 4.5", + "claude-sonnet-4-5": "Claude Sonnet 4.5", + "claude-sonnet-3-5": "Claude Sonnet 3.5", + "gpt-4o": "GPT-4o", + "gpt-4-turbo": "GPT-4 Turbo", + "gpt-4": "GPT-4", + "gemini-2.0-flash": "Gemini 2.0 Flash", + "gemini-pro": "Gemini Pro", + }; + + if (modelMappings[modelName]) return modelMappings[modelName]; + return modelName + .replace(/-/g, " ") + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +export function isBotMentioned(messageText: string, botShipName: string): boolean { + if (!messageText || !botShipName) return false; + const normalizedBotShip = normalizeShip(botShipName); + const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i"); + return mentionPattern.test(messageText); +} + +export function isDmAllowed(senderShip: string, allowlist: string[] | undefined): boolean { + if (!allowlist || allowlist.length === 0) return true; + const normalizedSender = normalizeShip(senderShip); + return allowlist + .map((ship) => normalizeShip(ship)) + .some((ship) => ship === normalizedSender); +} + +export function extractMessageText(content: unknown): string { + if (!content || !Array.isArray(content)) return ""; + + return content + .map((block: any) => { + if (block.inline && Array.isArray(block.inline)) { + return block.inline + .map((item: any) => { + if (typeof item === "string") return item; + if (item && typeof item === "object") { + if (item.ship) return item.ship; + if (item.break !== undefined) return "\n"; + if (item.link && item.link.href) return item.link.href; + } + return ""; + }) + .join(""); + } + return ""; + }) + .join("\n") + .trim(); +} + +export function isSummarizationRequest(messageText: string): boolean { + const patterns = [ + /summarize\s+(this\s+)?(channel|chat|conversation)/i, + /what\s+did\s+i\s+miss/i, + /catch\s+me\s+up/i, + /channel\s+summary/i, + /tldr/i, + ]; + return patterns.some((pattern) => pattern.test(messageText)); +} + +export function formatChangesDate(daysAgo = 5): string { + const now = new Date(); + const targetDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000); + const year = targetDate.getFullYear(); + const month = targetDate.getMonth() + 1; + const day = targetDate.getDate(); + return `~${year}.${month}.${day}..20.19.51..9b9d`; +} diff --git a/extensions/tlon/src/onboarding.ts b/extensions/tlon/src/onboarding.ts new file mode 100644 index 000000000..803cd5bd3 --- /dev/null +++ b/extensions/tlon/src/onboarding.ts @@ -0,0 +1,213 @@ +import { + formatDocsLink, + promptAccountId, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + type ChannelOnboardingAdapter, + type WizardPrompter, +} from "clawdbot/plugin-sdk"; + +import { listTlonAccountIds, resolveTlonAccount } from "./types.js"; +import type { TlonResolvedAccount } from "./types.js"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; + +const channel = "tlon" as const; + +function isConfigured(account: TlonResolvedAccount): boolean { + return Boolean(account.ship && account.url && account.code); +} + +function applyAccountConfig(params: { + cfg: ClawdbotConfig; + accountId: string; + input: { + name?: string; + ship?: string; + url?: string; + code?: string; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; + }; +}): ClawdbotConfig { + const { cfg, accountId, input } = params; + const useDefault = accountId === DEFAULT_ACCOUNT_ID; + const base = cfg.channels?.tlon ?? {}; + + if (useDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...base, + enabled: true, + ...(input.name ? { name: input.name } : {}), + ...(input.ship ? { ship: input.ship } : {}), + ...(input.url ? { url: input.url } : {}), + ...(input.code ? { code: input.code } : {}), + ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), + ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), + ...(typeof input.autoDiscoverChannels === "boolean" + ? { autoDiscoverChannels: input.autoDiscoverChannels } + : {}), + }, + }, + }; + } + + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...base, + enabled: base.enabled ?? true, + accounts: { + ...(base as { accounts?: Record }).accounts, + [accountId]: { + ...((base as { accounts?: Record> }).accounts?.[accountId] ?? {}), + enabled: true, + ...(input.name ? { name: input.name } : {}), + ...(input.ship ? { ship: input.ship } : {}), + ...(input.url ? { url: input.url } : {}), + ...(input.code ? { code: input.code } : {}), + ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), + ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), + ...(typeof input.autoDiscoverChannels === "boolean" + ? { autoDiscoverChannels: input.autoDiscoverChannels } + : {}), + }, + }, + }, + }, + }; +} + +async function noteTlonHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "You need your Urbit ship URL and login code.", + "Example URL: https://your-ship-host", + "Example ship: ~sampel-palnet", + `Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`, + ].join("\n"), + "Tlon setup", + ); +} + +function parseList(value: string): string[] { + return value + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export const tlonOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const accountIds = listTlonAccountIds(cfg); + const configured = + accountIds.length > 0 + ? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId))) + : isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID)); + + return { + channel, + configured, + statusLines: [`Tlon: ${configured ? "configured" : "needs setup"}`], + selectionHint: configured ? "configured" : "urbit messenger", + quickstartScore: configured ? 1 : 4, + }; + }, + configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { + const override = accountOverrides[channel]?.trim(); + const defaultAccountId = DEFAULT_ACCOUNT_ID; + let accountId = override ? normalizeAccountId(override) : defaultAccountId; + + if (shouldPromptAccountIds && !override) { + accountId = await promptAccountId({ + cfg, + prompter, + label: "Tlon", + currentId: accountId, + listAccountIds: listTlonAccountIds, + defaultAccountId, + }); + } + + const resolved = resolveTlonAccount(cfg, accountId); + await noteTlonHelp(prompter); + + const ship = await prompter.text({ + message: "Ship name", + placeholder: "~sampel-palnet", + initialValue: resolved.ship ?? undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + + const url = await prompter.text({ + message: "Ship URL", + placeholder: "https://your-ship-host", + initialValue: resolved.url ?? undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + + const code = await prompter.text({ + message: "Login code", + placeholder: "lidlut-tabwed-pillex-ridrup", + initialValue: resolved.code ?? undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + + const wantsGroupChannels = await prompter.confirm({ + message: "Add group channels manually? (optional)", + initialValue: false, + }); + + let groupChannels: string[] | undefined; + if (wantsGroupChannels) { + const entry = await prompter.text({ + message: "Group channels (comma-separated)", + placeholder: "chat/~host-ship/general, chat/~host-ship/support", + }); + const parsed = parseList(String(entry ?? "")); + groupChannels = parsed.length > 0 ? parsed : undefined; + } + + const wantsAllowlist = await prompter.confirm({ + message: "Restrict DMs with an allowlist?", + initialValue: false, + }); + + let dmAllowlist: string[] | undefined; + if (wantsAllowlist) { + const entry = await prompter.text({ + message: "DM allowlist (comma-separated ship names)", + placeholder: "~zod, ~nec", + }); + const parsed = parseList(String(entry ?? "")); + dmAllowlist = parsed.length > 0 ? parsed : undefined; + } + + const autoDiscoverChannels = await prompter.confirm({ + message: "Enable auto-discovery of group channels?", + initialValue: resolved.autoDiscoverChannels ?? true, + }); + + const next = applyAccountConfig({ + cfg, + accountId, + input: { + ship: String(ship).trim(), + url: String(url).trim(), + code: String(code).trim(), + groupChannels, + dmAllowlist, + autoDiscoverChannels, + }, + }); + + return { cfg: next, accountId }; + }, +}; diff --git a/extensions/tlon/src/runtime.ts b/extensions/tlon/src/runtime.ts new file mode 100644 index 000000000..bdcaeae4d --- /dev/null +++ b/extensions/tlon/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setTlonRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getTlonRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Tlon runtime not initialized"); + } + return runtime; +} diff --git a/extensions/tlon/src/targets.ts b/extensions/tlon/src/targets.ts new file mode 100644 index 000000000..7f1d9f28c --- /dev/null +++ b/extensions/tlon/src/targets.ts @@ -0,0 +1,79 @@ +export type TlonTarget = + | { kind: "dm"; ship: string } + | { kind: "group"; nest: string; hostShip: string; channelName: string }; + +const SHIP_RE = /^~?[a-z-]+$/i; +const NEST_RE = /^chat\/([^/]+)\/([^/]+)$/i; + +export function normalizeShip(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return trimmed; + return trimmed.startsWith("~") ? trimmed : `~${trimmed}`; +} + +export function parseChannelNest(raw: string): { hostShip: string; channelName: string } | null { + const match = NEST_RE.exec(raw.trim()); + if (!match) return null; + const hostShip = normalizeShip(match[1]); + const channelName = match[2]; + return { hostShip, channelName }; +} + +export function parseTlonTarget(raw?: string | null): TlonTarget | null { + const trimmed = raw?.trim(); + if (!trimmed) return null; + const withoutPrefix = trimmed.replace(/^tlon:/i, ""); + + const dmPrefix = withoutPrefix.match(/^dm[/:](.+)$/i); + if (dmPrefix) { + return { kind: "dm", ship: normalizeShip(dmPrefix[1]) }; + } + + const groupPrefix = withoutPrefix.match(/^(group|room)[/:](.+)$/i); + if (groupPrefix) { + const groupTarget = groupPrefix[2].trim(); + if (groupTarget.startsWith("chat/")) { + const parsed = parseChannelNest(groupTarget); + if (!parsed) return null; + return { + kind: "group", + nest: `chat/${parsed.hostShip}/${parsed.channelName}`, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + }; + } + const parts = groupTarget.split("/"); + if (parts.length === 2) { + const hostShip = normalizeShip(parts[0]); + const channelName = parts[1]; + return { + kind: "group", + nest: `chat/${hostShip}/${channelName}`, + hostShip, + channelName, + }; + } + return null; + } + + if (withoutPrefix.startsWith("chat/")) { + const parsed = parseChannelNest(withoutPrefix); + if (!parsed) return null; + return { + kind: "group", + nest: `chat/${parsed.hostShip}/${parsed.channelName}`, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + }; + } + + if (SHIP_RE.test(withoutPrefix)) { + return { kind: "dm", ship: normalizeShip(withoutPrefix) }; + } + + return null; +} + +export function formatTargetHint(): string { + return "dm/~sampel-palnet | ~sampel-palnet | chat/~host-ship/channel | group:~host-ship/channel"; +} diff --git a/extensions/tlon/src/types.ts b/extensions/tlon/src/types.ts new file mode 100644 index 000000000..47595df7b --- /dev/null +++ b/extensions/tlon/src/types.ts @@ -0,0 +1,85 @@ +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; + +export type TlonResolvedAccount = { + accountId: string; + name: string | null; + enabled: boolean; + configured: boolean; + ship: string | null; + url: string | null; + code: string | null; + groupChannels: string[]; + dmAllowlist: string[]; + autoDiscoverChannels: boolean | null; + showModelSignature: boolean | null; +}; + +export function resolveTlonAccount(cfg: ClawdbotConfig, accountId?: string | null): TlonResolvedAccount { + const base = cfg.channels?.tlon as + | { + name?: string; + enabled?: boolean; + ship?: string; + url?: string; + code?: string; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; + showModelSignature?: boolean; + accounts?: Record>; + } + | undefined; + + if (!base) { + return { + accountId: accountId || "default", + name: null, + enabled: false, + configured: false, + ship: null, + url: null, + code: null, + groupChannels: [], + dmAllowlist: [], + autoDiscoverChannels: null, + showModelSignature: null, + }; + } + + const useDefault = !accountId || accountId === "default"; + const account = useDefault ? base : (base.accounts?.[accountId] as Record | undefined); + + const ship = (account?.ship ?? base.ship ?? null) as string | null; + const url = (account?.url ?? base.url ?? null) as string | null; + const code = (account?.code ?? base.code ?? null) as string | null; + const groupChannels = (account?.groupChannels ?? base.groupChannels ?? []) as string[]; + const dmAllowlist = (account?.dmAllowlist ?? base.dmAllowlist ?? []) as string[]; + const autoDiscoverChannels = + (account?.autoDiscoverChannels ?? base.autoDiscoverChannels ?? null) as boolean | null; + const showModelSignature = + (account?.showModelSignature ?? base.showModelSignature ?? null) as boolean | null; + const configured = Boolean(ship && url && code); + + return { + accountId: accountId || "default", + name: (account?.name ?? base.name ?? null) as string | null, + enabled: (account?.enabled ?? base.enabled ?? true) !== false, + configured, + ship, + url, + code, + groupChannels, + dmAllowlist, + autoDiscoverChannels, + showModelSignature, + }; +} + +export function listTlonAccountIds(cfg: ClawdbotConfig): string[] { + const base = cfg.channels?.tlon as + | { ship?: string; accounts?: Record> } + | undefined; + if (!base) return []; + const accounts = base.accounts ?? {}; + return [...(base.ship ? ["default"] : []), ...Object.keys(accounts)]; +} diff --git a/extensions/tlon/src/urbit/auth.ts b/extensions/tlon/src/urbit/auth.ts new file mode 100644 index 000000000..ae5fb5339 --- /dev/null +++ b/extensions/tlon/src/urbit/auth.ts @@ -0,0 +1,18 @@ +export async function authenticate(url: string, code: string): Promise { + const resp = await fetch(`${url}/~/login`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `password=${code}`, + }); + + if (!resp.ok) { + throw new Error(`Login failed with status ${resp.status}`); + } + + await resp.text(); + const cookie = resp.headers.get("set-cookie"); + if (!cookie) { + throw new Error("No authentication cookie received"); + } + return cookie; +} diff --git a/extensions/tlon/src/urbit/http-api.ts b/extensions/tlon/src/urbit/http-api.ts new file mode 100644 index 000000000..61ff72371 --- /dev/null +++ b/extensions/tlon/src/urbit/http-api.ts @@ -0,0 +1,36 @@ +import { Urbit } from "@urbit/http-api"; + +let patched = false; + +export function ensureUrbitConnectPatched() { + if (patched) return; + patched = true; + Urbit.prototype.connect = async function patchedConnect() { + const resp = await fetch(`${this.url}/~/login`, { + method: "POST", + body: `password=${this.code}`, + credentials: "include", + }); + + if (resp.status >= 400) { + throw new Error(`Login failed with status ${resp.status}`); + } + + const cookie = resp.headers.get("set-cookie"); + if (cookie) { + const match = /urbauth-~([\w-]+)/.exec(cookie); + if (match) { + if (!(this as unknown as { ship?: string | null }).ship) { + (this as unknown as { ship?: string | null }).ship = match[1]; + } + (this as unknown as { nodeId?: string }).nodeId = match[1]; + } + (this as unknown as { cookie?: string }).cookie = cookie; + } + + await (this as typeof Urbit.prototype).getShipName(); + await (this as typeof Urbit.prototype).getOurName(); + }; +} + +export { Urbit }; diff --git a/extensions/tlon/src/urbit/send.ts b/extensions/tlon/src/urbit/send.ts new file mode 100644 index 000000000..6a90fcbf9 --- /dev/null +++ b/extensions/tlon/src/urbit/send.ts @@ -0,0 +1,114 @@ +import { unixToDa, formatUd } from "@urbit/aura"; + +export type TlonPokeApi = { + poke: (params: { app: string; mark: string; json: unknown }) => Promise; +}; + +type SendTextParams = { + api: TlonPokeApi; + fromShip: string; + toShip: string; + text: string; +}; + +export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) { + const story = [{ inline: [text] }]; + const sentAt = Date.now(); + const idUd = formatUd(unixToDa(sentAt)); + const id = `${fromShip}/${idUd}`; + + const delta = { + add: { + memo: { + content: story, + author: fromShip, + sent: sentAt, + }, + kind: null, + time: null, + }, + }; + + const action = { + ship: toShip, + diff: { id, delta }, + }; + + await api.poke({ + app: "chat", + mark: "chat-dm-action", + json: action, + }); + + return { channel: "tlon", messageId: id }; +} + +type SendGroupParams = { + api: TlonPokeApi; + fromShip: string; + hostShip: string; + channelName: string; + text: string; + replyToId?: string | null; +}; + +export async function sendGroupMessage({ + api, + fromShip, + hostShip, + channelName, + text, + replyToId, +}: SendGroupParams) { + const story = [{ inline: [text] }]; + const sentAt = Date.now(); + + const action = { + channel: { + nest: `chat/${hostShip}/${channelName}`, + action: replyToId + ? { + reply: { + id: replyToId, + delta: { + add: { + memo: { + content: story, + author: fromShip, + sent: sentAt, + }, + }, + }, + }, + } + : { + post: { + add: { + content: story, + author: fromShip, + sent: sentAt, + kind: "/chat", + blob: null, + meta: null, + }, + }, + }, + }, + }; + + await api.poke({ + app: "channels", + mark: "channel-action-1", + json: action, + }); + + return { channel: "tlon", messageId: `${fromShip}/${sentAt}` }; +} + +export function buildMediaText(text: string | undefined, mediaUrl: string | undefined): string { + const cleanText = text?.trim() ?? ""; + const cleanUrl = mediaUrl?.trim() ?? ""; + if (cleanText && cleanUrl) return `${cleanText}\n${cleanUrl}`; + if (cleanUrl) return cleanUrl; + return cleanText; +} diff --git a/extensions/tlon/src/urbit/sse-client.test.ts b/extensions/tlon/src/urbit/sse-client.test.ts new file mode 100644 index 000000000..9b67f6bfb --- /dev/null +++ b/extensions/tlon/src/urbit/sse-client.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { UrbitSSEClient } from "./sse-client.js"; + +const mockFetch = vi.fn(); + +describe("UrbitSSEClient", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("sends subscriptions added after connect", async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => "" }); + + const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123"); + (client as { isConnected: boolean }).isConnected = true; + + await client.subscribe({ + app: "chat", + path: "/dm/~zod", + event: () => {}, + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe(client.channelUrl); + expect(init.method).toBe("PUT"); + const body = JSON.parse(init.body as string); + expect(body).toHaveLength(1); + expect(body[0]).toMatchObject({ + action: "subscribe", + app: "chat", + path: "/dm/~zod", + }); + }); +}); diff --git a/extensions/tlon/src/urbit-sse-client.js b/extensions/tlon/src/urbit/sse-client.ts similarity index 51% rename from extensions/tlon/src/urbit-sse-client.js rename to extensions/tlon/src/urbit/sse-client.ts index eb52c8573..19878e679 100644 --- a/extensions/tlon/src/urbit-sse-client.js +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -1,59 +1,128 @@ -/** - * Custom SSE client for Urbit that works in Node.js - * Handles authentication cookies and streaming properly - */ +import { Readable } from "node:stream"; -import { Readable } from "stream"; +export type UrbitSseLogger = { + log?: (message: string) => void; + error?: (message: string) => void; +}; + +type UrbitSseOptions = { + ship?: string; + onReconnect?: (client: UrbitSSEClient) => Promise | void; + autoReconnect?: boolean; + maxReconnectAttempts?: number; + reconnectDelay?: number; + maxReconnectDelay?: number; + logger?: UrbitSseLogger; +}; export class UrbitSSEClient { - constructor(url, cookie, options = {}) { - this.url = url; - // Extract just the cookie value (first part before semicolon) - this.cookie = cookie.split(";")[0]; - this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random() - .toString(36) - .substring(2, 8)}`; - this.channelUrl = `${url}/~/channel/${this.channelId}`; - this.subscriptions = []; - this.eventHandlers = new Map(); - this.aborted = false; - this.streamController = null; + url: string; + cookie: string; + ship: string; + channelId: string; + channelUrl: string; + subscriptions: Array<{ + id: number; + action: "subscribe"; + ship: string; + app: string; + path: string; + }> = []; + eventHandlers = new Map< + number, + { event?: (data: unknown) => void; err?: (error: unknown) => void; quit?: () => void } + >(); + aborted = false; + streamController: AbortController | null = null; + onReconnect: UrbitSseOptions["onReconnect"] | null; + autoReconnect: boolean; + reconnectAttempts = 0; + maxReconnectAttempts: number; + reconnectDelay: number; + maxReconnectDelay: number; + isConnected = false; + logger: UrbitSseLogger; - // Reconnection settings - this.onReconnect = options.onReconnect || null; - this.autoReconnect = options.autoReconnect !== false; // Default true - this.reconnectAttempts = 0; - this.maxReconnectAttempts = options.maxReconnectAttempts || 10; - this.reconnectDelay = options.reconnectDelay || 1000; // Start at 1s - this.maxReconnectDelay = options.maxReconnectDelay || 30000; // Max 30s - this.isConnected = false; + constructor(url: string, cookie: string, options: UrbitSseOptions = {}) { + this.url = url; + this.cookie = cookie.split(";")[0]; + this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromUrl(url); + this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; + this.channelUrl = `${url}/~/channel/${this.channelId}`; + this.onReconnect = options.onReconnect ?? null; + this.autoReconnect = options.autoReconnect !== false; + this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10; + this.reconnectDelay = options.reconnectDelay ?? 1000; + this.maxReconnectDelay = options.maxReconnectDelay ?? 30000; + this.logger = options.logger ?? {}; } - /** - * Subscribe to an Urbit path - */ - async subscribe({ app, path, event, err, quit }) { - const subId = this.subscriptions.length + 1; + private resolveShipFromUrl(url: string): string { + try { + const parsed = new URL(url); + const host = parsed.hostname; + if (host.includes(".")) { + return host.split(".")[0] ?? host; + } + return host; + } catch { + return ""; + } + } - this.subscriptions.push({ + async subscribe(params: { + app: string; + path: string; + event?: (data: unknown) => void; + err?: (error: unknown) => void; + quit?: () => void; + }) { + const subId = this.subscriptions.length + 1; + const subscription = { id: subId, action: "subscribe", - ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""), - app, - path, - }); + ship: this.ship, + app: params.app, + path: params.path, + } as const; - // Store event handlers - this.eventHandlers.set(subId, { event, err, quit }); + this.subscriptions.push(subscription); + this.eventHandlers.set(subId, { event: params.event, err: params.err, quit: params.quit }); + if (this.isConnected) { + try { + await this.sendSubscription(subscription); + } catch (error) { + const handler = this.eventHandlers.get(subId); + handler?.err?.(error); + } + } return subId; } - /** - * Create the channel and start listening for events - */ + private async sendSubscription(subscription: { + id: number; + action: "subscribe"; + ship: string; + app: string; + path: string; + }) { + const response = await fetch(this.channelUrl, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify([subscription]), + }); + + if (!response.ok && response.status !== 204) { + const errorText = await response.text(); + throw new Error(`Subscribe failed: ${response.status} - ${errorText}`); + } + } + async connect() { - // Create channel with all subscriptions const createResp = await fetch(this.channelUrl, { method: "PUT", headers: { @@ -67,8 +136,6 @@ export class UrbitSSEClient { throw new Error(`Channel creation failed: ${createResp.status}`); } - // Send helm-hi poke to activate the channel - // This is required before opening the SSE stream const pokeResp = await fetch(this.channelUrl, { method: "PUT", headers: { @@ -79,7 +146,7 @@ export class UrbitSSEClient { { id: Date.now(), action: "poke", - ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""), + ship: this.ship, app: "hood", mark: "helm-hi", json: "Opening API channel", @@ -91,15 +158,11 @@ export class UrbitSSEClient { throw new Error(`Channel activation failed: ${pokeResp.status}`); } - // Open SSE stream await this.openStream(); this.isConnected = true; - this.reconnectAttempts = 0; // Reset on successful connection + this.reconnectAttempts = 0; } - /** - * Open the SSE stream and process events - */ async openStream() { const response = await fetch(this.channelUrl, { method: "GET", @@ -113,69 +176,47 @@ export class UrbitSSEClient { throw new Error(`Stream connection failed: ${response.status}`); } - // Start processing the stream in the background (don't await) this.processStream(response.body).catch((error) => { if (!this.aborted) { - console.error("Stream error:", error); - // Notify all error handlers + this.logger.error?.(`Stream error: ${String(error)}`); for (const { err } of this.eventHandlers.values()) { if (err) err(error); } } }); - - // Stream is connected and running in background - // Return immediately so connect() can complete } - /** - * Process the SSE stream (runs in background) - */ - async processStream(body) { - const reader = body; + async processStream(body: ReadableStream | Readable | null) { + if (!body) return; + const stream = body instanceof ReadableStream ? Readable.fromWeb(body) : body; let buffer = ""; - // Convert Web ReadableStream to Node Readable if needed - const stream = - reader instanceof ReadableStream ? Readable.fromWeb(reader) : reader; - try { for await (const chunk of stream) { if (this.aborted) break; - buffer += chunk.toString(); - - // Process complete SSE events let eventEnd; while ((eventEnd = buffer.indexOf("\n\n")) !== -1) { const eventData = buffer.substring(0, eventEnd); buffer = buffer.substring(eventEnd + 2); - this.processEvent(eventData); } } } finally { - // Stream ended (either normally or due to error) if (!this.aborted && this.autoReconnect) { this.isConnected = false; - console.log("[SSE] Stream ended, attempting reconnection..."); + this.logger.log?.("[SSE] Stream ended, attempting reconnection..."); await this.attemptReconnect(); } } } - /** - * Process a single SSE event - */ - processEvent(eventData) { + processEvent(eventData: string) { const lines = eventData.split("\n"); - let id = null; - let data = null; + let data: string | null = null; for (const line of lines) { - if (line.startsWith("id: ")) { - id = line.substring(4); - } else if (line.startsWith("data: ")) { + if (line.startsWith("data: ")) { data = line.substring(6); } } @@ -183,61 +224,42 @@ export class UrbitSSEClient { if (!data) return; try { - const parsed = JSON.parse(data); + const parsed = JSON.parse(data) as { id?: number; json?: unknown; response?: string }; - // Handle quit events - subscription ended if (parsed.response === "quit") { - console.log(`[SSE] Received quit event for subscription ${parsed.id}`); - const handlers = this.eventHandlers.get(parsed.id); - if (handlers && handlers.quit) { - handlers.quit(); + if (parsed.id) { + const handlers = this.eventHandlers.get(parsed.id); + if (handlers?.quit) handlers.quit(); } return; } - // Debug: Log received events (skip subscription confirmations) - if (parsed.response !== "subscribe" && parsed.response !== "poke") { - console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500)); - } - - // Route to appropriate handler based on subscription if (parsed.id && this.eventHandlers.has(parsed.id)) { - const { event } = this.eventHandlers.get(parsed.id); + const { event } = this.eventHandlers.get(parsed.id) ?? {}; if (event && parsed.json) { - console.log(`[SSE] Calling handler for subscription ${parsed.id}`); event(parsed.json); } } else if (parsed.json) { - // Try to match by response structure for events without specific ID - console.log(`[SSE] Broadcasting event to all handlers`); for (const { event } of this.eventHandlers.values()) { - if (event) { - event(parsed.json); - } + if (event) event(parsed.json); } } } catch (error) { - console.error("Error parsing SSE event:", error); + this.logger.error?.(`Error parsing SSE event: ${String(error)}`); } } - /** - * Send a poke to Urbit - */ - async poke({ app, mark, json }) { + async poke(params: { app: string; mark: string; json: unknown }) { const pokeId = Date.now(); - const pokeData = { id: pokeId, action: "poke", - ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""), - app, - mark, - json, + ship: this.ship, + app: params.app, + mark: params.mark, + json: params.json, }; - console.log(`[SSE] Sending poke to ${app}:`, JSON.stringify(pokeData).substring(0, 300)); - const response = await fetch(this.channelUrl, { method: "PUT", headers: { @@ -247,23 +269,16 @@ export class UrbitSSEClient { body: JSON.stringify([pokeData]), }); - console.log(`[SSE] Poke response status: ${response.status}`); - if (!response.ok && response.status !== 204) { const errorText = await response.text(); - console.log(`[SSE] Poke error body: ${errorText.substring(0, 500)}`); throw new Error(`Poke failed: ${response.status} - ${errorText}`); } return pokeId; } - /** - * Perform a scry (read-only query) to Urbit - */ - async scry(path) { + async scry(path: string) { const scryUrl = `${this.url}/~/scry${path}`; - const response = await fetch(scryUrl, { method: "GET", headers: { @@ -278,70 +293,52 @@ export class UrbitSSEClient { return await response.json(); } - /** - * Attempt to reconnect with exponential backoff - */ async attemptReconnect() { if (this.aborted || !this.autoReconnect) { - console.log("[SSE] Reconnection aborted or disabled"); + this.logger.log?.("[SSE] Reconnection aborted or disabled"); return; } if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.error( - `[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.` + this.logger.error?.( + `[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`, ); return; } - this.reconnectAttempts++; - - // Calculate delay with exponential backoff + this.reconnectAttempts += 1; const delay = Math.min( this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), - this.maxReconnectDelay + this.maxReconnectDelay, ); - console.log( - `[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...` + this.logger.log?.( + `[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`, ); await new Promise((resolve) => setTimeout(resolve, delay)); try { - // Generate new channel ID for reconnection - this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random() - .toString(36) - .substring(2, 8)}`; + this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; this.channelUrl = `${this.url}/~/channel/${this.channelId}`; - console.log(`[SSE] Reconnecting with new channel ID: ${this.channelId}`); - - // Call reconnect callback if provided if (this.onReconnect) { await this.onReconnect(this); } - // Reconnect await this.connect(); - - console.log("[SSE] Reconnection successful!"); + this.logger.log?.("[SSE] Reconnection successful!"); } catch (error) { - console.error(`[SSE] Reconnection failed: ${error.message}`); - // Try again + this.logger.error?.(`[SSE] Reconnection failed: ${String(error)}`); await this.attemptReconnect(); } } - /** - * Close the connection - */ async close() { this.aborted = true; this.isConnected = false; try { - // Send unsubscribe for all subscriptions const unsubscribes = this.subscriptions.map((sub) => ({ id: sub.id, action: "unsubscribe", @@ -357,7 +354,6 @@ export class UrbitSSEClient { body: JSON.stringify(unsubscribes), }); - // Delete the channel await fetch(this.channelUrl, { method: "DELETE", headers: { @@ -365,7 +361,7 @@ export class UrbitSSEClient { }, }); } catch (error) { - console.error("Error closing channel:", error); + this.logger.error?.(`Error closing channel: ${String(error)}`); } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 529005132..f2ac16e31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -376,12 +376,23 @@ importers: specifier: ^4.3.5 version: 4.3.5 + extensions/open-prose: {} + extensions/signal: {} extensions/slack: {} extensions/telegram: {} + extensions/tlon: + dependencies: + '@urbit/aura': + specifier: ^2.0.0 + version: 2.0.1 + '@urbit/http-api': + specifier: ^3.0.0 + version: 3.0.0 + extensions/voice-call: dependencies: '@sinclair/typebox': @@ -2572,6 +2583,13 @@ packages: resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==} engines: {node: '>=20.0.0'} + '@urbit/aura@2.0.1': + resolution: {integrity: sha512-B1ZTwsEVqi/iybxjHlY3gBz7r4Xd7n9pwi9NY6V+7r4DksqBYBpfzdqWGUXgZ0x67IW8AOGjC73tkTOclNMhUg==} + engines: {node: '>=16', npm: '>=8'} + + '@urbit/http-api@3.0.0': + resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==} + '@vitest/browser-playwright@4.0.17': resolution: {integrity: sha512-CE9nlzslHX6Qz//MVrjpulTC9IgtXTbJ+q7Rx1HD+IeSOWv4NHIRNHPA6dB4x01d9paEqt+TvoqZfmgq40DxEQ==} peerDependencies: @@ -2862,6 +2880,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browser-or-node@1.3.0: + resolution: {integrity: sha512-0F2z/VSnLbmEeBcUrSuDH5l0HxTXdQQzLjkmBR4cYfvg1zJrKSlmIZFqyFR8oX0NrwPhy3c3HQ6i3OxMbew4Tg==} + browser-or-node@3.0.0: resolution: {integrity: sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==} @@ -3037,6 +3058,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-js@3.48.0: + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -7756,6 +7780,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@urbit/aura@2.0.1': {} + + '@urbit/http-api@3.0.0': + dependencies: + '@babel/runtime': 7.28.6 + browser-or-node: 1.3.0 + core-js: 3.48.0 + '@vitest/browser-playwright@4.0.17(playwright@1.57.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.17)': dependencies: '@vitest/browser': 4.0.17(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.17) @@ -8117,6 +8149,8 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-or-node@1.3.0: {} + browser-or-node@3.0.0: {} buffer-equal-constant-time@1.0.1: {} @@ -8309,6 +8343,8 @@ snapshots: cookie@0.7.2: {} + core-js@3.48.0: {} + core-util-is@1.0.2: {} core-util-is@1.0.3: {} diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts index 2df29a95c..2470dbd33 100644 --- a/src/channels/plugins/catalog.test.ts +++ b/src/channels/plugins/catalog.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js"; @@ -13,4 +16,37 @@ describe("channel plugin catalog", () => { const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); expect(ids).toContain("msteams"); }); + + it("includes external catalog entries", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-catalog-")); + const catalogPath = path.join(dir, "catalog.json"); + fs.writeFileSync( + catalogPath, + JSON.stringify({ + entries: [ + { + name: "@clawdbot/demo-channel", + clawdbot: { + channel: { + id: "demo-channel", + label: "Demo Channel", + selectionLabel: "Demo Channel", + docsPath: "/channels/demo-channel", + blurb: "Demo entry", + order: 999, + }, + install: { + npmSpec: "@clawdbot/demo-channel", + }, + }, + }, + ], + }), + ); + + const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map( + (entry) => entry.id, + ); + expect(ids).toContain("demo-channel"); + }); }); diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 5729276d1..d98ee1aa9 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -1,8 +1,10 @@ +import fs from "node:fs"; import path from "node:path"; import { discoverClawdbotPlugins } from "../../plugins/discovery.js"; import type { PluginOrigin } from "../../plugins/types.js"; import type { ClawdbotPackageManifest } from "../../plugins/manifest.js"; +import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; import type { ChannelMeta } from "./types.js"; export type ChannelUiMetaEntry = { @@ -33,6 +35,7 @@ export type ChannelPluginCatalogEntry = { type CatalogOptions = { workspaceDir?: string; + catalogPaths?: string[]; }; const ORIGIN_PRIORITY: Record = { @@ -42,6 +45,74 @@ const ORIGIN_PRIORITY: Record = { bundled: 3, }; +type ExternalCatalogEntry = { + name?: string; + version?: string; + description?: string; + clawdbot?: ClawdbotPackageManifest; +}; + +const DEFAULT_CATALOG_PATHS = [ + path.join(CONFIG_DIR, "mpm", "plugins.json"), + path.join(CONFIG_DIR, "mpm", "catalog.json"), + path.join(CONFIG_DIR, "plugins", "catalog.json"), +]; + +const ENV_CATALOG_PATHS = ["CLAWDBOT_PLUGIN_CATALOG_PATHS", "CLAWDBOT_MPM_CATALOG_PATHS"]; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function parseCatalogEntries(raw: unknown): ExternalCatalogEntry[] { + if (Array.isArray(raw)) { + return raw.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); + } + if (!isRecord(raw)) return []; + const list = raw.entries ?? raw.packages ?? raw.plugins; + if (!Array.isArray(list)) return []; + return list.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); +} + +function splitEnvPaths(value: string): string[] { + const trimmed = value.trim(); + if (!trimmed) return []; + return trimmed + .split(/[;,]/g) + .flatMap((chunk) => chunk.split(path.delimiter)) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function resolveExternalCatalogPaths(options: CatalogOptions): string[] { + if (options.catalogPaths && options.catalogPaths.length > 0) { + return options.catalogPaths.map((entry) => entry.trim()).filter(Boolean); + } + for (const key of ENV_CATALOG_PATHS) { + const raw = process.env[key]; + if (raw && raw.trim()) { + return splitEnvPaths(raw); + } + } + return DEFAULT_CATALOG_PATHS; +} + +function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEntry[] { + const paths = resolveExternalCatalogPaths(options); + const entries: ExternalCatalogEntry[] = []; + for (const rawPath of paths) { + const resolved = resolveUserPath(rawPath); + if (!fs.existsSync(resolved)) continue; + try { + const payload = JSON.parse(fs.readFileSync(resolved, "utf-8")) as unknown; + entries.push(...parseCatalogEntries(payload)); + } catch { + // Ignore invalid catalog files. + } + } + return entries; +} + function toChannelMeta(params: { channel: NonNullable; id: string; @@ -132,6 +203,13 @@ function buildCatalogEntry(candidate: { return { id, meta, install }; } +function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null { + return buildCatalogEntry({ + packageName: entry.name, + packageClawdbot: entry.clawdbot, + }); +} + export function buildChannelUiCatalog( plugins: Array<{ id: string; meta: ChannelMeta }>, ): ChannelUiCatalog { @@ -176,6 +254,15 @@ export function listChannelPluginCatalogEntries( } } + const externalEntries = loadExternalCatalogEntries(options) + .map((entry) => buildExternalCatalogEntry(entry)) + .filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry)); + for (const entry of externalEntries) { + if (!resolved.has(entry.id)) { + resolved.set(entry.id, { entry, priority: 99 }); + } + } + return Array.from(resolved.values()) .map(({ entry }) => entry) .sort((a, b) => { diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 5b0dbd1fc..058745824 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -39,6 +39,12 @@ export type ChannelSetupInput = { password?: string; deviceName?: string; initialSyncLimit?: number; + ship?: string; + url?: string; + code?: string; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; }; export type ChannelStatusIssue = { diff --git a/src/cli/channel-options.ts b/src/cli/channel-options.ts index c7b25cfb3..fac7fbd9e 100644 --- a/src/cli/channel-options.ts +++ b/src/cli/channel-options.ts @@ -1,14 +1,29 @@ +import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { CHAT_CHANNEL_ORDER } from "../channels/registry.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { ensurePluginRegistryLoaded } from "./plugin-registry.js"; +function dedupe(values: string[]): string[] { + const seen = new Set(); + const resolved: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + resolved.push(value); + } + return resolved; +} + export function resolveCliChannelOptions(): string[] { + const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id); + const base = dedupe([...CHAT_CHANNEL_ORDER, ...catalog]); if (isTruthyEnvValue(process.env.CLAWDBOT_EAGER_CHANNEL_OPTIONS)) { ensurePluginRegistryLoaded(); - return listChannelPlugins().map((plugin) => plugin.id); + const pluginIds = listChannelPlugins().map((plugin) => plugin.id); + return dedupe([...base, ...pluginIds]); } - return [...CHAT_CHANNEL_ORDER]; + return base; } export function formatCliChannelOptions(extra: string[] = []): string { diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 586e7c5c2..8b2e2d8f0 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { listChannelPlugins } from "../channels/plugins/index.js"; +import { formatCliChannelOptions } from "./channel-options.js"; import { channelsAddCommand, channelsCapabilitiesCommand, @@ -42,6 +42,12 @@ const optionNamesAdd = [ "password", "deviceName", "initialSyncLimit", + "ship", + "url", + "code", + "groupChannels", + "dmAllowlist", + "autoDiscoverChannels", ] as const; const optionNamesRemove = ["channel", "account", "delete"] as const; @@ -58,9 +64,7 @@ function runChannelsCommandWithDanger(action: () => Promise, label: string } export function registerChannelsCli(program: Command) { - const channelNames = listChannelPlugins() - .map((plugin) => plugin.id) - .join("|"); + const channelNames = formatCliChannelOptions(); const channels = program .command("channels") .description("Manage chat channel accounts") @@ -99,7 +103,7 @@ export function registerChannelsCli(program: Command) { channels .command("capabilities") .description("Show provider capabilities (intents/scopes + supported features)") - .option("--channel ", `Channel (${channelNames}|all)`) + .option("--channel ", `Channel (${formatCliChannelOptions(["all"])})`) .option("--account ", "Account id (only with --channel)") .option("--target ", "Channel target for permission audit (Discord channel:)") .option("--timeout ", "Timeout in ms", "10000") @@ -136,7 +140,7 @@ export function registerChannelsCli(program: Command) { channels .command("logs") .description("Show recent channel logs from the gateway log file") - .option("--channel ", `Channel (${channelNames}|all)`, "all") + .option("--channel ", `Channel (${formatCliChannelOptions(["all"])})`, "all") .option("--lines ", "Number of lines (default: 200)", "200") .option("--json", "Output JSON", false) .action(async (opts) => { @@ -171,6 +175,13 @@ export function registerChannelsCli(program: Command) { .option("--password ", "Matrix password") .option("--device-name ", "Matrix device name") .option("--initial-sync-limit ", "Matrix initial sync limit") + .option("--ship ", "Tlon ship name (~sampel-palnet)") + .option("--url ", "Tlon ship URL") + .option("--code ", "Tlon login code") + .option("--group-channels ", "Tlon group channels (comma-separated)") + .option("--dm-allowlist ", "Tlon DM allowlist (comma-separated ships)") + .option("--auto-discover-channels", "Tlon auto-discover group channels") + .option("--no-auto-discover-channels", "Disable Tlon auto-discovery") .option("--use-env", "Use env token (default account only)", false) .action(async (opts, command) => { await runChannelsCommand(async () => { diff --git a/src/commands/channels/add-mutators.ts b/src/commands/channels/add-mutators.ts index 01d83b90b..10fb93b30 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -43,6 +43,12 @@ export function applyChannelAccountConfig(params: { password?: string; deviceName?: string; initialSyncLimit?: number; + ship?: string; + url?: string; + code?: string; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; }): ClawdbotConfig { const accountId = normalizeAccountId(params.accountId); const plugin = getChannelPlugin(params.channel); @@ -71,6 +77,12 @@ export function applyChannelAccountConfig(params: { password: params.password, deviceName: params.deviceName, initialSyncLimit: params.initialSyncLimit, + ship: params.ship, + url: params.url, + code: params.code, + groupChannels: params.groupChannels, + dmAllowlist: params.dmAllowlist, + autoDiscoverChannels: params.autoDiscoverChannels, }; return apply({ cfg: params.cfg, accountId, input }); } diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 46bedd50b..8a5b72185 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -1,11 +1,17 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { ChannelId } from "../../channels/plugins/types.js"; -import { writeConfigFile } from "../../config/config.js"; +import { writeConfigFile, type ClawdbotConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { setupChannels } from "../onboard-channels.js"; import type { ChannelChoice } from "../onboard-types.js"; +import { + ensureOnboardingPluginInstalled, + reloadOnboardingPluginRegistry, +} from "../onboarding/plugin-install.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; @@ -34,8 +40,33 @@ export type ChannelsAddOptions = { password?: string; deviceName?: string; initialSyncLimit?: number | string; + ship?: string; + url?: string; + code?: string; + groupChannels?: string; + dmAllowlist?: string; + autoDiscoverChannels?: boolean; }; +function parseList(value: string | undefined): string[] | undefined { + if (!value?.trim()) return undefined; + const parsed = value + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); + return parsed.length > 0 ? parsed : undefined; +} + +function resolveCatalogChannelEntry(raw: string, cfg: ClawdbotConfig | null) { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) return undefined; + const workspaceDir = cfg ? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) : undefined; + return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => { + if (entry.id.toLowerCase() === trimmed) return true; + return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed); + }); +} + export async function channelsAddCommand( opts: ChannelsAddOptions, runtime: RuntimeEnv = defaultRuntime, @@ -43,6 +74,7 @@ export async function channelsAddCommand( ) { const cfg = await requireValidConfig(runtime); if (!cfg) return; + let nextConfig = cfg; const useWizard = shouldUseWizard(params); if (useWizard) { @@ -99,9 +131,31 @@ export async function channelsAddCommand( return; } - const channel = normalizeChannelId(opts.channel); + const rawChannel = String(opts.channel ?? ""); + let channel = normalizeChannelId(rawChannel); + let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig); + + if (!channel && catalogEntry) { + const prompter = createClackPrompter(); + const workspaceDir = resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig)); + const result = await ensureOnboardingPluginInstalled({ + cfg: nextConfig, + entry: catalogEntry, + prompter, + runtime, + workspaceDir, + }); + nextConfig = result.cfg; + if (!result.installed) return; + reloadOnboardingPluginRegistry({ cfg: nextConfig, runtime, workspaceDir }); + channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId); + } + if (!channel) { - runtime.error(`Unknown channel: ${String(opts.channel ?? "")}`); + const hint = catalogEntry + ? `Plugin ${catalogEntry.meta.label} could not be loaded after install.` + : `Unknown channel: ${String(opts.channel ?? "")}`; + runtime.error(hint); runtime.exit(1); return; } @@ -113,7 +167,7 @@ export async function channelsAddCommand( return; } const accountId = - plugin.setup.resolveAccountId?.({ cfg, accountId: opts.account }) ?? + plugin.setup.resolveAccountId?.({ cfg: nextConfig, accountId: opts.account }) ?? normalizeAccountId(opts.account); const useEnv = opts.useEnv === true; const initialSyncLimit = @@ -122,8 +176,11 @@ export async function channelsAddCommand( : typeof opts.initialSyncLimit === "string" && opts.initialSyncLimit.trim() ? Number.parseInt(opts.initialSyncLimit, 10) : undefined; + const groupChannels = parseList(opts.groupChannels); + const dmAllowlist = parseList(opts.dmAllowlist); + const validationError = plugin.setup.validateInput?.({ - cfg, + cfg: nextConfig, accountId, input: { name: opts.name, @@ -148,6 +205,12 @@ export async function channelsAddCommand( deviceName: opts.deviceName, initialSyncLimit, useEnv, + ship: opts.ship, + url: opts.url, + code: opts.code, + groupChannels, + dmAllowlist, + autoDiscoverChannels: opts.autoDiscoverChannels, }, }); if (validationError) { @@ -156,8 +219,8 @@ export async function channelsAddCommand( return; } - const nextConfig = applyChannelAccountConfig({ - cfg, + nextConfig = applyChannelAccountConfig({ + cfg: nextConfig, channel, accountId, name: opts.name, @@ -182,6 +245,12 @@ export async function channelsAddCommand( deviceName: opts.deviceName, initialSyncLimit, useEnv, + ship: opts.ship, + url: opts.url, + code: opts.code, + groupChannels, + dmAllowlist, + autoDiscoverChannels: opts.autoDiscoverChannels, }); await writeConfigFile(nextConfig); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 945ecf3e2..bcff1c171 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -111,7 +111,8 @@ async function collectChannelStatus(params: { }): Promise { const installedPlugins = listChannelPlugins(); const installedIds = new Set(installedPlugins.map((plugin) => plugin.id)); - const catalogEntries = listChannelPluginCatalogEntries().filter( + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); + const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter( (entry) => !installedIds.has(entry.id), ); const statusEntries = await Promise.all( @@ -388,7 +389,8 @@ export async function setupChannels( const core = listChatChannels(); const installed = listChannelPlugins(); const installedIds = new Set(installed.map((plugin) => plugin.id)); - const catalog = listChannelPluginCatalogEntries().filter( + const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); + const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter( (entry) => !installedIds.has(entry.id), ); const metaById = new Map();