feat: add tlon channel plugin
This commit is contained in:
parent
d46642319b
commit
791b568f78
@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.23
|
## 2026.1.23 (Unreleased)
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
- 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
|
### Fixes
|
||||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||||
|
|||||||
@ -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).
|
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
||||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||||
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (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](/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).
|
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||||
|
|||||||
133
docs/channels/tlon.md
Normal file
133
docs/channels/tlon.md
Normal file
@ -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).
|
||||||
@ -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
|
## Plugin IDs
|
||||||
|
|
||||||
Default plugin ids:
|
Default plugin ids:
|
||||||
|
|||||||
@ -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
|
Docs: https://docs.clawd.bot/channels/tlon
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|||||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { tlonPlugin } from "./src/channel.js";
|
import { tlonPlugin } from "./src/channel.js";
|
||||||
|
import { setTlonRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
id: "tlon",
|
id: "tlon",
|
||||||
@ -9,6 +10,7 @@ const plugin = {
|
|||||||
description: "Tlon/Urbit channel plugin",
|
description: "Tlon/Urbit channel plugin",
|
||||||
configSchema: emptyPluginConfigSchema(),
|
configSchema: emptyPluginConfigSchema(),
|
||||||
register(api: ClawdbotPluginApi) {
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setTlonRuntime(api.runtime);
|
||||||
api.registerChannel({ plugin: tlonPlugin });
|
api.registerChannel({ plugin: tlonPlugin });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
1
extensions/tlon/node_modules/@urbit/aura
generated
vendored
Symbolic link
1
extensions/tlon/node_modules/@urbit/aura
generated
vendored
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
../../../../node_modules/.pnpm/@urbit+aura@2.0.1/node_modules/@urbit/aura
|
||||||
1
extensions/tlon/node_modules/@urbit/http-api
generated
vendored
Symbolic link
1
extensions/tlon/node_modules/@urbit/http-api
generated
vendored
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
../../../../node_modules/.pnpm/@urbit+http-api@3.0.0/node_modules/@urbit/http-api
|
||||||
@ -6,11 +6,25 @@
|
|||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"./index.ts"
|
"./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": {
|
"dependencies": {
|
||||||
"@urbit/http-api": "^3.0.0",
|
|
||||||
"@urbit/aura": "^2.0.0",
|
"@urbit/aura": "^2.0.0",
|
||||||
"eventsource": "^2.0.2"
|
"@urbit/http-api": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
|
||||||
379
extensions/tlon/src/channel.ts
Normal file
379
extensions/tlon/src/channel.ts
Normal file
@ -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<string, unknown> }).accounts,
|
||||||
|
[accountId]: {
|
||||||
|
...((base as { accounts?: Record<string, Record<string, unknown>> }).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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
43
extensions/tlon/src/config-schema.ts
Normal file
43
extensions/tlon/src/config-schema.ts
Normal file
@ -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);
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
71
extensions/tlon/src/monitor/discovery.ts
Normal file
71
extensions/tlon/src/monitor/discovery.ts
Normal file
@ -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<unknown> },
|
||||||
|
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<unknown> },
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
): Promise<string[]> {
|
||||||
|
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<string, any>)) {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
87
extensions/tlon/src/monitor/history.ts
Normal file
87
extensions/tlon/src/monitor/history.ts
Normal file
@ -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<string, TlonHistoryEntry[]>();
|
||||||
|
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<unknown> },
|
||||||
|
channelNest: string,
|
||||||
|
count = 50,
|
||||||
|
runtime?: RuntimeEnv,
|
||||||
|
): Promise<TlonHistoryEntry[]> {
|
||||||
|
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<unknown> },
|
||||||
|
channelNest: string,
|
||||||
|
count = 50,
|
||||||
|
runtime?: RuntimeEnv,
|
||||||
|
): Promise<TlonHistoryEntry[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
501
extensions/tlon/src/monitor/index.ts
Normal file
501
extensions/tlon/src/monitor/index.ts
Normal file
@ -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<string, ChannelAuthorization> };
|
||||||
|
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<void> {
|
||||||
|
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<RuntimeEnv["log"]>) => 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<string>();
|
||||||
|
const subscribedDMs = new Set<string>();
|
||||||
|
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
extensions/tlon/src/monitor/processed-messages.test.ts
Normal file
24
extensions/tlon/src/monitor/processed-messages.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
38
extensions/tlon/src/monitor/processed-messages.ts
Normal file
38
extensions/tlon/src/monitor/processed-messages.ts
Normal file
@ -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<string>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
83
extensions/tlon/src/monitor/utils.ts
Normal file
83
extensions/tlon/src/monitor/utils.ts
Normal file
@ -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<string, string> = {
|
||||||
|
"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`;
|
||||||
|
}
|
||||||
213
extensions/tlon/src/onboarding.ts
Normal file
213
extensions/tlon/src/onboarding.ts
Normal file
@ -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<string, unknown> }).accounts,
|
||||||
|
[accountId]: {
|
||||||
|
...((base as { accounts?: Record<string, Record<string, unknown>> }).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<void> {
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
14
extensions/tlon/src/runtime.ts
Normal file
14
extensions/tlon/src/runtime.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
79
extensions/tlon/src/targets.ts
Normal file
79
extensions/tlon/src/targets.ts
Normal file
@ -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";
|
||||||
|
}
|
||||||
85
extensions/tlon/src/types.ts
Normal file
85
extensions/tlon/src/types.ts
Normal file
@ -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<string, Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
| 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<string, unknown> | 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<string, Record<string, unknown>> }
|
||||||
|
| undefined;
|
||||||
|
if (!base) return [];
|
||||||
|
const accounts = base.accounts ?? {};
|
||||||
|
return [...(base.ship ? ["default"] : []), ...Object.keys(accounts)];
|
||||||
|
}
|
||||||
18
extensions/tlon/src/urbit/auth.ts
Normal file
18
extensions/tlon/src/urbit/auth.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export async function authenticate(url: string, code: string): Promise<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
36
extensions/tlon/src/urbit/http-api.ts
Normal file
36
extensions/tlon/src/urbit/http-api.ts
Normal file
@ -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 };
|
||||||
114
extensions/tlon/src/urbit/send.ts
Normal file
114
extensions/tlon/src/urbit/send.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { unixToDa, formatUd } from "@urbit/aura";
|
||||||
|
|
||||||
|
export type TlonPokeApi = {
|
||||||
|
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
41
extensions/tlon/src/urbit/sse-client.test.ts
Normal file
41
extensions/tlon/src/urbit/sse-client.test.ts
Normal file
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,59 +1,128 @@
|
|||||||
/**
|
import { Readable } from "node:stream";
|
||||||
* Custom SSE client for Urbit that works in Node.js
|
|
||||||
* Handles authentication cookies and streaming properly
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Readable } from "stream";
|
export type UrbitSseLogger = {
|
||||||
|
log?: (message: string) => void;
|
||||||
|
error?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UrbitSseOptions = {
|
||||||
|
ship?: string;
|
||||||
|
onReconnect?: (client: UrbitSSEClient) => Promise<void> | void;
|
||||||
|
autoReconnect?: boolean;
|
||||||
|
maxReconnectAttempts?: number;
|
||||||
|
reconnectDelay?: number;
|
||||||
|
maxReconnectDelay?: number;
|
||||||
|
logger?: UrbitSseLogger;
|
||||||
|
};
|
||||||
|
|
||||||
export class UrbitSSEClient {
|
export class UrbitSSEClient {
|
||||||
constructor(url, cookie, options = {}) {
|
url: string;
|
||||||
this.url = url;
|
cookie: string;
|
||||||
// Extract just the cookie value (first part before semicolon)
|
ship: string;
|
||||||
this.cookie = cookie.split(";")[0];
|
channelId: string;
|
||||||
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random()
|
channelUrl: string;
|
||||||
.toString(36)
|
subscriptions: Array<{
|
||||||
.substring(2, 8)}`;
|
id: number;
|
||||||
this.channelUrl = `${url}/~/channel/${this.channelId}`;
|
action: "subscribe";
|
||||||
this.subscriptions = [];
|
ship: string;
|
||||||
this.eventHandlers = new Map();
|
app: string;
|
||||||
this.aborted = false;
|
path: string;
|
||||||
this.streamController = null;
|
}> = [];
|
||||||
|
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
|
constructor(url: string, cookie: string, options: UrbitSseOptions = {}) {
|
||||||
this.onReconnect = options.onReconnect || null;
|
this.url = url;
|
||||||
this.autoReconnect = options.autoReconnect !== false; // Default true
|
this.cookie = cookie.split(";")[0];
|
||||||
this.reconnectAttempts = 0;
|
this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromUrl(url);
|
||||||
this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
|
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
this.reconnectDelay = options.reconnectDelay || 1000; // Start at 1s
|
this.channelUrl = `${url}/~/channel/${this.channelId}`;
|
||||||
this.maxReconnectDelay = options.maxReconnectDelay || 30000; // Max 30s
|
this.onReconnect = options.onReconnect ?? null;
|
||||||
this.isConnected = false;
|
this.autoReconnect = options.autoReconnect !== false;
|
||||||
|
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
|
||||||
|
this.reconnectDelay = options.reconnectDelay ?? 1000;
|
||||||
|
this.maxReconnectDelay = options.maxReconnectDelay ?? 30000;
|
||||||
|
this.logger = options.logger ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private resolveShipFromUrl(url: string): string {
|
||||||
* Subscribe to an Urbit path
|
try {
|
||||||
*/
|
const parsed = new URL(url);
|
||||||
async subscribe({ app, path, event, err, quit }) {
|
const host = parsed.hostname;
|
||||||
const subId = this.subscriptions.length + 1;
|
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,
|
id: subId,
|
||||||
action: "subscribe",
|
action: "subscribe",
|
||||||
ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
ship: this.ship,
|
||||||
app,
|
app: params.app,
|
||||||
path,
|
path: params.path,
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
// Store event handlers
|
this.subscriptions.push(subscription);
|
||||||
this.eventHandlers.set(subId, { event, err, quit });
|
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;
|
return subId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async sendSubscription(subscription: {
|
||||||
* Create the channel and start listening for events
|
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() {
|
async connect() {
|
||||||
// Create channel with all subscriptions
|
|
||||||
const createResp = await fetch(this.channelUrl, {
|
const createResp = await fetch(this.channelUrl, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
@ -67,8 +136,6 @@ export class UrbitSSEClient {
|
|||||||
throw new Error(`Channel creation failed: ${createResp.status}`);
|
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, {
|
const pokeResp = await fetch(this.channelUrl, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
@ -79,7 +146,7 @@ export class UrbitSSEClient {
|
|||||||
{
|
{
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
action: "poke",
|
action: "poke",
|
||||||
ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
ship: this.ship,
|
||||||
app: "hood",
|
app: "hood",
|
||||||
mark: "helm-hi",
|
mark: "helm-hi",
|
||||||
json: "Opening API channel",
|
json: "Opening API channel",
|
||||||
@ -91,15 +158,11 @@ export class UrbitSSEClient {
|
|||||||
throw new Error(`Channel activation failed: ${pokeResp.status}`);
|
throw new Error(`Channel activation failed: ${pokeResp.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open SSE stream
|
|
||||||
await this.openStream();
|
await this.openStream();
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
this.reconnectAttempts = 0; // Reset on successful connection
|
this.reconnectAttempts = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the SSE stream and process events
|
|
||||||
*/
|
|
||||||
async openStream() {
|
async openStream() {
|
||||||
const response = await fetch(this.channelUrl, {
|
const response = await fetch(this.channelUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@ -113,69 +176,47 @@ export class UrbitSSEClient {
|
|||||||
throw new Error(`Stream connection failed: ${response.status}`);
|
throw new Error(`Stream connection failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start processing the stream in the background (don't await)
|
|
||||||
this.processStream(response.body).catch((error) => {
|
this.processStream(response.body).catch((error) => {
|
||||||
if (!this.aborted) {
|
if (!this.aborted) {
|
||||||
console.error("Stream error:", error);
|
this.logger.error?.(`Stream error: ${String(error)}`);
|
||||||
// Notify all error handlers
|
|
||||||
for (const { err } of this.eventHandlers.values()) {
|
for (const { err } of this.eventHandlers.values()) {
|
||||||
if (err) err(error);
|
if (err) err(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stream is connected and running in background
|
|
||||||
// Return immediately so connect() can complete
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async processStream(body: ReadableStream<Uint8Array> | Readable | null) {
|
||||||
* Process the SSE stream (runs in background)
|
if (!body) return;
|
||||||
*/
|
const stream = body instanceof ReadableStream ? Readable.fromWeb(body) : body;
|
||||||
async processStream(body) {
|
|
||||||
const reader = body;
|
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
|
|
||||||
// Convert Web ReadableStream to Node Readable if needed
|
|
||||||
const stream =
|
|
||||||
reader instanceof ReadableStream ? Readable.fromWeb(reader) : reader;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
if (this.aborted) break;
|
if (this.aborted) break;
|
||||||
|
|
||||||
buffer += chunk.toString();
|
buffer += chunk.toString();
|
||||||
|
|
||||||
// Process complete SSE events
|
|
||||||
let eventEnd;
|
let eventEnd;
|
||||||
while ((eventEnd = buffer.indexOf("\n\n")) !== -1) {
|
while ((eventEnd = buffer.indexOf("\n\n")) !== -1) {
|
||||||
const eventData = buffer.substring(0, eventEnd);
|
const eventData = buffer.substring(0, eventEnd);
|
||||||
buffer = buffer.substring(eventEnd + 2);
|
buffer = buffer.substring(eventEnd + 2);
|
||||||
|
|
||||||
this.processEvent(eventData);
|
this.processEvent(eventData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Stream ended (either normally or due to error)
|
|
||||||
if (!this.aborted && this.autoReconnect) {
|
if (!this.aborted && this.autoReconnect) {
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
console.log("[SSE] Stream ended, attempting reconnection...");
|
this.logger.log?.("[SSE] Stream ended, attempting reconnection...");
|
||||||
await this.attemptReconnect();
|
await this.attemptReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
processEvent(eventData: string) {
|
||||||
* Process a single SSE event
|
|
||||||
*/
|
|
||||||
processEvent(eventData) {
|
|
||||||
const lines = eventData.split("\n");
|
const lines = eventData.split("\n");
|
||||||
let id = null;
|
let data: string | null = null;
|
||||||
let data = null;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("id: ")) {
|
if (line.startsWith("data: ")) {
|
||||||
id = line.substring(4);
|
|
||||||
} else if (line.startsWith("data: ")) {
|
|
||||||
data = line.substring(6);
|
data = line.substring(6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -183,61 +224,42 @@ export class UrbitSSEClient {
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
try {
|
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") {
|
if (parsed.response === "quit") {
|
||||||
console.log(`[SSE] Received quit event for subscription ${parsed.id}`);
|
if (parsed.id) {
|
||||||
const handlers = this.eventHandlers.get(parsed.id);
|
const handlers = this.eventHandlers.get(parsed.id);
|
||||||
if (handlers && handlers.quit) {
|
if (handlers?.quit) handlers.quit();
|
||||||
handlers.quit();
|
|
||||||
}
|
}
|
||||||
return;
|
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)) {
|
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) {
|
if (event && parsed.json) {
|
||||||
console.log(`[SSE] Calling handler for subscription ${parsed.id}`);
|
|
||||||
event(parsed.json);
|
event(parsed.json);
|
||||||
}
|
}
|
||||||
} else if (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()) {
|
for (const { event } of this.eventHandlers.values()) {
|
||||||
if (event) {
|
if (event) event(parsed.json);
|
||||||
event(parsed.json);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error parsing SSE event:", error);
|
this.logger.error?.(`Error parsing SSE event: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async poke(params: { app: string; mark: string; json: unknown }) {
|
||||||
* Send a poke to Urbit
|
|
||||||
*/
|
|
||||||
async poke({ app, mark, json }) {
|
|
||||||
const pokeId = Date.now();
|
const pokeId = Date.now();
|
||||||
|
|
||||||
const pokeData = {
|
const pokeData = {
|
||||||
id: pokeId,
|
id: pokeId,
|
||||||
action: "poke",
|
action: "poke",
|
||||||
ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""),
|
ship: this.ship,
|
||||||
app,
|
app: params.app,
|
||||||
mark,
|
mark: params.mark,
|
||||||
json,
|
json: params.json,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[SSE] Sending poke to ${app}:`, JSON.stringify(pokeData).substring(0, 300));
|
|
||||||
|
|
||||||
const response = await fetch(this.channelUrl, {
|
const response = await fetch(this.channelUrl, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
@ -247,23 +269,16 @@ export class UrbitSSEClient {
|
|||||||
body: JSON.stringify([pokeData]),
|
body: JSON.stringify([pokeData]),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[SSE] Poke response status: ${response.status}`);
|
|
||||||
|
|
||||||
if (!response.ok && response.status !== 204) {
|
if (!response.ok && response.status !== 204) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.log(`[SSE] Poke error body: ${errorText.substring(0, 500)}`);
|
|
||||||
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
|
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pokeId;
|
return pokeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async scry(path: string) {
|
||||||
* Perform a scry (read-only query) to Urbit
|
|
||||||
*/
|
|
||||||
async scry(path) {
|
|
||||||
const scryUrl = `${this.url}/~/scry${path}`;
|
const scryUrl = `${this.url}/~/scry${path}`;
|
||||||
|
|
||||||
const response = await fetch(scryUrl, {
|
const response = await fetch(scryUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
@ -278,70 +293,52 @@ export class UrbitSSEClient {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to reconnect with exponential backoff
|
|
||||||
*/
|
|
||||||
async attemptReconnect() {
|
async attemptReconnect() {
|
||||||
if (this.aborted || !this.autoReconnect) {
|
if (this.aborted || !this.autoReconnect) {
|
||||||
console.log("[SSE] Reconnection aborted or disabled");
|
this.logger.log?.("[SSE] Reconnection aborted or disabled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
console.error(
|
this.logger.error?.(
|
||||||
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`
|
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reconnectAttempts++;
|
this.reconnectAttempts += 1;
|
||||||
|
|
||||||
// Calculate delay with exponential backoff
|
|
||||||
const delay = Math.min(
|
const delay = Math.min(
|
||||||
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
||||||
this.maxReconnectDelay
|
this.maxReconnectDelay,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
this.logger.log?.(
|
||||||
`[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`
|
`[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
|
||||||
try {
|
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}`;
|
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) {
|
if (this.onReconnect) {
|
||||||
await this.onReconnect(this);
|
await this.onReconnect(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconnect
|
|
||||||
await this.connect();
|
await this.connect();
|
||||||
|
this.logger.log?.("[SSE] Reconnection successful!");
|
||||||
console.log("[SSE] Reconnection successful!");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SSE] Reconnection failed: ${error.message}`);
|
this.logger.error?.(`[SSE] Reconnection failed: ${String(error)}`);
|
||||||
// Try again
|
|
||||||
await this.attemptReconnect();
|
await this.attemptReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the connection
|
|
||||||
*/
|
|
||||||
async close() {
|
async close() {
|
||||||
this.aborted = true;
|
this.aborted = true;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Send unsubscribe for all subscriptions
|
|
||||||
const unsubscribes = this.subscriptions.map((sub) => ({
|
const unsubscribes = this.subscriptions.map((sub) => ({
|
||||||
id: sub.id,
|
id: sub.id,
|
||||||
action: "unsubscribe",
|
action: "unsubscribe",
|
||||||
@ -357,7 +354,6 @@ export class UrbitSSEClient {
|
|||||||
body: JSON.stringify(unsubscribes),
|
body: JSON.stringify(unsubscribes),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete the channel
|
|
||||||
await fetch(this.channelUrl, {
|
await fetch(this.channelUrl, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
@ -365,7 +361,7 @@ export class UrbitSSEClient {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error closing channel:", error);
|
this.logger.error?.(`Error closing channel: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@ -376,12 +376,23 @@ importers:
|
|||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.3.5
|
version: 4.3.5
|
||||||
|
|
||||||
|
extensions/open-prose: {}
|
||||||
|
|
||||||
extensions/signal: {}
|
extensions/signal: {}
|
||||||
|
|
||||||
extensions/slack: {}
|
extensions/slack: {}
|
||||||
|
|
||||||
extensions/telegram: {}
|
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:
|
extensions/voice-call:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
@ -2572,6 +2583,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==}
|
resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==}
|
||||||
engines: {node: '>=20.0.0'}
|
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':
|
'@vitest/browser-playwright@4.0.17':
|
||||||
resolution: {integrity: sha512-CE9nlzslHX6Qz//MVrjpulTC9IgtXTbJ+q7Rx1HD+IeSOWv4NHIRNHPA6dB4x01d9paEqt+TvoqZfmgq40DxEQ==}
|
resolution: {integrity: sha512-CE9nlzslHX6Qz//MVrjpulTC9IgtXTbJ+q7Rx1HD+IeSOWv4NHIRNHPA6dB4x01d9paEqt+TvoqZfmgq40DxEQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2862,6 +2880,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
browser-or-node@1.3.0:
|
||||||
|
resolution: {integrity: sha512-0F2z/VSnLbmEeBcUrSuDH5l0HxTXdQQzLjkmBR4cYfvg1zJrKSlmIZFqyFR8oX0NrwPhy3c3HQ6i3OxMbew4Tg==}
|
||||||
|
|
||||||
browser-or-node@3.0.0:
|
browser-or-node@3.0.0:
|
||||||
resolution: {integrity: sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==}
|
resolution: {integrity: sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==}
|
||||||
|
|
||||||
@ -3037,6 +3058,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
core-js@3.48.0:
|
||||||
|
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
|
||||||
|
|
||||||
core-util-is@1.0.2:
|
core-util-is@1.0.2:
|
||||||
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
|
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
|
||||||
|
|
||||||
@ -7756,6 +7780,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)':
|
'@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:
|
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)
|
'@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:
|
dependencies:
|
||||||
fill-range: 7.1.1
|
fill-range: 7.1.1
|
||||||
|
|
||||||
|
browser-or-node@1.3.0: {}
|
||||||
|
|
||||||
browser-or-node@3.0.0: {}
|
browser-or-node@3.0.0: {}
|
||||||
|
|
||||||
buffer-equal-constant-time@1.0.1: {}
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
@ -8309,6 +8343,8 @@ snapshots:
|
|||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
|
core-js@3.48.0: {}
|
||||||
|
|
||||||
core-util-is@1.0.2: {}
|
core-util-is@1.0.2: {}
|
||||||
|
|
||||||
core-util-is@1.0.3: {}
|
core-util-is@1.0.3: {}
|
||||||
|
|||||||
@ -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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
|
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
|
||||||
@ -13,4 +16,37 @@ describe("channel plugin catalog", () => {
|
|||||||
const ids = listChannelPluginCatalogEntries().map((entry) => entry.id);
|
const ids = listChannelPluginCatalogEntries().map((entry) => entry.id);
|
||||||
expect(ids).toContain("msteams");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { discoverClawdbotPlugins } from "../../plugins/discovery.js";
|
import { discoverClawdbotPlugins } from "../../plugins/discovery.js";
|
||||||
import type { PluginOrigin } from "../../plugins/types.js";
|
import type { PluginOrigin } from "../../plugins/types.js";
|
||||||
import type { ClawdbotPackageManifest } from "../../plugins/manifest.js";
|
import type { ClawdbotPackageManifest } from "../../plugins/manifest.js";
|
||||||
|
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
|
||||||
import type { ChannelMeta } from "./types.js";
|
import type { ChannelMeta } from "./types.js";
|
||||||
|
|
||||||
export type ChannelUiMetaEntry = {
|
export type ChannelUiMetaEntry = {
|
||||||
@ -33,6 +35,7 @@ export type ChannelPluginCatalogEntry = {
|
|||||||
|
|
||||||
type CatalogOptions = {
|
type CatalogOptions = {
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
|
catalogPaths?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
|
const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
|
||||||
@ -42,6 +45,74 @@ const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
|
|||||||
bundled: 3,
|
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<string, unknown> {
|
||||||
|
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: {
|
function toChannelMeta(params: {
|
||||||
channel: NonNullable<ClawdbotPackageManifest["channel"]>;
|
channel: NonNullable<ClawdbotPackageManifest["channel"]>;
|
||||||
id: string;
|
id: string;
|
||||||
@ -132,6 +203,13 @@ function buildCatalogEntry(candidate: {
|
|||||||
return { id, meta, install };
|
return { id, meta, install };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null {
|
||||||
|
return buildCatalogEntry({
|
||||||
|
packageName: entry.name,
|
||||||
|
packageClawdbot: entry.clawdbot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildChannelUiCatalog(
|
export function buildChannelUiCatalog(
|
||||||
plugins: Array<{ id: string; meta: ChannelMeta }>,
|
plugins: Array<{ id: string; meta: ChannelMeta }>,
|
||||||
): ChannelUiCatalog {
|
): 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())
|
return Array.from(resolved.values())
|
||||||
.map(({ entry }) => entry)
|
.map(({ entry }) => entry)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|||||||
@ -39,6 +39,12 @@ export type ChannelSetupInput = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
initialSyncLimit?: number;
|
initialSyncLimit?: number;
|
||||||
|
ship?: string;
|
||||||
|
url?: string;
|
||||||
|
code?: string;
|
||||||
|
groupChannels?: string[];
|
||||||
|
dmAllowlist?: string[];
|
||||||
|
autoDiscoverChannels?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelStatusIssue = {
|
export type ChannelStatusIssue = {
|
||||||
|
|||||||
@ -1,14 +1,29 @@
|
|||||||
|
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
|
||||||
import { CHAT_CHANNEL_ORDER } from "../channels/registry.js";
|
import { CHAT_CHANNEL_ORDER } from "../channels/registry.js";
|
||||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
import { isTruthyEnvValue } from "../infra/env.js";
|
import { isTruthyEnvValue } from "../infra/env.js";
|
||||||
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
|
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
|
||||||
|
|
||||||
|
function dedupe(values: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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[] {
|
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)) {
|
if (isTruthyEnvValue(process.env.CLAWDBOT_EAGER_CHANNEL_OPTIONS)) {
|
||||||
ensurePluginRegistryLoaded();
|
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 {
|
export function formatCliChannelOptions(extra: string[] = []): string {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
import { formatCliChannelOptions } from "./channel-options.js";
|
||||||
import {
|
import {
|
||||||
channelsAddCommand,
|
channelsAddCommand,
|
||||||
channelsCapabilitiesCommand,
|
channelsCapabilitiesCommand,
|
||||||
@ -42,6 +42,12 @@ const optionNamesAdd = [
|
|||||||
"password",
|
"password",
|
||||||
"deviceName",
|
"deviceName",
|
||||||
"initialSyncLimit",
|
"initialSyncLimit",
|
||||||
|
"ship",
|
||||||
|
"url",
|
||||||
|
"code",
|
||||||
|
"groupChannels",
|
||||||
|
"dmAllowlist",
|
||||||
|
"autoDiscoverChannels",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const optionNamesRemove = ["channel", "account", "delete"] as const;
|
const optionNamesRemove = ["channel", "account", "delete"] as const;
|
||||||
@ -58,9 +64,7 @@ function runChannelsCommandWithDanger(action: () => Promise<void>, label: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerChannelsCli(program: Command) {
|
export function registerChannelsCli(program: Command) {
|
||||||
const channelNames = listChannelPlugins()
|
const channelNames = formatCliChannelOptions();
|
||||||
.map((plugin) => plugin.id)
|
|
||||||
.join("|");
|
|
||||||
const channels = program
|
const channels = program
|
||||||
.command("channels")
|
.command("channels")
|
||||||
.description("Manage chat channel accounts")
|
.description("Manage chat channel accounts")
|
||||||
@ -99,7 +103,7 @@ export function registerChannelsCli(program: Command) {
|
|||||||
channels
|
channels
|
||||||
.command("capabilities")
|
.command("capabilities")
|
||||||
.description("Show provider capabilities (intents/scopes + supported features)")
|
.description("Show provider capabilities (intents/scopes + supported features)")
|
||||||
.option("--channel <name>", `Channel (${channelNames}|all)`)
|
.option("--channel <name>", `Channel (${formatCliChannelOptions(["all"])})`)
|
||||||
.option("--account <id>", "Account id (only with --channel)")
|
.option("--account <id>", "Account id (only with --channel)")
|
||||||
.option("--target <dest>", "Channel target for permission audit (Discord channel:<id>)")
|
.option("--target <dest>", "Channel target for permission audit (Discord channel:<id>)")
|
||||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||||
@ -136,7 +140,7 @@ export function registerChannelsCli(program: Command) {
|
|||||||
channels
|
channels
|
||||||
.command("logs")
|
.command("logs")
|
||||||
.description("Show recent channel logs from the gateway log file")
|
.description("Show recent channel logs from the gateway log file")
|
||||||
.option("--channel <name>", `Channel (${channelNames}|all)`, "all")
|
.option("--channel <name>", `Channel (${formatCliChannelOptions(["all"])})`, "all")
|
||||||
.option("--lines <n>", "Number of lines (default: 200)", "200")
|
.option("--lines <n>", "Number of lines (default: 200)", "200")
|
||||||
.option("--json", "Output JSON", false)
|
.option("--json", "Output JSON", false)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
@ -171,6 +175,13 @@ export function registerChannelsCli(program: Command) {
|
|||||||
.option("--password <password>", "Matrix password")
|
.option("--password <password>", "Matrix password")
|
||||||
.option("--device-name <name>", "Matrix device name")
|
.option("--device-name <name>", "Matrix device name")
|
||||||
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
|
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
|
||||||
|
.option("--ship <ship>", "Tlon ship name (~sampel-palnet)")
|
||||||
|
.option("--url <url>", "Tlon ship URL")
|
||||||
|
.option("--code <code>", "Tlon login code")
|
||||||
|
.option("--group-channels <list>", "Tlon group channels (comma-separated)")
|
||||||
|
.option("--dm-allowlist <list>", "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)
|
.option("--use-env", "Use env token (default account only)", false)
|
||||||
.action(async (opts, command) => {
|
.action(async (opts, command) => {
|
||||||
await runChannelsCommand(async () => {
|
await runChannelsCommand(async () => {
|
||||||
|
|||||||
@ -43,6 +43,12 @@ export function applyChannelAccountConfig(params: {
|
|||||||
password?: string;
|
password?: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
initialSyncLimit?: number;
|
initialSyncLimit?: number;
|
||||||
|
ship?: string;
|
||||||
|
url?: string;
|
||||||
|
code?: string;
|
||||||
|
groupChannels?: string[];
|
||||||
|
dmAllowlist?: string[];
|
||||||
|
autoDiscoverChannels?: boolean;
|
||||||
}): ClawdbotConfig {
|
}): ClawdbotConfig {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const plugin = getChannelPlugin(params.channel);
|
const plugin = getChannelPlugin(params.channel);
|
||||||
@ -71,6 +77,12 @@ export function applyChannelAccountConfig(params: {
|
|||||||
password: params.password,
|
password: params.password,
|
||||||
deviceName: params.deviceName,
|
deviceName: params.deviceName,
|
||||||
initialSyncLimit: params.initialSyncLimit,
|
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 });
|
return apply({ cfg: params.cfg, accountId, input });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||||
import type { ChannelId } from "../../channels/plugins/types.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 { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||||
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
||||||
import { setupChannels } from "../onboard-channels.js";
|
import { setupChannels } from "../onboard-channels.js";
|
||||||
import type { ChannelChoice } from "../onboard-types.js";
|
import type { ChannelChoice } from "../onboard-types.js";
|
||||||
|
import {
|
||||||
|
ensureOnboardingPluginInstalled,
|
||||||
|
reloadOnboardingPluginRegistry,
|
||||||
|
} from "../onboarding/plugin-install.js";
|
||||||
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
|
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
|
||||||
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
|
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
|
||||||
|
|
||||||
@ -34,8 +40,33 @@ export type ChannelsAddOptions = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
initialSyncLimit?: number | 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(
|
export async function channelsAddCommand(
|
||||||
opts: ChannelsAddOptions,
|
opts: ChannelsAddOptions,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
@ -43,6 +74,7 @@ export async function channelsAddCommand(
|
|||||||
) {
|
) {
|
||||||
const cfg = await requireValidConfig(runtime);
|
const cfg = await requireValidConfig(runtime);
|
||||||
if (!cfg) return;
|
if (!cfg) return;
|
||||||
|
let nextConfig = cfg;
|
||||||
|
|
||||||
const useWizard = shouldUseWizard(params);
|
const useWizard = shouldUseWizard(params);
|
||||||
if (useWizard) {
|
if (useWizard) {
|
||||||
@ -99,9 +131,31 @@ export async function channelsAddCommand(
|
|||||||
return;
|
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) {
|
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);
|
runtime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -113,7 +167,7 @@ export async function channelsAddCommand(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const accountId =
|
const accountId =
|
||||||
plugin.setup.resolveAccountId?.({ cfg, accountId: opts.account }) ??
|
plugin.setup.resolveAccountId?.({ cfg: nextConfig, accountId: opts.account }) ??
|
||||||
normalizeAccountId(opts.account);
|
normalizeAccountId(opts.account);
|
||||||
const useEnv = opts.useEnv === true;
|
const useEnv = opts.useEnv === true;
|
||||||
const initialSyncLimit =
|
const initialSyncLimit =
|
||||||
@ -122,8 +176,11 @@ export async function channelsAddCommand(
|
|||||||
: typeof opts.initialSyncLimit === "string" && opts.initialSyncLimit.trim()
|
: typeof opts.initialSyncLimit === "string" && opts.initialSyncLimit.trim()
|
||||||
? Number.parseInt(opts.initialSyncLimit, 10)
|
? Number.parseInt(opts.initialSyncLimit, 10)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const groupChannels = parseList(opts.groupChannels);
|
||||||
|
const dmAllowlist = parseList(opts.dmAllowlist);
|
||||||
|
|
||||||
const validationError = plugin.setup.validateInput?.({
|
const validationError = plugin.setup.validateInput?.({
|
||||||
cfg,
|
cfg: nextConfig,
|
||||||
accountId,
|
accountId,
|
||||||
input: {
|
input: {
|
||||||
name: opts.name,
|
name: opts.name,
|
||||||
@ -148,6 +205,12 @@ export async function channelsAddCommand(
|
|||||||
deviceName: opts.deviceName,
|
deviceName: opts.deviceName,
|
||||||
initialSyncLimit,
|
initialSyncLimit,
|
||||||
useEnv,
|
useEnv,
|
||||||
|
ship: opts.ship,
|
||||||
|
url: opts.url,
|
||||||
|
code: opts.code,
|
||||||
|
groupChannels,
|
||||||
|
dmAllowlist,
|
||||||
|
autoDiscoverChannels: opts.autoDiscoverChannels,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
@ -156,8 +219,8 @@ export async function channelsAddCommand(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextConfig = applyChannelAccountConfig({
|
nextConfig = applyChannelAccountConfig({
|
||||||
cfg,
|
cfg: nextConfig,
|
||||||
channel,
|
channel,
|
||||||
accountId,
|
accountId,
|
||||||
name: opts.name,
|
name: opts.name,
|
||||||
@ -182,6 +245,12 @@ export async function channelsAddCommand(
|
|||||||
deviceName: opts.deviceName,
|
deviceName: opts.deviceName,
|
||||||
initialSyncLimit,
|
initialSyncLimit,
|
||||||
useEnv,
|
useEnv,
|
||||||
|
ship: opts.ship,
|
||||||
|
url: opts.url,
|
||||||
|
code: opts.code,
|
||||||
|
groupChannels,
|
||||||
|
dmAllowlist,
|
||||||
|
autoDiscoverChannels: opts.autoDiscoverChannels,
|
||||||
});
|
});
|
||||||
|
|
||||||
await writeConfigFile(nextConfig);
|
await writeConfigFile(nextConfig);
|
||||||
|
|||||||
@ -111,7 +111,8 @@ async function collectChannelStatus(params: {
|
|||||||
}): Promise<ChannelStatusSummary> {
|
}): Promise<ChannelStatusSummary> {
|
||||||
const installedPlugins = listChannelPlugins();
|
const installedPlugins = listChannelPlugins();
|
||||||
const installedIds = new Set(installedPlugins.map((plugin) => plugin.id));
|
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),
|
(entry) => !installedIds.has(entry.id),
|
||||||
);
|
);
|
||||||
const statusEntries = await Promise.all(
|
const statusEntries = await Promise.all(
|
||||||
@ -388,7 +389,8 @@ export async function setupChannels(
|
|||||||
const core = listChatChannels();
|
const core = listChatChannels();
|
||||||
const installed = listChannelPlugins();
|
const installed = listChannelPlugins();
|
||||||
const installedIds = new Set(installed.map((plugin) => plugin.id));
|
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),
|
(entry) => !installedIds.has(entry.id),
|
||||||
);
|
);
|
||||||
const metaById = new Map<string, ChannelMeta>();
|
const metaById = new Map<string, ChannelMeta>();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user