Add Tlon/Urbit channel plugin
Adds built-in Tlon (Urbit) channel plugin to support decentralized messaging on the Urbit network. Features: - DM and group chat support - SSE-based real-time message monitoring - Auto-discovery of group channels - Thread replies and reactions - Integration with Urbit's HTTP API This resolves cron delivery issues with external Tlon plugins by making it a first-class built-in channel alongside Telegram, Signal, and other messaging platforms. Implementation includes: - Plugin registration via ClawdbotPluginApi - Outbound delivery with sendText and sendMedia - Gateway adapter for inbound message handling - Urbit SSE client for event streaming - Core bridge for Clawdbot runtime integration Co-authored-by: William Arzt <william@arzt.co>
This commit is contained in:
828
extensions/tlon/README.md
Normal file
828
extensions/tlon/README.md
Normal file
@@ -0,0 +1,828 @@
|
|||||||
|
# Clawdbot Tlon/Urbit Integration
|
||||||
|
|
||||||
|
Complete documentation for integrating Clawdbot with Tlon Messenger (built on Urbit).
|
||||||
|
|
||||||
|
## 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
|
||||||
11
extensions/tlon/clawdbot.plugin.json
Normal file
11
extensions/tlon/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "tlon",
|
||||||
|
"channels": [
|
||||||
|
"tlon"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
extensions/tlon/index.ts
Normal file
16
extensions/tlon/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { tlonPlugin } from "./src/channel.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "tlon",
|
||||||
|
name: "Tlon",
|
||||||
|
description: "Tlon/Urbit channel plugin",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
api.registerChannel({ plugin: tlonPlugin });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
16
extensions/tlon/package.json
Normal file
16
extensions/tlon/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/tlon",
|
||||||
|
"version": "2026.1.22",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot Tlon/Urbit channel plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@urbit/http-api": "^3.0.0",
|
||||||
|
"@urbit/aura": "^2.0.0",
|
||||||
|
"eventsource": "^2.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
360
extensions/tlon/src/channel.js
Normal file
360
extensions/tlon/src/channel.js
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
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 };
|
||||||
100
extensions/tlon/src/core-bridge.js
Normal file
100
extensions/tlon/src/core-bridge.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
1572
extensions/tlon/src/monitor.js
Normal file
1572
extensions/tlon/src/monitor.js
Normal file
File diff suppressed because it is too large
Load Diff
371
extensions/tlon/src/urbit-sse-client.js
Normal file
371
extensions/tlon/src/urbit-sse-client.js
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* Custom SSE client for Urbit that works in Node.js
|
||||||
|
* Handles authentication cookies and streaming properly
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Readable } from "stream";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to an Urbit path
|
||||||
|
*/
|
||||||
|
async subscribe({ app, path, event, err, quit }) {
|
||||||
|
const subId = this.subscriptions.length + 1;
|
||||||
|
|
||||||
|
this.subscriptions.push({
|
||||||
|
id: subId,
|
||||||
|
action: "subscribe",
|
||||||
|
ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
||||||
|
app,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store event handlers
|
||||||
|
this.eventHandlers.set(subId, { event, err, quit });
|
||||||
|
|
||||||
|
return subId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the channel and start listening for events
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
// Create channel with all subscriptions
|
||||||
|
const createResp = await fetch(this.channelUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Cookie: this.cookie,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(this.subscriptions),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResp.ok && createResp.status !== 204) {
|
||||||
|
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: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Cookie: this.cookie,
|
||||||
|
},
|
||||||
|
body: JSON.stringify([
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
action: "poke",
|
||||||
|
ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
||||||
|
app: "hood",
|
||||||
|
mark: "helm-hi",
|
||||||
|
json: "Opening API channel",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pokeResp.ok && pokeResp.status !== 204) {
|
||||||
|
throw new Error(`Channel activation failed: ${pokeResp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open SSE stream
|
||||||
|
await this.openStream();
|
||||||
|
this.isConnected = true;
|
||||||
|
this.reconnectAttempts = 0; // Reset on successful connection
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the SSE stream and process events
|
||||||
|
*/
|
||||||
|
async openStream() {
|
||||||
|
const response = await fetch(this.channelUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
Cookie: this.cookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
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
|
||||||
|
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;
|
||||||
|
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...");
|
||||||
|
await this.attemptReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single SSE event
|
||||||
|
*/
|
||||||
|
processEvent(eventData) {
|
||||||
|
const lines = eventData.split("\n");
|
||||||
|
let id = null;
|
||||||
|
let data = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("id: ")) {
|
||||||
|
id = line.substring(4);
|
||||||
|
} else if (line.startsWith("data: ")) {
|
||||||
|
data = line.substring(6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing SSE event:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a poke to Urbit
|
||||||
|
*/
|
||||||
|
async poke({ app, mark, json }) {
|
||||||
|
const pokeId = Date.now();
|
||||||
|
|
||||||
|
const pokeData = {
|
||||||
|
id: pokeId,
|
||||||
|
action: "poke",
|
||||||
|
ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
||||||
|
app,
|
||||||
|
mark,
|
||||||
|
json,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[SSE] Sending poke to ${app}:`, JSON.stringify(pokeData).substring(0, 300));
|
||||||
|
|
||||||
|
const response = await fetch(this.channelUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Cookie: this.cookie,
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
const scryUrl = `${this.url}/~/scry${path}`;
|
||||||
|
|
||||||
|
const response = await fetch(scryUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Cookie: this.cookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Scry failed: ${response.status} for path ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to reconnect with exponential backoff
|
||||||
|
*/
|
||||||
|
async attemptReconnect() {
|
||||||
|
if (this.aborted || !this.autoReconnect) {
|
||||||
|
console.log("[SSE] Reconnection aborted or disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
console.error(
|
||||||
|
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
|
||||||
|
// Calculate delay with exponential backoff
|
||||||
|
const delay = Math.min(
|
||||||
|
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
||||||
|
this.maxReconnectDelay
|
||||||
|
);
|
||||||
|
|
||||||
|
console.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.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!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SSE] Reconnection failed: ${error.message}`);
|
||||||
|
// Try again
|
||||||
|
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",
|
||||||
|
subscription: sub.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await fetch(this.channelUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Cookie: this.cookie,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(unsubscribes),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the channel
|
||||||
|
await fetch(this.channelUrl, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Cookie: this.cookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error closing channel:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user