diff --git a/docs/channels/convos.md b/docs/channels/convos.md deleted file mode 100644 index 34947c2e47fd..000000000000 --- a/docs/channels/convos.md +++ /dev/null @@ -1,168 +0,0 @@ ---- -summary: "Convos channel setup and configuration for E2E encrypted XMTP messaging" -read_when: - - Working on Convos channel features - - Setting up Convos integration -title: "Convos" ---- - -# Convos (XMTP) - -Convos provides E2E encrypted messaging via XMTP. OpenClaw uses the convos-node-sdk to communicate directly with the XMTP network. - -Status: supported via plugin. Group conversations, reactions. - -## Requirements - -- Convos iOS app for creating conversations and invites - -## Plugin required - -Convos ships as a plugin and is not bundled with the core install. - -Install via CLI (npm registry): - -```bash -openclaw plugins install @openclaw/convos -``` - -Local checkout (when running from a git repo): - -```bash -openclaw plugins install ./extensions/convos -``` - -## Setup - -### 1. Create a conversation and get invite link - -In the Convos iOS app: -1. Open a conversation (or create a new one) -2. Tap the "+" button -3. Tap the share button on the QR code that appears -4. Tap "Copy" to copy the invite URL (or AirDrop to your Mac) - -This conversation will be your "owner channel" where you communicate with OpenClaw. - -### 2. Configure OpenClaw - -Run the configuration wizard: - -```bash -openclaw configure -``` - -When prompted, paste the invite link. OpenClaw will: -1. Create a new XMTP identity -2. Join the conversation -3. Save the configuration - -Or manually add to `~/.openclaw/openclaw.json`: - -```json -{ - "channels": { - "convos": { - "enabled": true, - "privateKey": "", - "ownerConversationId": "" - } - } -} -``` - -## Configuration - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `enabled` | boolean | `true` | Enable/disable Convos | -| `privateKey` | string | auto | XMTP identity key (hex, auto-generated) | -| `env` | string | `production` | XMTP environment (production/dev) | -| `ownerConversationId` | string | - | Conversation for owner communication | -| `dmPolicy` | string | `pairing` | DM access policy | -| `debug` | boolean | `false` | Enable debug logging | - -## DM Policies - -- `pairing` (default): Unknown senders get a pairing code; owner approves -- `allowlist`: Only allow senders in `allowFrom` -- `open`: Accept all incoming DMs -- `disabled`: Ignore all DMs - -## Architecture - -``` -┌─────────────────────────────────────────┐ -│ OpenClaw Gateway │ -│ └── Convos Channel Plugin │ -│ └── convos-node-sdk │ -│ └── @xmtp/agent-sdk │ -└────────────────┬────────────────────────┘ - │ XMTP Protocol - ▼ -┌─────────────────────────────────────────┐ -│ XMTP Network │ -└─────────────────────────────────────────┘ -``` - -### Per-Conversation Identity - -Convos uses a unique XMTP inbox identity for each conversation. This provides: -- Complete isolation between conversations -- No cross-conversation tracking possible -- Compromise of one conversation doesn't affect others - -### Invite System - -Invite links are cryptographically signed and contain: -- Encrypted conversation ID -- Creator's identity for verification -- Optional expiration and single-use flags - -When joining: -1. OpenClaw decodes and verifies the invite signature -2. Sends a join request to the conversation creator -3. Creator approves and adds OpenClaw to the conversation -4. OpenClaw verifies the conversation matches the invite - -## Troubleshooting - -### Join pending - -Some conversations require the creator to approve join requests. Check the Convos iOS app for pending requests. - -### Invalid invite - -The invite link may be: -- Expired (if time-limited) -- Revoked (invite tag was rotated) -- Single-use and already claimed - -Generate a new invite from the Convos iOS app. - -### Connection issues - -If you see XMTP connection errors: -1. Check your network connectivity -2. Try setting `env: "dev"` for testing -3. Enable `debug: true` for detailed logs - -## Capabilities - -| Feature | Supported | -|---------|-----------| -| Group conversations | Yes | -| Direct messages | Yes | -| Reactions | Yes | -| Threads | No | -| Media/attachments | Not yet | -| E2E encryption | Yes (XMTP) | - -## Cross-Platform Deployment - -The Convos channel uses the convos-node-sdk which runs on any platform with Node.js support: -- macOS -- Linux (including containers) -- Windows - -This makes it suitable for deployment to Railway, Fly.io, or any containerized environment. diff --git a/docs/channels/xmtp.md b/docs/channels/xmtp.md new file mode 100644 index 000000000000..47f244d88947 --- /dev/null +++ b/docs/channels/xmtp.md @@ -0,0 +1,153 @@ +--- +summary: "XMTP channel setup and configuration for decentralized E2E encrypted messaging" +read_when: + - Working on XMTP channel features + - Setting up XMTP integration +title: "XMTP" +--- + +# XMTP + +The XMTP channel provides decentralized E2E encrypted messaging via the XMTP protocol. OpenClaw uses the XMTP plugin and `@xmtp/agent-sdk` to communicate with the XMTP network. + +Status: supported via plugin. Direct messages, group conversations, media (remote attachments). No reactions or threads. + +**One-click deploy:** [Deploy on Railway](https://railway.com/deploy/xmtp-openclaw-template?referralCode=UxaXte&utm_medium=integration&utm_source=template&utm_campaign=generic) (OpenClaw + XMTP, then use `/setup` in the browser). + +## Requirements + +- Wallet private key (or generate one via the configure wizard) +- DB encryption key for local XMTP storage (generated or provided) + +## Plugin required + +XMTP ships as a plugin and is not bundled with the core install. + +> **Note:** The XMTP plugin is currently published under the `@xmtp` scope, not `@openclaw`. Use the install command below until native bundling is available. + +Install via CLI (npm registry): + +```bash +openclaw plugins install @xmtp/openclaw +``` + +## Setup + +### 1. Configure OpenClaw + +Run the configuration wizard: + +```bash +openclaw configure +``` + +When prompted: + +1. Choose environment: **Production** or **Dev** +2. Choose keys: **Random** (generate new keys) or **Custom** (enter existing keys) +3. If random: the plugin generates a wallet key and DB encryption key, writes them to config and `~/.openclaw/.env` +4. If custom: enter your wallet private key and DB encryption key + +OpenClaw initializes the XMTP client and shows your **public address** (derived from the wallet key). Share this address so others can message your agent. + +### 2. Start the agent + +```bash +openclaw start +# or with logs: +openclaw start --debug +``` + +### 3. Manual config (optional) + +Add to `~/.openclaw/openclaw.json`: + +```json +{ + "channels": { + "xmtp": { + "enabled": true, + "walletKey": "", + "dbEncryptionKey": "", + "env": "production" + } + } +} +``` + +## Configuration + +| Field | Type | Default | Description | +| ----------------- | ------- | ------------ | ------------------------------------------------------------- | +| `enabled` | boolean | `true` | Enable/disable XMTP | +| `walletKey` | string | - | Wallet private key (hex or env var name) | +| `dbEncryptionKey` | string | - | DB encryption key for local XMTP storage | +| `env` | string | `production` | XMTP environment (production/dev) | +| `debug` | boolean | `false` | Enable debug logging | +| `dmPolicy` | string | `pairing` | DM access policy | +| `allowFrom` | array | - | Allowlist of addresses (when dmPolicy is allowlist) | +| `groupPolicy` | string | `open` | How group messages are handled | +| `groups` | array | - | Allowlist of conversation IDs (when groupPolicy is allowlist) | +| `textChunkLimit` | number | 4000 | Outbound text chunk size (chars) | +| `name` | string | - | Optional display name for this account | + +For multiple XMTP identities, use `channels.xmtp.accounts.` with the same fields per account. + +## DM Policies + +- `pairing` (default): Unknown senders get a pairing code; owner approves +- `allowlist`: Only allow senders in `allowFrom` +- `open`: Accept all incoming DMs +- `disabled`: Ignore all DMs + +## Group Policies + +- **`open`** (default): Accept messages from all groups. The `groups` array is ignored. +- **`disabled`**: Ignore all group messages. +- **`allowlist`**: Only accept messages from conversations listed in `groups`. Set `groups` to `["*"]` to allow all, or list specific conversation IDs. + +Example (allowlist only; `groups` is unused when policy is `open` or `disabled`): + +```json +{ + "channels": { + "xmtp": { + "groupPolicy": "allowlist", + "groups": ["*"] + } + } +} +``` + +## Wallet-based identity + +The XMTP plugin uses one identity per account (derived from the wallet key). The agent-sdk reads credentials from environment variables: `XMTP_WALLET_KEY`, `XMTP_DB_ENCRYPTION_KEY`, `XMTP_ENV`, `XMTP_DB_DIRECTORY`. The plugin writes keys to `~/.openclaw/.env` for `Agent.createFromEnv()`. + +## Test your agent + +Use [xmtp.chat](https://xmtp.chat/) — connect with a wallet, start a new conversation, enter your agent's XMTP address, and send a message. + +## Troubleshooting + +### Not configured + +Ensure `walletKey` and `dbEncryptionKey` are set. Run `openclaw configure` to set up XMTP. + +### Connection issues + +If you see XMTP connection errors: + +1. Check your network connectivity +2. Try setting `env: "dev"` for testing +3. Enable `debug: true` for detailed logs + +## Capabilities + +| Feature | Supported | +| ------------------- | ------------ | +| Group conversations | Yes | +| Direct messages | Yes | +| Reactions | No | +| Threads | No | +| Media/attachments | Yes (remote) | +| E2E encryption | Yes (XMTP) | diff --git a/extensions/convos/RAILWAY-MIGRATION.md b/extensions/convos/RAILWAY-MIGRATION.md deleted file mode 100644 index 0afd7402c24c..000000000000 --- a/extensions/convos/RAILWAY-MIGRATION.md +++ /dev/null @@ -1,115 +0,0 @@ -# Railway Template Migration: Convos Setup via HTTP API - -This document is for the agent updating the `clawdbot-railway-template` repo. -The OpenClaw Convos extension now exposes HTTP endpoints for setup, eliminating -the need for the template to bundle its own XMTP agent logic. - -## HTTP API - -The Convos plugin registers HTTP routes on the gateway server. All endpoints -accept and return JSON. The gateway listens on `http://127.0.0.1:18789` by -default. - -| Endpoint | Method | Body | Returns | Notes | -|----------|--------|------|---------|-------| -| `/convos/setup` | POST | `{ env?, name?, accountId? }` | `{ inviteUrl, conversationId, qrDataUrl }` | Creates XMTP identity + conversation in memory. `qrDataUrl` is a `data:image/png;base64,...` string ready for ``. | -| `/convos/setup/status` | GET | none | `{ active, joined, joinerInboxId }` | Poll every 3 seconds. `joined` becomes `true` when a user scans the invite and joins. | -| `/convos/setup/complete` | POST | none | `{ saved: true, conversationId }` | Persists the identity + conversation to config. Triggers a single gateway restart. Call only after `joined === true`. | - -Errors return HTTP 4xx/5xx with `{ error: "message" }`. - -The same methods are also available via WebSocket (`ws://127.0.0.1:18789`) -as gateway methods `convos.setup`, `convos.setup.status`, and -`convos.setup.complete` for use by the Control UI. - -## New Template Flow - -1. **Start the gateway** with minimal config (`gateway.mode=local` pre-set). - No Convos config needed yet -- the gateway starts without the channel. - -2. **Call `POST /convos/setup`** with optional `{ env, name }`. - - Returns `inviteUrl`, `conversationId`, and `qrDataUrl`. - - The setup agent stays running in memory to accept join requests. - - No config is written at this point, so there are no gateway restarts. - -3. **Display the QR code** in the `/setup` page. - - Use `qrDataUrl` directly as an `` attribute. - - No client-side QR library needed. - - Also display `inviteUrl` as a clickable/copyable link. - -4. **Poll `GET /convos/setup/status`** every 3 seconds. - - When `joined === true`, the user has scanned the QR and joined. - -5. **Call `POST /convos/setup/complete`** after join is confirmed. - - This writes the XMTP private key, conversation ID, and environment to config. - - The gateway restarts once with the complete Convos config. - - The normal Convos channel picks up the config and starts. - -6. **Configure remaining settings** (AI model, API key, etc.) via - `openclaw config set` or the config RPC, then restart the gateway. - -## What to Remove from the Template - -- **`convos-setup.js`** (or equivalent) -- all XMTP agent creation logic. -- **`@xmtp/agent-sdk`** and **`convos-node-sdk`** from `package.json` dependencies. -- Any code that calls `openclaw config set channels.convos.privateKey ...` directly. - Config writes are now handled by `/convos/setup/complete`. - -## Example: Calling from the Template Server - -```javascript -const GATEWAY = "http://127.0.0.1:18789"; - -// 1. Start setup -const setupRes = await fetch(`${GATEWAY}/convos/setup`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ env: "production", name: "My Bot" }), -}); -const setup = await setupRes.json(); -// setup.inviteUrl -- invite link -// setup.qrDataUrl -- data:image/png;base64,... for -// setup.conversationId - -// 2. Poll for join -const poll = setInterval(async () => { - const statusRes = await fetch(`${GATEWAY}/convos/setup/status`); - const status = await statusRes.json(); - if (status.joined) { - clearInterval(poll); - // 3. Save config - await fetch(`${GATEWAY}/convos/setup/complete`, { method: "POST" }); - console.log("Convos configured and running!"); - } -}, 3000); -``` - -## Architecture Change - -**Before (current template):** -1. Template creates its own XMTP agent (duplicated SDK logic) -2. Template saves config via `openclaw config set` -3. Template starts gateway -4. Multiple config writes cause cascading restarts - -**After:** -1. Template starts gateway (minimal config) -2. Template calls HTTP endpoints for Convos setup -3. Single config write after join confirmed -4. One clean restart - -All Convos SDK logic lives in the OpenClaw extension. The template just calls -HTTP endpoints. Template updates automatically benefit from new Convos features -without code changes. - -## Avoiding Restart Cascades - -**Do not run `openclaw config set` multiple times in sequence after the gateway -is running.** Each `config set` writes the config file and triggers a gateway -restart via SIGUSR1. Multiple writes in quick succession cause a cascade of -restarts. - -Instead, batch all config into a single write: -- Use `openclaw config set key1=val1 key2=val2 ...` (single command) -- Or write the config file once before starting the gateway -- Or use the `config.update` gateway method to batch changes diff --git a/extensions/convos/index.ts b/extensions/convos/index.ts deleted file mode 100644 index f8c4966f16f1..000000000000 --- a/extensions/convos/index.ts +++ /dev/null @@ -1,404 +0,0 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { emptyPluginConfigSchema, renderQrPngBase64 } from "openclaw/plugin-sdk"; -import type { ConvosSDKClient } from "./src/sdk-client.js"; -import { resolveConvosAccount, type CoreConfig } from "./src/accounts.js"; -import { convosPlugin } from "./src/channel.js"; -import { getConvosRuntime, setConvosRuntime, setConvosSetupActive } from "./src/runtime.js"; -import { resolveConvosDbPath } from "./src/sdk-client.js"; -import { setupConvosWithInvite } from "./src/setup.js"; - -// Module-level state for setup agent (accepts join requests during setup flow) -let setupAgent: ConvosSDKClient | null = null; -let setupJoinState = { joined: false, joinerInboxId: null as string | null }; -let setupCleanupTimer: ReturnType | null = null; - -// Deferred config: stored after setup, written on convos.setup.complete -let setupResult: { - privateKey: string; - conversationId: string; - env: "production" | "dev"; - accountId?: string; -} | null = null; - -// Cached setup response (so repeated calls don't destroy the running agent) -let cachedSetupResponse: { - inviteUrl: string; - conversationId: string; - qrDataUrl: string; -} | null = null; - -async function cleanupSetupAgent() { - if (setupCleanupTimer) { - clearTimeout(setupCleanupTimer); - setupCleanupTimer = null; - } - if (setupAgent) { - try { - await setupAgent.stop(); - } catch { - // Ignore cleanup errors - } - setupAgent = null; - } - cachedSetupResponse = null; - setConvosSetupActive(false); -} - -// --- Core handlers shared by WebSocket gateway methods and HTTP routes --- - -/** - * Delete the old XMTP DB directory for the current account/env/key. - * Only deletes if the resolved path is inside the expected stateDir prefix. - */ -function deleteOldDbFiles(accountId?: string, env?: "production" | "dev") { - try { - const runtime = getConvosRuntime(); - const cfg = runtime.config.loadConfig() as OpenClawConfig; - const account = resolveConvosAccount({ cfg: cfg as CoreConfig, accountId }); - if (!account.privateKey) return; - - const stateDir = runtime.state.resolveStateDir(); - const dbPath = resolveConvosDbPath({ - stateDir, - env: env ?? account.env, - accountId: account.accountId, - privateKey: account.privateKey, - }); - - // Delete the hash directory (parent of xmtp.db file) - const hashDir = path.dirname(dbPath); - const safePrefix = path.join(stateDir, "convos", "xmtp"); - if (!hashDir.startsWith(safePrefix)) { - console.error(`[convos-reset] Refusing to delete path outside safe prefix: ${hashDir}`); - return; - } - - fs.rmSync(hashDir, { recursive: true, force: true }); - console.log(`[convos-reset] Deleted old DB directory: ${hashDir}`); - } catch (err) { - console.error(`[convos-reset] Failed to delete old DB files:`, err); - } -} - -async function handleSetup(params: { - accountId?: string; - env?: "production" | "dev"; - name?: string; - force?: boolean; - forceNewKey?: boolean; - deleteDb?: boolean; -}) { - // If a setup agent is already running and we have a cached response, return it - // (prevents repeated calls from destroying the listening agent) - if (!params.force && setupAgent?.isRunning() && cachedSetupResponse) { - console.log("[convos-setup] Returning cached setup (agent already running)"); - return cachedSetupResponse; - } - - await cleanupSetupAgent(); - setupJoinState = { joined: false, joinerInboxId: null }; - cachedSetupResponse = null; - - // Optionally delete old XMTP DB files before starting fresh setup - if (params.deleteDb) { - deleteOldDbFiles(params.accountId, params.env); - } - - const result = await setupConvosWithInvite({ - accountId: params.accountId, - env: params.env, - name: params.name, - forceNewKey: params.forceNewKey, - keepRunning: true, - onInvite: async (ctx) => { - console.log(`[convos-setup] Join request from ${ctx.joinerInboxId}`); - try { - await ctx.accept(); - setupJoinState = { joined: true, joinerInboxId: ctx.joinerInboxId }; - console.log(`[convos-setup] Accepted join from ${ctx.joinerInboxId}`); - } catch (err) { - console.error(`[convos-setup] Failed to accept join:`, err); - } - }, - }); - - if (result.client) { - setupAgent = result.client; - setConvosSetupActive(true); - console.log("[convos-setup] Agent kept running to accept join requests"); - setupCleanupTimer = setTimeout( - async () => { - console.log("[convos-setup] Timeout - stopping setup agent"); - setupResult = null; - await cleanupSetupAgent(); - }, - 10 * 60 * 1000, - ); - } - - setupResult = { - privateKey: result.privateKey, - conversationId: result.conversationId, - env: params.env ?? "production", - accountId: params.accountId, - }; - - const qrBase64 = await renderQrPngBase64(result.inviteUrl); - - cachedSetupResponse = { - inviteUrl: result.inviteUrl, - conversationId: result.conversationId, - qrDataUrl: `data:image/png;base64,${qrBase64}`, - }; - - return cachedSetupResponse; -} - -function handleStatus() { - return { - active: setupAgent !== null, - joined: setupJoinState.joined, - joinerInboxId: setupJoinState.joinerInboxId, - }; -} - -async function handleCancel() { - const wasActive = setupAgent !== null; - setupResult = null; - await cleanupSetupAgent(); - setupJoinState = { joined: false, joinerInboxId: null }; - return { cancelled: wasActive }; -} - -async function handleComplete() { - if (!setupResult) { - throw new Error("No active setup to complete. Run convos.setup first."); - } - - const runtime = getConvosRuntime(); - const cfg = runtime.config.loadConfig() as OpenClawConfig; - - const existingChannels = (cfg as Record).channels as - | Record - | undefined; - const existingConvos = (existingChannels?.convos ?? {}) as Record; - - // Auto-add the joiner's inbox ID to allowFrom so the operator can - // message the agent immediately after setup (no pairing prompt). - const existingAllowFrom = ( - Array.isArray(existingConvos.allowFrom) ? existingConvos.allowFrom : [] - ) as Array; - const joinerInboxId = setupJoinState.joinerInboxId; - const allowFrom = - joinerInboxId && !existingAllowFrom.includes(joinerInboxId) - ? [...existingAllowFrom, joinerInboxId] - : existingAllowFrom; - - const updatedCfg = { - ...cfg, - channels: { - ...existingChannels, - convos: { - ...existingConvos, - privateKey: setupResult.privateKey, - ownerConversationId: setupResult.conversationId, - env: setupResult.env, - enabled: true, - ...(allowFrom.length > 0 ? { allowFrom } : {}), - }, - }, - }; - - await runtime.config.writeConfigFile(updatedCfg); - console.log("[convos-setup] Config saved successfully"); - - const saved = { ...setupResult }; - setupResult = null; - await cleanupSetupAgent(); - - return { saved: true, conversationId: saved.conversationId }; -} - -// --- HTTP helpers --- - -async function readJsonBody(req: IncomingMessage): Promise> { - const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(chunk as Buffer); - const raw = Buffer.concat(chunks).toString(); - if (!raw.trim()) return {}; - return JSON.parse(raw) as Record; -} - -function jsonResponse(res: ServerResponse, status: number, body: unknown) { - res.statusCode = status; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify(body)); -} - -// --- Plugin --- - -const plugin = { - id: "convos", - name: "Convos", - description: "E2E encrypted messaging via XMTP", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - setConvosRuntime(api.runtime); - api.registerChannel({ plugin: convosPlugin }); - - // ---- WebSocket gateway methods (for Control UI) ---- - - api.registerGatewayMethod("convos.setup", async ({ params, respond }) => { - try { - const p = params as Record; - const result = await handleSetup({ - accountId: typeof p.accountId === "string" ? p.accountId : undefined, - env: typeof p.env === "string" ? (p.env as "production" | "dev") : undefined, - name: typeof p.name === "string" ? p.name : undefined, - force: p.force === true, - }); - respond(true, result, undefined); - } catch (err) { - await cleanupSetupAgent(); - respond(false, undefined, { - code: -1, - message: err instanceof Error ? err.message : String(err), - }); - } - }); - - api.registerGatewayMethod("convos.setup.status", async ({ respond }) => { - respond(true, handleStatus(), undefined); - }); - - api.registerGatewayMethod("convos.setup.complete", async ({ respond }) => { - try { - const result = await handleComplete(); - respond(true, result, undefined); - } catch (err) { - respond(false, undefined, { - code: -1, - message: err instanceof Error ? err.message : String(err), - }); - } - }); - - api.registerGatewayMethod("convos.setup.cancel", async ({ respond }) => { - const result = await handleCancel(); - respond(true, result, undefined); - }); - - api.registerGatewayMethod("convos.reset", async ({ params, respond }) => { - try { - const p = params as Record; - const result = await handleSetup({ - accountId: typeof p.accountId === "string" ? p.accountId : undefined, - env: typeof p.env === "string" ? (p.env as "production" | "dev") : undefined, - force: true, - forceNewKey: true, - deleteDb: p.deleteDb === true, - }); - respond(true, result, undefined); - } catch (err) { - await cleanupSetupAgent(); - respond(false, undefined, { - code: -1, - message: err instanceof Error ? err.message : String(err), - }); - } - }); - - // ---- HTTP routes (for Railway template and other HTTP clients) ---- - - api.registerHttpRoute({ - path: "/convos/setup", - handler: async (req, res) => { - if (req.method !== "POST") { - jsonResponse(res, 405, { error: "Method Not Allowed" }); - return; - } - try { - const body = await readJsonBody(req); - const result = await handleSetup({ - accountId: typeof body.accountId === "string" ? body.accountId : undefined, - env: typeof body.env === "string" ? (body.env as "production" | "dev") : undefined, - name: typeof body.name === "string" ? body.name : undefined, - force: body.force === true, - }); - jsonResponse(res, 200, result); - } catch (err) { - await cleanupSetupAgent(); - jsonResponse(res, 500, { error: err instanceof Error ? err.message : String(err) }); - } - }, - }); - - api.registerHttpRoute({ - path: "/convos/setup/status", - handler: async (req, res) => { - if (req.method !== "GET") { - jsonResponse(res, 405, { error: "Method Not Allowed" }); - return; - } - jsonResponse(res, 200, handleStatus()); - }, - }); - - api.registerHttpRoute({ - path: "/convos/setup/complete", - handler: async (req, res) => { - if (req.method !== "POST") { - jsonResponse(res, 405, { error: "Method Not Allowed" }); - return; - } - try { - const result = await handleComplete(); - jsonResponse(res, 200, result); - } catch (err) { - jsonResponse(res, 400, { error: err instanceof Error ? err.message : String(err) }); - } - }, - }); - - api.registerHttpRoute({ - path: "/convos/setup/cancel", - handler: async (req, res) => { - if (req.method !== "POST") { - jsonResponse(res, 405, { error: "Method Not Allowed" }); - return; - } - const result = await handleCancel(); - jsonResponse(res, 200, result); - }, - }); - - api.registerHttpRoute({ - path: "/convos/reset", - handler: async (req, res) => { - if (req.method !== "POST") { - jsonResponse(res, 405, { error: "Method Not Allowed" }); - return; - } - try { - const body = await readJsonBody(req); - const result = await handleSetup({ - accountId: typeof body.accountId === "string" ? body.accountId : undefined, - env: typeof body.env === "string" ? (body.env as "production" | "dev") : undefined, - force: true, - forceNewKey: true, - deleteDb: body.deleteDb === true, - }); - jsonResponse(res, 200, result); - } catch (err) { - await cleanupSetupAgent(); - jsonResponse(res, 500, { error: err instanceof Error ? err.message : String(err) }); - } - }, - }); - }, -}; - -export default plugin; diff --git a/extensions/convos/package.json b/extensions/convos/package.json deleted file mode 100644 index bc663bc605c2..000000000000 --- a/extensions/convos/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@openclaw/convos", - "version": "2026.2.2", - "description": "OpenClaw Convos channel plugin (E2E encrypted messaging via XMTP)", - "type": "module", - "dependencies": { - "convos-node-sdk": "github:xmtplabs/convos-node-sdk", - "@xmtp/agent-sdk": "^2.0.1", - "zod": "^4.3.6" - }, - "devDependencies": { - "openclaw": "workspace:*" - }, - "openclaw": { - "extensions": [ - "./index.ts" - ], - "channel": { - "id": "convos", - "label": "Convos", - "selectionLabel": "Convos (XMTP)", - "docsPath": "/channels/convos", - "docsLabel": "convos", - "blurb": "E2E encrypted messaging via XMTP", - "systemImage": "lock.shield.fill", - "order": 75, - "quickstartAllowFrom": false - }, - "install": { - "npmSpec": "@openclaw/convos", - "localPath": "extensions/convos", - "defaultChoice": "local" - } - } -} diff --git a/extensions/convos/skills/convos-channel.md b/extensions/convos/skills/convos-channel.md deleted file mode 100644 index fad30cb4a2b3..000000000000 --- a/extensions/convos/skills/convos-channel.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -name: convos-channel -description: How to use Convos for E2E encrypted messaging via XMTP -read_when: - - Working with Convos channel - - Creating or managing XMTP conversations - - Inviting users to conversations - - Understanding Convos architecture ---- - -# Convos Channel Guide - -Convos provides E2E encrypted messaging via XMTP using the convos-node-sdk. This guide explains how OpenClaw can use Convos to communicate with users. - -## Architecture Overview - -### SDK-Based Implementation -OpenClaw uses convos-node-sdk directly (no external daemon required): -- Runs in-process within the gateway -- Cross-platform (macOS, Linux, Windows) -- Private keys stored in config file - -### Per-Conversation Identity -Each Convos conversation has its own unique XMTP inbox identity. This means: -- No single identity is reused across conversations -- Cross-conversation correlation/tracking is impossible -- Each conversation is cryptographically isolated -- Compromising one conversation doesn't affect others - -### Owner Channel -When OpenClaw is connected to Convos, there's a special "owner conversation" where you communicate with OpenClaw's operator. This conversation is set during onboarding when the owner pastes an invite link. - -The owner conversation ID is stored in `channels.convos.ownerConversationId`. - -## Conversation Operations - -### Listing Conversations -Use the SDK client to list available conversations: -``` -client.listConversations() → [{ id, displayName, memberCount, ... }] -``` - -### Creating New Conversations -OpenClaw can create new conversations for specific purposes: -``` -client.createConversation(name?) → { conversationId, inviteSlug } -``` - -Use cases: -- Creating a dedicated conversation for a project/topic -- Separating concerns (work vs personal vs alerts) -- Onboarding new users with fresh conversations - -### Generating Invites -To invite someone to a conversation: -``` -client.getInvite(conversationId) → { inviteSlug } -``` - -The invite URL format is: `https://convos.app/join/` - -Invites are: -- Cryptographically signed by the conversation creator -- Revocable by updating the conversation's invite tag -- Optionally time-limited or single-use - -### Sending Messages -``` -client.sendMessage(conversationId, message) → { success } -``` - -### Adding Reactions -``` -client.react(conversationId, messageId, emoji, remove?) → { success, action } -``` - -## Communication Guidelines - -### Owner Channel -The owner conversation (`ownerConversationId`) is your primary communication channel with the operator. Use it for: -- Status updates and notifications -- Requesting approvals for actions -- Reporting errors or issues -- Asking clarifying questions - -### Creating Purpose-Specific Conversations -When the operator asks you to communicate with others or manage different topics: -1. Create a new conversation with a descriptive name -2. Generate an invite link -3. Share the invite with the intended recipients -4. Use that conversation for its designated purpose - -Example workflow: -``` -"I need you to coordinate with my team on Project X" - -1. Create conversation: client.createConversation("Project X Coordination") -2. Get invite: client.getInvite(newConversationId) -3. Reply: "I've created a conversation for Project X. - Share this invite link with your team: https://convos.app/join/..." -4. Use that conversation for all Project X discussions -``` - -### Privacy Considerations -- Each conversation has an isolated identity - users in one conversation cannot link you to another -- Display names and avatars are per-conversation (no global profile) -- The owner can configure different personas in different conversations - -## Message Targeting - -When sending messages via Convos: -- Target by conversation ID (32-char hex): `a3aa5c564c072b6be8478409d72aa091` -- The ID is returned when creating or listing conversations -- To reply in the owner channel, use the `ownerConversationId` from config - -## Error Handling - -Common scenarios: -- **Invalid invite**: The invite may be expired or revoked -- **Join pending**: Some joins require approval from the conversation creator -- **Connection issues**: Check network, try `env: "dev"` for testing - -## Configuration Reference - -```json -{ - "channels": { - "convos": { - "enabled": true, - "privateKey": "0x...", - "env": "production", - "ownerConversationId": "abc123...", - "dmPolicy": "pairing" - } - } -} -``` - -Key fields: -- `privateKey`: XMTP identity key (hex, auto-generated on first run) -- `env`: XMTP environment (production/dev) -- `ownerConversationId`: The conversation for operator communication -- `dmPolicy`: Sender access policy (pairing/allowlist/open/disabled). Controls who can message the agent in group conversations. diff --git a/extensions/convos/src/accounts.ts b/extensions/convos/src/accounts.ts deleted file mode 100644 index 9880118161b8..000000000000 --- a/extensions/convos/src/accounts.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; -import type { ConvosConfig } from "./config-types.js"; - -export type CoreConfig = { - channels?: { - convos?: ConvosConfig; - }; - [key: string]: unknown; -}; - -export type ResolvedConvosAccount = { - accountId: string; - enabled: boolean; - name?: string; - configured: boolean; - /** Hex-encoded XMTP private key (undefined until first run) */ - privateKey?: string; - /** XMTP environment */ - env: "production" | "dev"; - debug: boolean; - /** Owner conversation ID for operator communication */ - ownerConversationId?: string; - config: ConvosConfig; -}; - -export function listConvosAccountIds(_cfg: CoreConfig): string[] { - return [DEFAULT_ACCOUNT_ID]; -} - -export function resolveDefaultConvosAccountId(cfg: CoreConfig): string { - const ids = listConvosAccountIds(cfg); - if (ids.includes(DEFAULT_ACCOUNT_ID)) { - return DEFAULT_ACCOUNT_ID; - } - return ids[0] ?? DEFAULT_ACCOUNT_ID; -} - -export function resolveConvosAccount(params: { - cfg: CoreConfig; - accountId?: string | null; -}): ResolvedConvosAccount { - const accountId = normalizeAccountId(params.accountId); - const base = params.cfg.channels?.convos ?? {}; - const enabled = base.enabled !== false; - - // Convos is "configured" if we have a private key (identity established) - const configured = Boolean(base.privateKey); - - return { - accountId, - enabled, - name: base.name?.trim() || undefined, - configured, - privateKey: base.privateKey, - env: base.env ?? "production", - debug: base.debug ?? false, - ownerConversationId: base.ownerConversationId, - config: base, - }; -} - -export function listEnabledConvosAccounts(cfg: CoreConfig): ResolvedConvosAccount[] { - return listConvosAccountIds(cfg) - .map((accountId) => resolveConvosAccount({ cfg, accountId })) - .filter((account) => account.enabled); -} diff --git a/extensions/convos/src/actions.ts b/extensions/convos/src/actions.ts deleted file mode 100644 index c4510ea76350..000000000000 --- a/extensions/convos/src/actions.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "openclaw/plugin-sdk"; -import { jsonResult, readStringParam, readReactionParams } from "openclaw/plugin-sdk"; -import { listConvosAccountIds, resolveConvosAccount, type CoreConfig } from "./accounts.js"; -import { getClientForAccount } from "./outbound.js"; - -export const convosMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { - const ids = listConvosAccountIds(cfg as CoreConfig); - if (ids.length === 0) { - return []; - } - const actions: ChannelMessageActionName[] = ["send", "react", "channel-create", "channel-join"]; - return actions; - }, - - supportsButtons: () => false, - - handleAction: async ({ action, params, cfg, accountId }) => { - const account = resolveConvosAccount({ cfg: cfg as CoreConfig, accountId }); - const client = getClientForAccount(account.accountId); - if (!client) { - throw new Error(`Convos client not running for account ${account.accountId}`); - } - - if (action === "send") { - const to = readStringParam(params, "to", { required: true }); - const message = readStringParam(params, "message", { required: true, allowEmpty: true }); - const result = await client.sendMessage(to!, message!); - return jsonResult({ ok: true, to, messageId: result.messageId ?? `convos-${Date.now()}` }); - } - - if (action === "react") { - const conversationId = readStringParam(params, "conversationId", { required: true }); - const messageId = readStringParam(params, "messageId", { required: true }); - const { emoji, remove } = readReactionParams(params, { - removeErrorMessage: "Emoji is required to remove a Convos reaction.", - }); - const result = await client.react(conversationId!, messageId!, emoji, remove); - return jsonResult({ ok: true, action: result.action, emoji }); - } - - if (action === "channel-create") { - const name = readStringParam(params, "name") ?? undefined; - const result = await client.createConversation(name); - return jsonResult({ - ok: true, - conversationId: result.conversationId, - inviteUrl: result.inviteUrl, - }); - } - - if (action === "channel-join") { - const inviteUrl = readStringParam(params, "inviteUrl", { required: true }); - const result = await client.joinConversation(inviteUrl!); - return jsonResult({ - ok: true, - status: result.status, - conversationId: result.conversationId, - }); - } - - throw new Error(`Action "${action}" is not supported for Convos.`); - }, -}; diff --git a/extensions/convos/src/channel.ts b/extensions/convos/src/channel.ts deleted file mode 100644 index 68314bec90cd..000000000000 --- a/extensions/convos/src/channel.ts +++ /dev/null @@ -1,524 +0,0 @@ -import { - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, - type ChannelPlugin, - type OpenClawConfig, - type PluginRuntime, - type ReplyPayload, -} from "openclaw/plugin-sdk"; -import { - listConvosAccountIds, - resolveConvosAccount, - resolveDefaultConvosAccountId, - type CoreConfig, - type ResolvedConvosAccount, -} from "./accounts.js"; -import { convosMessageActions } from "./actions.js"; -import { convosChannelConfigSchema } from "./config-schema.js"; -import { convosOnboardingAdapter } from "./onboarding.js"; -import { convosOutbound, getClientForAccount, setClientForAccount } from "./outbound.js"; -import { getConvosRuntime } from "./runtime.js"; -import { ConvosSDKClient, resolveConvosDbPath, type InboundMessage } from "./sdk-client.js"; - -type RuntimeLogger = { - info: (msg: string) => void; - error: (msg: string) => void; -}; - -const meta = { - id: "convos", - label: "Convos", - selectionLabel: "Convos (XMTP)", - docsPath: "/channels/convos", - docsLabel: "convos", - blurb: "E2E encrypted messaging via XMTP", - systemImage: "lock.shield.fill", - order: 75, - quickstartAllowFrom: false, -}; - -function normalizeConvosMessagingTarget(raw: string): string | undefined { - let normalized = raw.trim(); - if (!normalized) { - return undefined; - } - const lowered = normalized.toLowerCase(); - if (lowered.startsWith("convos:")) { - normalized = normalized.slice("convos:".length).trim(); - } - return normalized || undefined; -} - -export const convosPlugin: ChannelPlugin = { - id: "convos", - meta, - capabilities: { - chatTypes: ["group"], - reactions: true, - threads: false, - media: false, - }, - reload: { configPrefixes: ["channels.convos"] }, - configSchema: convosChannelConfigSchema, - onboarding: convosOnboardingAdapter, - actions: convosMessageActions, - agentPrompt: { - messageToolHints: () => [ - "- Convos targets are conversation IDs (UUIDs). Use `to=` for `action=send`.", - "- For reactions, use `action=react` with `conversationId`, `messageId`, and `emoji`.", - "- To create a new Convos group: use `action=channel-create` with optional `name`. Returns `conversationId` and `inviteUrl`.", - "- To join a Convos invite: use `action=channel-join` with `inviteUrl=`. Returns join status.", - "- Note: if groupPolicy=allowlist, add the new conversationId to channels.convos.groups for the bot to respond in it.", - ], - }, - config: { - listAccountIds: (cfg) => listConvosAccountIds(cfg as CoreConfig), - resolveAccount: (cfg, accountId) => resolveConvosAccount({ cfg: cfg as CoreConfig, accountId }), - defaultAccountId: (cfg) => resolveDefaultConvosAccountId(cfg as CoreConfig), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg as CoreConfig, - sectionKey: "convos", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg as CoreConfig, - sectionKey: "convos", - accountId, - clearBaseFields: ["name", "privateKey", "env", "debug", "ownerConversationId"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - env: account.env, - }), - }, - security: { - resolveDmPolicy: ({ account }) => ({ - policy: account.config.dmPolicy ?? "pairing", - allowFrom: account.config.allowFrom ?? [], - policyPath: "channels.convos.dmPolicy", - allowFromPath: "channels.convos.allowFrom", - }), - }, - pairing: { - idLabel: "inbox ID", - normalizeAllowEntry: (entry) => { - const trimmed = entry.trim(); - if (!trimmed) return trimmed; - // Remove convos: prefix if present for storage - if (trimmed.toLowerCase().startsWith("convos:")) { - return trimmed.slice("convos:".length).trim(); - } - return trimmed; - }, - notifyApproval: async ({ cfg, id, runtime }) => { - const account = resolveConvosAccount({ cfg: cfg as CoreConfig }); - const client = getClientForAccount(account.accountId); - if (!client || !account.ownerConversationId) { - return; - } - try { - await client.sendMessage( - account.ownerConversationId, - `✅ Device paired successfully (inbox: ${id.slice(0, 12)}...)`, - ); - } catch { - // Ignore notification errors - } - }, - }, - messaging: { - normalizeTarget: normalizeConvosMessagingTarget, - targetResolver: { - looksLikeId: (raw) => { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - // Convos conversation IDs are hex strings (32 chars) or UUIDs (36 chars with dashes) - return ( - /^[0-9a-f]{32}$/i.test(trimmed) || - /^[0-9a-f-]{36}$/i.test(trimmed) || - trimmed.includes("/") - ); - }, - hint: "", - }, - }, - directory: { - self: async () => null, - listPeers: async () => [], // Convos doesn't have a user directory - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveConvosAccount({ cfg: cfg as CoreConfig, accountId }); - const client = getClientForAccount(account.accountId); - if (!client) { - return []; - } - try { - const conversations = await client.listConversations(); - const q = query?.trim().toLowerCase() ?? ""; - return conversations - .filter((conv) => !q || conv.displayName.toLowerCase().includes(q)) - .slice(0, limit ?? 50) - .map((conv) => ({ - kind: "group" as const, - id: conv.id, - name: conv.displayName, - })); - } catch { - return []; - } - }, - }, - outbound: convosOutbound, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; - if (!lastError) { - return []; - } - return [ - { - channel: "convos", - accountId: account.accountId, - kind: "runtime", - message: `Channel error: ${lastError}`, - }, - ]; - }), - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - env: snapshot.env ?? "production", - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), - probeAccount: async ({ account }) => { - if (!account.privateKey) { - return { - ok: false, - error: "Not configured: no private key. Run 'openclaw configure' to set up Convos.", - }; - } - - // Check the live runtime client instead of creating a temporary one. - // Creating throwaway XMTP clients burns installation slots (10 max). - const client = getClientForAccount(account.accountId); - if (client?.isRunning()) { - return { ok: true }; - } - - return { - ok: false, - error: "Convos client is not running. Restart the gateway to reconnect.", - }; - }, - buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - env: account.env, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastProbeAt: runtime?.lastProbeAt ?? null, - }), - }, - gateway: { - startAccount: async (ctx) => { - const { account, abortSignal, setStatus, log } = ctx; - const runtime = getConvosRuntime(); - - if (!account.privateKey) { - throw new Error( - "Convos not configured: no private key. Run 'openclaw configure' to set up Convos.", - ); - } - - setStatus({ - accountId: account.accountId, - env: account.env, - }); - - log?.info(`[${account.accountId}] starting Convos provider (env: ${account.env})`); - - // Compute a deterministic dbPath under the OpenClaw state directory so - // the XMTP local DB survives restarts but rotates when the key changes. - const stateDir = runtime.state.resolveStateDir(); - const dbPath = resolveConvosDbPath({ - stateDir, - env: account.env, - accountId: account.accountId, - privateKey: account.privateKey, - }); - log?.info( - `[${account.accountId}] XMTP stateDir: ${stateDir}, cwd: ${process.cwd()}, dbPath: ${dbPath}`, - ); - - // Create SDK client with message handling - const client = await ConvosSDKClient.create({ - privateKey: account.privateKey, - env: account.env, - dbPath, - debug: account.debug, - onMessage: (msg: InboundMessage) => { - // Handle async message processing with error logging - handleInboundMessage(account, msg, runtime, log).catch((err) => { - log?.error(`[${account.accountId}] Message handling failed: ${String(err)}`); - }); - }, - onInvite: async (inviteCtx) => { - // Auto-accept invites for now - // TODO: Add policy-based handling - log?.info(`[${account.accountId}] Auto-accepting invite request`); - await inviteCtx.accept(); - }, - }); - - // Store client for outbound use - setClientForAccount(account.accountId, client); - - // Start listening for messages - await client.start(); - - log?.info(`[${account.accountId}] Convos provider started`); - - // Block until abort signal fires (gateway expects startAccount to stay - // alive for the channel's lifetime; returning early marks it stopped). - await new Promise((resolve) => { - const onAbort = () => { - stopClient(account.accountId, log).finally(resolve); - }; - if (abortSignal?.aborted) { - onAbort(); - return; - } - abortSignal?.addEventListener("abort", onAbort, { once: true }); - }); - }, - stopAccount: async (ctx) => { - const { account, log } = ctx; - log?.info(`[${account.accountId}] stopping Convos provider`); - await stopClient(account.accountId, log); - }, - }, -}; - -/** Check whether a group conversation is allowed by the current policy. */ -function isGroupAllowed(params: { - account: ResolvedConvosAccount; - conversationId: string; -}): boolean { - const { account, conversationId } = params; - const policy = account.config.groupPolicy ?? "open"; - if (policy === "open") return true; - if (policy === "disabled") return false; - - // policy === "allowlist" - const groups = account.config.groups ?? []; - if (groups.includes("*")) return true; - return groups.includes(conversationId); -} - -/** - * Handle inbound messages from SDK - dispatches to the reply pipeline - */ -async function handleInboundMessage( - account: ResolvedConvosAccount, - msg: InboundMessage, - runtime: PluginRuntime, - log?: RuntimeLogger, -) { - if (account.debug) { - log?.info( - `[${account.accountId}] Inbound message from ${msg.senderId}: ${msg.content.slice(0, 50)}`, - ); - } - - // Enforce group policy before doing any work. - // Owner conversation always passes so you can't lock yourself out. - const isOwnerConversation = msg.conversationId === account.ownerConversationId; - if (!isOwnerConversation && !isGroupAllowed({ account, conversationId: msg.conversationId })) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped message from disallowed group ${msg.conversationId.slice(0, 12)}`, - ); - } - return; - } - - const cfg = runtime.config.loadConfig() as OpenClawConfig; - const rawBody = msg.content; - - // Resolve agent route to get session key for conversation tracking - const route = runtime.channel.routing.resolveAgentRoute({ - cfg, - channel: "convos", - accountId: account.accountId, - peer: { - kind: "group", - id: msg.conversationId, - }, - }); - - // Get store path for session recording - const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); - - // Get previous timestamp for envelope formatting - const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); - - // Format the agent envelope (adds channel/timestamp context) - const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg); - const body = runtime.channel.reply.formatAgentEnvelope({ - channel: "Convos", - from: msg.senderName || msg.senderId.slice(0, 12), - timestamp: msg.timestamp.getTime(), - previousTimestamp, - envelope: envelopeOptions, - body: rawBody, - }); - - // Build the finalized inbound context with all required fields - const ctxPayload = runtime.channel.reply.finalizeInboundContext({ - Body: body, - RawBody: rawBody, - CommandBody: rawBody, - From: `convos:${msg.senderId}`, - To: `convos:${msg.conversationId}`, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: "group", - ConversationLabel: msg.conversationId.slice(0, 12), - SenderName: msg.senderName || undefined, - SenderId: msg.senderId, - Provider: "convos", - Surface: "convos", - MessageSid: msg.messageId, - OriginatingChannel: "convos", - OriginatingTo: `convos:${msg.conversationId}`, - }); - - // Record the inbound session for conversation history - await runtime.channel.session.recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - onRecordError: (err) => { - log?.error(`[${account.accountId}] Failed updating session meta: ${String(err)}`); - }, - }); - - // Resolve markdown table mode for reply formatting - const tableMode = runtime.channel.text.resolveMarkdownTableMode({ - cfg, - channel: "convos", - accountId: account.accountId, - }); - - // Dispatch to the reply pipeline with buffered block dispatcher - await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - deliver: async (payload: ReplyPayload) => { - await deliverConvosReply({ - payload, - conversationId: msg.conversationId, - accountId: account.accountId, - runtime, - log, - tableMode, - }); - }, - onError: (err, info) => { - log?.error(`[${account.accountId}] Convos ${info.kind} reply failed: ${String(err)}`); - }, - }, - }); -} - -/** - * Deliver a reply to a Convos conversation - */ -async function deliverConvosReply(params: { - payload: ReplyPayload; - conversationId: string; - accountId: string; - runtime: PluginRuntime; - log?: RuntimeLogger; - tableMode?: "off" | "plain" | "markdown" | "bullets" | "code"; -}): Promise { - const { payload, conversationId, accountId, runtime, log, tableMode = "code" } = params; - - const client = getClientForAccount(accountId); - if (!client) { - throw new Error("Convos client not available"); - } - - // Convert markdown tables if needed - const text = runtime.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - - if (text) { - // Chunk the text if needed (Convos/XMTP has message size limits). - // Use the markdown-aware chunker to avoid breaking code blocks/tables. - const cfg = runtime.config.loadConfig() as OpenClawConfig; - const chunkLimit = runtime.channel.text.resolveTextChunkLimit({ - cfg, - channel: "convos", - accountId, - }); - - const chunks = runtime.channel.text.chunkMarkdownText(text, chunkLimit); - - for (const chunk of chunks) { - try { - await client.sendMessage(conversationId, chunk); - } catch (err) { - log?.error(`[${accountId}] Failed to send message: ${String(err)}`); - throw err; - } - } - } -} - -/** - * Stop SDK client for an account - */ -async function stopClient(accountId: string, log?: RuntimeLogger) { - const client = getClientForAccount(accountId); - if (client) { - try { - await client.stop(); - } catch (err) { - log?.error(`[${accountId}] Error stopping client: ${String(err)}`); - } - setClientForAccount(accountId, null); - } -} diff --git a/extensions/convos/src/config-schema.ts b/extensions/convos/src/config-schema.ts deleted file mode 100644 index a0b727a5de33..000000000000 --- a/extensions/convos/src/config-schema.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Convos channel configuration schema - * Used for Control UI form generation - */ - -import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk"; -import { z } from "zod"; - -const allowFromEntry = z.union([z.string(), z.number()]); - -/** - * Zod schema for channels.convos.* configuration - */ -export const ConvosConfigSchema = z.object({ - /** Account name (optional display name) */ - name: z.string().optional(), - - /** Whether this channel is enabled */ - enabled: z.boolean().optional(), - - /** Markdown formatting overrides (tables). */ - markdown: MarkdownConfigSchema, - - /** Hex-encoded XMTP private key (auto-generated on first run). */ - privateKey: z.string().optional(), - - /** XMTP environment: production (default) or dev. */ - env: z.enum(["production", "dev"]).optional(), - - /** Enable debug logging for this account. */ - debug: z.boolean().optional(), - - /** Sender access policy (default: pairing). Controls who can message the agent in groups. */ - dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - - /** Allowlist of inbox IDs permitted to message the agent. */ - allowFrom: z.array(allowFromEntry).optional(), - - /** Optional allowlist for group senders. */ - groupAllowFrom: z.array(allowFromEntry).optional(), - - /** Controls how group messages are handled (default: open). */ - groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), - - /** Allowlist of conversation IDs the agent listens in (groupPolicy "allowlist"). Include "*" to allow all. */ - groups: z.array(z.string()).optional(), - - /** Max group messages to keep as history context (0 disables). */ - historyLimit: z.number().int().min(0).optional(), - - /** Max per-sender turns to keep as history context. */ - dmHistoryLimit: z.number().int().min(0).optional(), - - /** Outbound text chunk size (chars). Default: 4000. */ - textChunkLimit: z.number().int().min(100).optional(), - - /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ - chunkMode: z.enum(["length", "newline"]).optional(), - - /** Controls agent reaction behavior. */ - reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), - - /** The conversation ID where OpenClaw communicates with its owner. */ - ownerConversationId: z.string().optional(), -}); - -export type ConvosConfigInput = z.infer; - -/** - * JSON Schema for Control UI (converted from Zod) - */ -export const convosChannelConfigSchema = buildChannelConfigSchema(ConvosConfigSchema); diff --git a/extensions/convos/src/config-types.ts b/extensions/convos/src/config-types.ts deleted file mode 100644 index 5b3c0a390cd7..000000000000 --- a/extensions/convos/src/config-types.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Convos channel configuration types - * These mirror the types in src/config/types.convos.ts but are self-contained - * for the extension to avoid cross-package imports. - */ - -export type ConvosReactionLevel = "off" | "ack" | "minimal" | "extensive"; -export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; -export type GroupPolicy = "open" | "disabled" | "allowlist"; - -export type ConvosAccountConfig = { - /** Optional display name for this account (used in CLI/UI lists). */ - name?: string; - /** If false, do not start this Convos account. Default: true. */ - enabled?: boolean; - /** Hex-encoded XMTP private key (auto-generated on first run). */ - privateKey?: string; - /** XMTP environment: production (default) or dev. */ - env?: "production" | "dev"; - /** Enable debug logging for this account. */ - debug?: boolean; - /** Sender access policy (default: pairing). Controls who can message the agent in groups. */ - dmPolicy?: DmPolicy; - /** Allowlist of inbox IDs permitted to message the agent. */ - allowFrom?: Array; - /** Optional allowlist for group senders. */ - groupAllowFrom?: Array; - /** Controls how group messages are handled (default: open). */ - groupPolicy?: GroupPolicy; - /** Allowlist of conversation IDs the agent listens in (groupPolicy "allowlist"). Include "*" to allow all. */ - groups?: string[]; - /** Max group messages to keep as history context (0 disables). */ - historyLimit?: number; - /** Max per-sender turns to keep as history context. */ - dmHistoryLimit?: number; - /** Outbound text chunk size (chars). Default: 4000. */ - textChunkLimit?: number; - /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ - chunkMode?: "length" | "newline"; - /** Action toggles for message tool capabilities. */ - actions?: { - /** Enable/disable sending reactions via message tool (default: true). */ - reactions?: boolean; - }; - /** Controls agent reaction behavior. */ - reactionLevel?: ConvosReactionLevel; - /** The conversation ID where OpenClaw communicates with its owner. */ - ownerConversationId?: string; -}; - -export type ConvosConfig = { - /** Optional per-account Convos configuration (multi-account). */ - accounts?: Record; -} & ConvosAccountConfig; diff --git a/extensions/convos/src/onboarding.ts b/extensions/convos/src/onboarding.ts deleted file mode 100644 index 61a1e6db2b4c..000000000000 --- a/extensions/convos/src/onboarding.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type OpenClawConfig, -} from "openclaw/plugin-sdk"; -import type { DmPolicy } from "./config-types.js"; -import { resolveConvosAccount, listConvosAccountIds, type CoreConfig } from "./accounts.js"; -import { ConvosSDKClient } from "./sdk-client.js"; - -const channel = "convos" as const; - -// Convos invite URLs can be: -// - Full URL: https://convos.app/join/SLUG or convos://join/SLUG -// - Just the slug: a base64-encoded string with asterisks for iMessage compatibility -const INVITE_URL_PATTERNS = [ - /^https?:\/\/convos\.app\/join\/(.+)$/i, - /^convos:\/\/join\/(.+)$/i, -]; - -function extractInviteSlug(input: string): string { - const trimmed = input.trim(); - for (const pattern of INVITE_URL_PATTERNS) { - const match = trimmed.match(pattern); - if (match) { - return match[1]; - } - } - // Assume it's a raw slug - return trimmed; -} - -function isValidInviteInput(input: string): boolean { - const trimmed = input.trim(); - if (!trimmed) { - return false; - } - // Check if it's a URL or a slug (base64-ish with asterisks) - for (const pattern of INVITE_URL_PATTERNS) { - if (pattern.test(trimmed)) { - return true; - } - } - // Raw slug: should be base64 characters possibly with asterisks - return /^[A-Za-z0-9+/=*_-]+$/.test(trimmed) && trimmed.length > 20; -} - -function setConvosDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return { - ...cfg, - channels: { - ...cfg.channels, - convos: { - ...(cfg.channels as CoreConfig["channels"])?.convos, - dmPolicy, - }, - }, - }; -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "Convos", - channel, - policyKey: "channels.convos.dmPolicy", - allowFromKey: "channels.convos.allowFrom", - getCurrent: (cfg) => (cfg.channels as CoreConfig["channels"])?.convos?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setConvosDmPolicy(cfg, policy), -}; - -export const convosOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - - getStatus: async ({ cfg }) => { - const configured = listConvosAccountIds(cfg as CoreConfig).some((accountId) => - resolveConvosAccount({ cfg: cfg as CoreConfig, accountId }).configured, - ); - const account = resolveConvosAccount({ cfg: cfg as CoreConfig }); - const ownerConversation = (cfg.channels as CoreConfig["channels"])?.convos?.ownerConversationId; - - return { - channel, - configured, - statusLines: [ - `Convos: ${configured ? "configured" : "needs setup"}`, - `Environment: ${account.env}`, - ownerConversation ? `Owner conversation: ${ownerConversation.slice(0, 8)}...` : "", - ].filter(Boolean), - selectionHint: configured ? "ready" : "paste invite link", - quickstartScore: 0, // Requires manual invite flow - }; - }, - - configure: async ({ cfg, prompter }) => { - let next = cfg; - const account = resolveConvosAccount({ cfg: next as CoreConfig }); - - // Check for existing configuration - if (account.privateKey && account.ownerConversationId) { - const keep = await prompter.confirm({ - message: `Convos already configured (conversation: ${account.ownerConversationId.slice(0, 12)}...). Keep it?`, - initialValue: true, - }); - if (keep) { - return { cfg: next }; - } - } - - // Explain the invite flow - await prompter.note( - [ - "To connect OpenClaw to Convos:", - "", - "1. Open the Convos iOS app", - "2. Open a conversation (or create one)", - '3. Tap the "+" button', - "4. Tap the share button on the QR code", - '5. Tap "Copy" (or AirDrop to your Mac)', - "6. Paste the invite link below", - "", - "OpenClaw will join this conversation as your control channel.", - ].join("\n"), - "Convos Setup", - ); - - // Prompt for invite link - const inviteInput = await prompter.text({ - message: "Paste Convos invite link or slug", - placeholder: "https://convos.app/join/... or raw slug", - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - if (!isValidInviteInput(raw)) { - return "Invalid invite format. Paste the full URL or slug."; - } - return undefined; - }, - }); - - const inviteSlug = extractInviteSlug(String(inviteInput)); - - // Join the conversation using SDK client - await prompter.note("Creating XMTP identity and joining conversation...", "Convos"); - - let client: ConvosSDKClient | undefined; - try { - // Create a new SDK client (will generate a new private key) - client = await ConvosSDKClient.create({ - env: account.env, - debug: account.debug, - }); - - const result = await client.joinConversation(inviteSlug); - - if (!result.conversationId) { - await prompter.note( - result.status === "waiting_for_acceptance" - ? "Join request sent. The conversation owner needs to approve your request in the Convos iOS app." - : "Failed to join conversation. The invite may be invalid or expired.", - "Convos", - ); - - // Still save the private key so we can retry later - next = { - ...next, - channels: { - ...next.channels, - convos: { - ...(next.channels as CoreConfig["channels"])?.convos, - enabled: true, - privateKey: client.getPrivateKey(), - env: account.env, - }, - }, - }; - - return { cfg: next }; - } - - // Save privateKey and ownerConversationId - next = { - ...next, - channels: { - ...next.channels, - convos: { - ...(next.channels as CoreConfig["channels"])?.convos, - enabled: true, - privateKey: client.getPrivateKey(), - env: account.env, - ownerConversationId: result.conversationId, - }, - }, - }; - - await prompter.note( - [ - "Successfully joined conversation!", - "", - `Conversation ID: ${result.conversationId}`, - "", - "This is now your owner channel. OpenClaw will:", - "- Send status updates here", - "- Ask for approvals here", - "- Communicate with you here", - ].join("\n"), - "Convos Connected", - ); - } catch (err) { - await prompter.note( - `Failed to join: ${err instanceof Error ? err.message : String(err)}`, - "Convos Error", - ); - } finally { - // Stop the temporary client - if (client) { - await client.stop(); - } - } - - return { cfg: next }; - }, - - dmPolicy, - - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - convos: { - ...(cfg.channels as CoreConfig["channels"])?.convos, - enabled: false, - }, - }, - }), -}; diff --git a/extensions/convos/src/outbound.ts b/extensions/convos/src/outbound.ts deleted file mode 100644 index 5a002f9a946a..000000000000 --- a/extensions/convos/src/outbound.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk"; -import { resolveConvosAccount, type CoreConfig } from "./accounts.js"; -import type { ConvosSDKClient } from "./sdk-client.js"; -import { getConvosRuntime } from "./runtime.js"; - -// Track SDK clients by account ID (set by channel.ts during startAccount) -const clients = new Map(); - -/** - * Set the SDK client for an account (called from channel.ts) - */ -export function setClientForAccount(accountId: string, client: ConvosSDKClient | null): void { - if (client) { - clients.set(accountId, client); - } else { - clients.delete(accountId); - } -} - -/** - * Get the SDK client for an account - */ -export function getClientForAccount(accountId: string): ConvosSDKClient | undefined { - return clients.get(accountId); -} - -export const convosOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - chunker: (text, limit) => getConvosRuntime().channel.text.chunkMarkdownText(text, limit), - chunkerMode: "markdown", - textChunkLimit: 4000, - - sendText: async ({ cfg, to, text, accountId }) => { - const account = resolveConvosAccount({ - cfg: cfg as CoreConfig, - accountId, - }); - const client = clients.get(account.accountId); - if (!client) { - throw new Error( - `Convos client not running for account ${account.accountId}. Is the gateway started?`, - ); - } - const result = await client.sendMessage(to, text); - return { - channel: "convos", - messageId: result.messageId ?? `convos-${Date.now()}`, - }; - }, - - sendMedia: async () => { - throw new Error("Media sending not yet implemented in Convos"); - }, -}; diff --git a/extensions/convos/src/runtime.ts b/extensions/convos/src/runtime.ts deleted file mode 100644 index 31b893a61718..000000000000 --- a/extensions/convos/src/runtime.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; - -let runtime: PluginRuntime | null = null; - -export function setConvosRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getConvosRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Convos runtime not initialized"); - } - return runtime; -} - -// Setup-active flag: when true, probes should be skipped to avoid -// hitting the XMTP "10/10 installations" limit with the old identity. -let setupActive = false; - -export function isConvosSetupActive(): boolean { - return setupActive; -} - -export function setConvosSetupActive(active: boolean) { - setupActive = active; -} diff --git a/extensions/convos/src/sdk-client.ts b/extensions/convos/src/sdk-client.ts deleted file mode 100644 index 5a22b381315d..000000000000 --- a/extensions/convos/src/sdk-client.ts +++ /dev/null @@ -1,459 +0,0 @@ -/** - * SDK client for Convos using convos-node-sdk - * Replaces the daemon HTTP client with direct SDK integration - */ - -import { Agent, createUser, createSigner, encodeText, type MessageContext } from "@xmtp/agent-sdk"; -import { ConvosMiddleware, type InviteContext } from "convos-node-sdk"; -import fs from "node:fs"; -import path from "node:path"; -import type { - ConversationInfo, - JoinConversationResult, - CreateConversationResult, - InviteResult, - MessageInfo, -} from "./types.js"; - -export interface ConvosSDKClientOptions { - /** Hex-encoded private key (generated if not provided) */ - privateKey?: string; - /** XMTP environment */ - env?: "production" | "dev"; - /** - * Path to the XMTP local database directory. - * - `string`: persistent DB at that path (directory created if missing). - * - `null`: in-memory DB (for setup/probe temporary clients). - * - `undefined`: SDK default (avoid — prefer explicit path or null). - */ - dbPath?: string | null; - /** Handler for incoming messages */ - onMessage?: (msg: InboundMessage) => void; - /** Handler for incoming invite/join requests */ - onInvite?: (ctx: InviteContext) => Promise; - /** Debug logging */ - debug?: boolean; -} - -export interface InboundMessage { - conversationId: string; - messageId: string; - senderId: string; - senderName: string; - content: string; - timestamp: Date; -} - -/** - * XMTP message timestamps are often exposed as nanoseconds (sentAtNs) and may be bigint. - * This helper normalizes to a safe JS Date. - */ -function dateFromSentAtNs(sentAtNs: unknown): Date { - try { - if (typeof sentAtNs === "bigint") { - const ms = sentAtNs / 1_000_000n; - const n = Number(ms); - return Number.isFinite(n) ? new Date(n) : new Date(); - } - if (typeof sentAtNs === "number") { - const n = Math.floor(sentAtNs / 1_000_000); - return Number.isFinite(n) ? new Date(n) : new Date(); - } - return new Date(); - } catch { - return new Date(); - } -} - -function readSentAtNs(obj: unknown): unknown { - if (!obj || typeof obj !== "object") return undefined; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (obj as any).sentAtNs; -} - -/** - * Derive a short hash from the private key so that changing the key - * produces a fresh DB directory, avoiding "identity rowid=1" crashes. - */ -function keyHash8(privateKey: string): string { - const hex = privateKey.startsWith("0x") ? privateKey.slice(2) : privateKey; - return hex.slice(0, 8).toLowerCase(); -} - -/** - * Build a deterministic dbPath file under the OpenClaw state directory. - * Agent.create() expects a **file** path (SQLite DB), not a directory. - * - * /convos/xmtp////xmtp.db - */ -export function resolveConvosDbPath(params: { - stateDir: string; - env: "production" | "dev"; - accountId: string; - privateKey: string; -}): string { - const hash = keyHash8(params.privateKey); - const dir = path.join(params.stateDir, "convos", "xmtp", params.env, params.accountId, hash); - return path.join(dir, "xmtp.db"); -} - -/** - * Ensure the parent directory of a dbPath file exists and is writable. - * Throws a clear error (instead of the opaque libxmtp "Pool error … - * Unable to open database file") if the directory cannot be written to. - */ -export function ensureDbPathWritable(dbPath: string): void { - const dir = path.dirname(dbPath); - fs.mkdirSync(dir, { recursive: true }); - const probe = path.join(dir, `.probe-${process.pid}`); - try { - fs.writeFileSync(probe, ""); - fs.unlinkSync(probe); - } catch (err) { - throw new Error( - `XMTP dbPath parent dir is not writable: ${dir} — ${err instanceof Error ? err.message : String(err)}`, - ); - } -} - -/** - * Convos SDK Client - wraps convos-node-sdk for OpenClaw use - */ -export class ConvosSDKClient { - private agent: Agent; - private convos: ConvosMiddleware; - private userKey: string; - private running = false; - private debug: boolean; - - private constructor(agent: Agent, convos: ConvosMiddleware, userKey: string, debug: boolean) { - this.agent = agent; - this.convos = convos; - this.userKey = userKey; - this.debug = debug; - } - - /** - * Create a new SDK client - */ - static async create(options: ConvosSDKClientOptions): Promise { - const debug = options.debug ?? false; - - // Create or restore user from privateKey - let user: ReturnType; - if (options.privateKey) { - // Restore from existing key - user = createUser(options.privateKey); - if (debug) { - console.log("[convos-sdk] Restored user from private key"); - } - } else { - // Generate new user - user = createUser(); - if (debug) { - console.log("[convos-sdk] Generated new user"); - } - } - - const resolvedEnv = options.env ?? "production"; - const signer = createSigner(user); - - // Build Agent options with explicit dbPath when provided. - // string → persistent (ensure dir exists + writable); null → in-memory; undefined → SDK default. - const agentOpts: Record = { env: resolvedEnv }; - if (options.dbPath !== undefined) { - if (typeof options.dbPath === "string") { - ensureDbPathWritable(options.dbPath); - } - agentOpts.dbPath = options.dbPath; - } - - const agent = await Agent.create(signer, agentOpts); - const convos = ConvosMiddleware.create(agent, { privateKey: user.key, env: resolvedEnv }); - agent.use(convos.middleware()); - - console.log(`[convos-sdk] XMTP env: ${resolvedEnv}, inboxId: ${agent.client.inboxId}`); - - const client = new ConvosSDKClient(agent, convos, user.key, debug); - - // Wire up event handlers - if (options.onInvite) { - convos.on("invite", options.onInvite); - } - - if (options.onMessage) { - agent.on("message", (ctx: MessageContext) => { - try { - const senderId = ctx.message.senderInboxId; - - // Prevent echo-loops / double-processing: ignore our own outbound messages - if (senderId === agent.client.inboxId) { - return; - } - - const content = typeof ctx.message.content === "string" ? ctx.message.content : ""; - const trimmed = content.trim(); - - // Ignore empty or non-text messages for now (reactions/attachments/etc.) - if (!trimmed) { - if (debug && content === "") { - console.log("[convos-sdk] Ignoring non-text/empty message"); - } - return; - } - - const msg: InboundMessage = { - conversationId: ctx.conversation.id, - messageId: ctx.message.id, - senderId, - senderName: "", // SDK doesn't expose display name; channel.ts falls back to truncated senderId - content, - timestamp: dateFromSentAtNs(readSentAtNs(ctx.message)), - }; - - // Avoid synchronous re-entrancy into the OpenClaw reply pipeline. - queueMicrotask(() => { - try { - options.onMessage?.(msg); - } catch (err) { - if (debug) { - console.error("[convos-sdk] onMessage handler threw:", err); - } - } - }); - } catch (err) { - if (debug) { - console.error("[convos-sdk] Failed processing inbound message event:", err); - } - } - }); - } - - return client; - } - - /** - * Start listening for messages - */ - async start(): Promise { - if (this.running) return; - this.running = true; - - if (this.debug) { - console.log("[convos-sdk] Starting agent..."); - } - - await this.agent.start(); - - if (this.debug) { - console.log("[convos-sdk] Agent started"); - } - } - - /** - * Stop the client and cleanup - */ - async stop(): Promise { - if (!this.running) return; - this.running = false; - - if (this.debug) { - console.log("[convos-sdk] Stopping agent..."); - } - - await this.agent.stop(); - - if (this.debug) { - console.log("[convos-sdk] Agent stopped"); - } - } - - /** - * Join a conversation via invite URL or slug - */ - async joinConversation(invite: string): Promise { - if (this.debug) { - console.log(`[convos-sdk] Joining conversation with invite: ${invite.slice(0, 20)}...`); - } - - try { - const result = await this.convos.join(invite); - - if (this.debug) { - console.log(`[convos-sdk] Join result:`, result); - } - - return { - status: result.conversationId ? "joined" : "waiting_for_acceptance", - conversationId: result.conversationId ?? null, - }; - } catch (err) { - if (this.debug) { - console.error(`[convos-sdk] Join failed:`, err); - } - throw err; - } - } - - /** - * List all conversations - */ - async listConversations(): Promise { - const conversations = await this.agent.conversations.list(); - - return conversations.map((conv) => ({ - id: conv.id, - displayName: conv.name ?? conv.id.slice(0, 8), - memberCount: conv.members?.length ?? 0, - isUnread: false, - isPinned: false, - isMuted: false, - kind: "group", - createdAt: new Date().toISOString(), - lastMessagePreview: undefined, - })); - } - - /** - * Create a new conversation with invite URL - */ - async createConversation(name?: string): Promise { - if (this.debug) { - console.log(`[convos-sdk] Creating conversation: ${name ?? "OpenClaw"}`); - } - - // Create XMTP group first - const group = await this.agent.client.conversations.createGroup([]); - - if (this.debug) { - console.log(`[convos-sdk] Created XMTP group: ${group.id}`); - } - - // Wrap with Convos to get invite functionality - const convosGroup = this.convos.group(group); - - // Create invite (automatically manages metadata) - const invite = await convosGroup.createInvite({ name }); - - // Always log invite details for diagnostics - console.log(`[convos-sdk] Created invite: url=${invite.url}`); - console.log(`[convos-sdk] Invite slug length: ${invite.slug.length}`); - console.log(`[convos-sdk] Agent inboxId: ${this.agent.client.inboxId}`); - - return { - conversationId: group.id, - inviteSlug: invite.slug, - inviteUrl: invite.url, - }; - } - - /** - * Get or create invite slug for a conversation - */ - async getInvite(conversationId: string): Promise { - const conversation = await this.agent.client.conversations.getConversationById(conversationId); - if (!conversation) { - throw new Error(`Conversation not found: ${conversationId}`); - } - - // Wrap with Convos and create a new invite - // Note: SDK doesn't have a "get existing invite" method, so we create a new one - const convosGroup = this.convos.group(conversation); - const invite = await convosGroup.createInvite(); - - return { - inviteSlug: invite.slug, - }; - } - - /** - * List messages in a conversation - */ - async listMessages(conversationId: string, limit?: number): Promise { - const conversation = await this.agent.client.conversations.getConversationById(conversationId); - if (!conversation) { - return []; - } - - const messages = await conversation.messages({ limit: limit ?? 50 }); - - return messages - .map((msg) => { - const content = typeof msg.content === "string" ? msg.content : ""; - return { - id: msg.id, - conversationId, - senderId: msg.senderInboxId, - senderName: "", - content, - timestamp: dateFromSentAtNs(readSentAtNs(msg)).toISOString(), - }; - }) - .filter((m) => m.content.trim().length > 0); - } - - /** - * Send a message to a conversation - */ - async sendMessage( - conversationId: string, - message: string, - ): Promise<{ success: boolean; messageId?: string }> { - if (this.debug) { - console.log(`[convos-sdk] Sending message to ${conversationId.slice(0, 8)}...`); - } - - const conversation = await this.agent.client.conversations.getConversationById(conversationId); - if (!conversation) { - throw new Error(`Conversation not found: ${conversationId}`); - } - - // send() expects encoded content (type + content bytes), not a raw string. - await conversation.send(encodeText(message)); - - return { success: true }; - } - - /** - * Add or remove a reaction - */ - async react( - conversationId: string, - messageId: string, - emoji: string, - remove?: boolean, - ): Promise<{ success: boolean; action: "added" | "removed" }> { - if (this.debug) { - console.log( - `[convos-sdk] ${remove ? "Removing" : "Adding"} reaction ${emoji} on ${messageId}`, - ); - } - - const conversation = await this.agent.client.conversations.getConversationById(conversationId); - if (!conversation) { - throw new Error(`Conversation not found: ${conversationId}`); - } - - if (remove) { - await conversation.removeReaction(messageId, emoji); - } else { - await conversation.addReaction(messageId, emoji); - } - - return { success: true, action: remove ? "removed" : "added" }; - } - - /** - * Get the private key for config storage - */ - getPrivateKey(): string { - return this.userKey; - } - - /** - * Check if the client is running - */ - isRunning(): boolean { - return this.running; - } -} diff --git a/extensions/convos/src/setup.ts b/extensions/convos/src/setup.ts deleted file mode 100644 index 4d17832e481b..000000000000 --- a/extensions/convos/src/setup.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Convos setup - creates XMTP identity and conversation, returns invite URL. - * Config is NOT written here; the caller persists config after join is confirmed. - */ - -import type { InviteContext } from "convos-node-sdk"; -import type { OpenClawConfig } from "openclaw/plugin-sdk"; -import type { ConvosSetupResult } from "./types.js"; -import { resolveConvosAccount, type CoreConfig } from "./accounts.js"; -import { getConvosRuntime } from "./runtime.js"; -import { ConvosSDKClient } from "./sdk-client.js"; - -export type SetupConvosParams = { - accountId?: string; - env?: "production" | "dev"; - name?: string; - /** If true, generates a new XMTP identity even if one already exists in config */ - forceNewKey?: boolean; - /** If true, keeps agent running to accept join requests (caller must stop it) */ - keepRunning?: boolean; - /** Handler for incoming invite/join requests */ - onInvite?: (ctx: InviteContext) => Promise; -}; - -export type SetupConvosResultWithClient = ConvosSetupResult & { - /** The running client (only if keepRunning=true) */ - client?: ConvosSDKClient; -}; - -/** - * Setup Convos by creating an XMTP identity and owner conversation. - * Returns an invite URL that can be displayed as a QR code. - * - * Does NOT write config — the caller should persist config after the user - * has successfully joined the conversation. - * - * If keepRunning=true, the agent stays running to accept join requests. - * Caller is responsible for stopping it later. - */ -export async function setupConvosWithInvite( - params: SetupConvosParams, -): Promise { - const runtime = getConvosRuntime(); - const cfg = runtime.config.loadConfig() as OpenClawConfig; - const account = resolveConvosAccount({ - cfg: cfg as CoreConfig, - accountId: params.accountId, - }); - - // Create SDK client (generates new identity if no privateKey). - // Use in-memory DB — setup is temporary; the runtime client will use a - // persistent dbPath once the identity is saved to config. - const client = await ConvosSDKClient.create({ - privateKey: params.forceNewKey ? undefined : account.privateKey, - env: params.env ?? account.env, - dbPath: null, - debug: false, - onInvite: params.onInvite, - }); - - try { - // Start the agent to enable conversation creation - await client.start(); - - // Create a new conversation (this will be the owner conversation) - const conversationName = params.name ?? "OpenClaw"; - const result = await client.createConversation(conversationName); - - const privateKey = client.getPrivateKey(); - - // Keep running or stop based on option - if (!params.keepRunning) { - await client.stop(); - } - - return { - inviteUrl: result.inviteUrl, - conversationId: result.conversationId, - privateKey, - client: params.keepRunning ? client : undefined, - }; - } catch (err) { - await client.stop(); - throw err; - } -} diff --git a/extensions/convos/src/types.ts b/extensions/convos/src/types.ts deleted file mode 100644 index 01cddc72d784..000000000000 --- a/extensions/convos/src/types.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Types for Convos SDK client - */ - -export interface ConversationInfo { - id: string; - displayName: string; - memberCount: number; - isUnread: boolean; - isPinned: boolean; - isMuted: boolean; - kind: string; - createdAt: string; - lastMessagePreview?: string; -} - -export interface MessageInfo { - id: string; - conversationId: string; - senderId: string; - senderName: string; - content: string; - timestamp: string; -} - -export interface CreateConversationResult { - conversationId: string; - inviteSlug: string; - inviteUrl: string; -} - -export interface ConvosSetupResult { - inviteUrl: string; - conversationId: string; - privateKey: string; -} - -export interface JoinConversationResult { - status: "joined" | "waiting_for_acceptance"; - conversationId: string | null; -} - -export interface InviteResult { - inviteSlug: string; -} - -export interface AccountInfo { - conversationCount: number; - environment: string; -} diff --git a/extensions/xmtp/index.ts b/extensions/xmtp/index.ts new file mode 100644 index 000000000000..e12404bc2756 --- /dev/null +++ b/extensions/xmtp/index.ts @@ -0,0 +1,136 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { xmtpPlugin } from "./src/channel.js"; +import { setXmtpRuntime } from "./src/runtime.js"; +import { + handleSetup, + handleSetupStatus, + handleSetupComplete, + handleSetupCancel, +} from "./src/setup.js"; +import { registerXmtpCommands } from "./src/xmtp-commands.js"; + +async function readJsonBody(req: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString(); + if (!raw.trim()) return {}; + return JSON.parse(raw) as Record; +} + +function jsonResponse(res: ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); +} + +const plugin = { + id: "xmtp", + name: "XMTP", + description: "XMTP decentralized messaging channel", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setXmtpRuntime(api.runtime); + api.registerChannel({ plugin: xmtpPlugin }); + registerXmtpCommands(api); + + api.registerGatewayMethod("xmtp.setup", async ({ params, respond }) => { + try { + const p = params as Record; + const result = await handleSetup({ + accountId: typeof p.accountId === "string" ? p.accountId : undefined, + env: typeof p.env === "string" ? (p.env as "production" | "dev") : undefined, + }); + respond(true, result, undefined); + } catch (err) { + respond(false, undefined, { + code: -1, + message: err instanceof Error ? err.message : String(err), + }); + } + }); + + api.registerGatewayMethod("xmtp.setup.status", async ({ respond }) => { + respond(true, handleSetupStatus(), undefined); + }); + + api.registerGatewayMethod("xmtp.setup.complete", async ({ respond }) => { + try { + const result = await handleSetupComplete(); + respond(true, result, undefined); + } catch (err) { + respond(false, undefined, { + code: -1, + message: err instanceof Error ? err.message : String(err), + }); + } + }); + + api.registerGatewayMethod("xmtp.setup.cancel", async ({ respond }) => { + const result = handleSetupCancel(); + respond(true, result, undefined); + }); + + api.registerHttpRoute({ + path: "/xmtp/setup", + handler: async (req, res) => { + if (req.method !== "POST") { + jsonResponse(res, 405, { error: "Method Not Allowed" }); + return; + } + try { + const body = await readJsonBody(req); + const result = await handleSetup({ + accountId: typeof body.accountId === "string" ? body.accountId : undefined, + env: typeof body.env === "string" ? (body.env as "production" | "dev") : undefined, + }); + jsonResponse(res, 200, result); + } catch (err) { + jsonResponse(res, 500, { error: err instanceof Error ? err.message : String(err) }); + } + }, + }); + + api.registerHttpRoute({ + path: "/xmtp/setup/status", + handler: async (req, res) => { + if (req.method !== "GET") { + jsonResponse(res, 405, { error: "Method Not Allowed" }); + return; + } + jsonResponse(res, 200, handleSetupStatus()); + }, + }); + + api.registerHttpRoute({ + path: "/xmtp/setup/complete", + handler: async (req, res) => { + if (req.method !== "POST") { + jsonResponse(res, 405, { error: "Method Not Allowed" }); + return; + } + try { + const result = await handleSetupComplete(); + jsonResponse(res, 200, result); + } catch (err) { + jsonResponse(res, 400, { error: err instanceof Error ? err.message : String(err) }); + } + }, + }); + + api.registerHttpRoute({ + path: "/xmtp/setup/cancel", + handler: async (req, res) => { + if (req.method !== "POST") { + jsonResponse(res, 405, { error: "Method Not Allowed" }); + return; + } + const result = handleSetupCancel(); + jsonResponse(res, 200, result); + }, + }); + }, +}; + +export default plugin; diff --git a/extensions/convos/openclaw.plugin.json b/extensions/xmtp/openclaw.plugin.json similarity index 70% rename from extensions/convos/openclaw.plugin.json rename to extensions/xmtp/openclaw.plugin.json index 684f18c1b6ff..2f546db5a13c 100644 --- a/extensions/convos/openclaw.plugin.json +++ b/extensions/xmtp/openclaw.plugin.json @@ -1,6 +1,6 @@ { - "id": "convos", - "channels": ["convos"], + "id": "xmtp", + "channels": ["xmtp"], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/xmtp/package.json b/extensions/xmtp/package.json new file mode 100644 index 000000000000..340a5998641e --- /dev/null +++ b/extensions/xmtp/package.json @@ -0,0 +1,34 @@ +{ + "name": "@openclaw/xmtp", + "version": "2026.2.4", + "description": "XMTP decentralized messaging channel", + "type": "module", + "dependencies": { + "@xmtp/agent-sdk": "^2.0.1", + "viem": "^2.21.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "xmtp", + "label": "XMTP", + "selectionLabel": "XMTP (Agent SDK)", + "docsPath": "/channels/xmtp", + "docsLabel": "xmtp", + "blurb": "Decentralized messaging via XMTP protocol", + "systemImage": "network", + "order": 76 + }, + "install": { + "npmSpec": "@openclaw/xmtp", + "localPath": "extensions/xmtp", + "defaultChoice": "local" + } + } +} diff --git a/extensions/xmtp/skills/xmtp-channel.md b/extensions/xmtp/skills/xmtp-channel.md new file mode 100644 index 000000000000..0ac9eec062e6 --- /dev/null +++ b/extensions/xmtp/skills/xmtp-channel.md @@ -0,0 +1,80 @@ +--- +name: xmtp-channel +description: How to use the XMTP channel for decentralized E2E encrypted messaging +read_when: + - Working with XMTP channel + - Sending or receiving messages via XMTP + - Understanding XMTP agent identity and addressing +--- + +# XMTP Channel Guide + +The XMTP channel provides decentralized E2E encrypted messaging via the XMTP protocol. OpenClaw uses the XMTP plugin and `@xmtp/agent-sdk` to communicate with the XMTP network. + +## Architecture Overview + +### SDK-Based Implementation + +- Runs in-process within the gateway +- Uses `Agent.createFromEnv()` with keys from config / `~/.openclaw/.env` +- One identity per account (derived from wallet key) +- Local DB path: `~/.openclaw/xmtp//` (or `XMTP_DB_DIRECTORY`) + +### Wallet-Based Identity + +The agent's public address is the Ethereum address derived from the wallet private key. Anyone can message the agent by that address from any XMTP client (Converse, xmtp.chat, etc.). No invite URL is required for DMs; the agent receives messages automatically once the gateway is running. + +## Slash command + +| Command | Args | Description | +| ---------- | ---- | --------------------------------------------------------------------------------------------------------------- | +| `/address` | None | Print the XMTP public agent address (Ethereum address). Use this to share with others so they can DM the agent. | + +Requires authorization. If XMTP is not configured, the command replies with a short error message. + +## Message Targeting + +- **Direct messages**: Target by the peer's Ethereum address (e.g. `0x1234...`). The agent creates or reuses a DM conversation by address. +- **Group messages**: Target by conversation ID (topic/id from the SDK). The agent must already be in the group to send. +- Outbound actions use `to` as conversation ID or address; the plugin resolves the conversation via `agent.client.conversations.getConversationById(to)` then `conversation.sendText(text)`. + +## Capabilities + +| Feature | Supported | +| ------------------- | ------------ | +| Group conversations | Yes | +| Direct messages | Yes | +| Reactions | No | +| Threads | No | +| Media/attachments | Yes (remote) | +| E2E encryption | Yes (XMTP) | + +## Configuration Reference + +```json +{ + "channels": { + "xmtp": { + "enabled": true, + "walletKey": "0x...", + "dbEncryptionKey": "", + "env": "production", + "dmPolicy": "pairing", + "groupPolicy": "open" + } + } +} +``` + +Key fields: + +- `walletKey`: Wallet private key (hex). Public address is derived from this. +- `dbEncryptionKey`: Encryption key for local XMTP DB. +- `env`: XMTP environment (`production` or `dev`). +- `dmPolicy`: Who can DM the agent (pairing / allowlist / open / disabled). +- `groupPolicy`: Which groups can message the agent (open / disabled / allowlist). + +## Error Handling + +- **Conversation not found**: The agent may not yet have a conversation with that address/ID; for DMs the other party must message the agent first, or use the send action with an existing conversation ID. +- **Agent not available**: Gateway not started or XMTP channel not running; start the gateway with XMTP enabled. diff --git a/extensions/xmtp/src/accounts.ts b/extensions/xmtp/src/accounts.ts new file mode 100644 index 000000000000..77937a672cdb --- /dev/null +++ b/extensions/xmtp/src/accounts.ts @@ -0,0 +1,139 @@ +import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig } from "openclaw/plugin-sdk"; +import type { XMTPConfig } from "./config-types.js"; +import { walletAddressFromPrivateKey } from "./lib/identity.js"; + +export type CoreConfig = { + channels?: { + xmtp?: XMTPConfig; + }; + [key: string]: unknown; +}; + +export type ResolvedXmtpAccount = { + accountId: string; + enabled: boolean; + name?: string; + configured: boolean; + walletKey: string; + dbEncryptionKey: string; + env: "production" | "dev"; + debug: boolean; + /** Ethereum address; from config or derived from walletKey. */ + publicAddress: string; + config: XMTPConfig; +}; + +export function getXmtpSection(cfg: CoreConfig): XMTPConfig | undefined { + return cfg.channels?.xmtp; +} + +export function updateXmtpSection( + cfg: OpenClawConfig, + update: Partial, +): OpenClawConfig { + const prev = (cfg.channels as CoreConfig["channels"])?.xmtp; + return { + ...cfg, + channels: { + ...cfg.channels, + xmtp: { ...prev, ...update }, + }, + }; +} + +export function listXmtpAccountIds(cfg: CoreConfig): string[] { + const accounts = cfg.channels?.xmtp?.accounts; + if (accounts && typeof accounts === "object" && Object.keys(accounts).length > 0) { + return Object.keys(accounts); + } + return [DEFAULT_ACCOUNT_ID]; +} + +export function resolveDefaultXmtpAccountId(cfg: CoreConfig): string { + const ids = listXmtpAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function getAccountBase(cfg: CoreConfig, accountId: string): XMTPConfig { + const section = cfg.channels?.xmtp ?? {}; + const accounts = section.accounts; + if (accounts && typeof accounts === "object" && accounts[accountId]) { + return { ...section, ...accounts[accountId] } as XMTPConfig; + } + return section; +} + +/** + * Return config with publicAddress set for the given account (for backfill). + * Writes to top-level xmtp or to xmtp.accounts[accountId] depending on structure. + */ +export function setAccountPublicAddress( + cfg: OpenClawConfig, + accountId: string, + publicAddress: string, +): OpenClawConfig { + const section = (cfg.channels as CoreConfig["channels"])?.xmtp ?? {}; + const accounts = section.accounts; + if (accounts && typeof accounts === "object" && accounts[accountId]) { + return { + ...cfg, + channels: { + ...cfg.channels, + xmtp: { + ...section, + accounts: { + ...accounts, + [accountId]: { ...accounts[accountId], publicAddress }, + }, + }, + }, + }; + } + return updateXmtpSection(cfg, { publicAddress }); +} + +export function resolveXmtpAccount(params: { + cfg: CoreConfig; + accountId?: string | null; +}): ResolvedXmtpAccount { + const accountId = normalizeAccountId(params.accountId); + const base = getAccountBase(params.cfg, accountId); + const enabled = base.enabled !== false; + const configured = Boolean(base.walletKey && base.dbEncryptionKey); + + const publicAddress = + base.publicAddress ?? (base.walletKey ? walletAddressFromPrivateKey(base.walletKey) : ""); + + return { + accountId, + enabled, + name: base.name?.trim() || undefined, + configured, + walletKey: base.walletKey ?? "", + dbEncryptionKey: base.dbEncryptionKey ?? "", + env: base.env === "dev" ? "dev" : "production", + debug: base.debug ?? false, + publicAddress, + config: base, + }; +} + +export function listEnabledXmtpAccounts(cfg: CoreConfig): ResolvedXmtpAccount[] { + return listXmtpAccountIds(cfg) + .map((accountId) => resolveXmtpAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} + +/** + * Throw if account is missing walletKey or dbEncryptionKey. + */ +export function ensureXmtpConfigured(account: ResolvedXmtpAccount): void { + if (!account.walletKey || !account.dbEncryptionKey) { + throw new Error( + "XMTP not configured: walletKey and dbEncryptionKey required. Run 'openclaw configure' to set up XMTP.", + ); + } +} diff --git a/extensions/xmtp/src/actions.ts b/extensions/xmtp/src/actions.ts new file mode 100644 index 000000000000..342348db51be --- /dev/null +++ b/extensions/xmtp/src/actions.ts @@ -0,0 +1,27 @@ +import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "openclaw/plugin-sdk"; +import { jsonResult, readStringParam } from "openclaw/plugin-sdk"; +import { listXmtpAccountIds, resolveXmtpAccount, type CoreConfig } from "./accounts.js"; +import { getAgentOrThrow } from "./outbound.js"; + +export const xmtpMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => + listXmtpAccountIds(cfg as CoreConfig).length > 0 + ? (["send"] as ChannelMessageActionName[]) + : [], + + supportsButtons: () => false, + + handleAction: async ({ action, params, cfg, accountId }) => { + const account = resolveXmtpAccount({ cfg: cfg as CoreConfig, accountId }); + const agent = getAgentOrThrow(account.accountId); + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const message = readStringParam(params, "message", { required: true, allowEmpty: true }); + await agent.sendText(to, message ?? ""); + return jsonResult({ ok: true, to, messageId: `xmtp-${Date.now()}` }); + } + + throw new Error(`Action "${action}" is not supported for XMTP.`); + }, +}; diff --git a/extensions/xmtp/src/channel.ts b/extensions/xmtp/src/channel.ts new file mode 100644 index 000000000000..ea4183c0d1b4 --- /dev/null +++ b/extensions/xmtp/src/channel.ts @@ -0,0 +1,463 @@ +/** + * XMTP channel adapter for OpenClaw gateway. + * Uses @xmtp/agent-sdk to listen for messages and forward them via the reply pipeline. + */ + +import { + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, + type ChannelPlugin, + type PluginRuntime, + type ReplyPayload, +} from "openclaw/plugin-sdk"; +import { + ensureXmtpConfigured, + listXmtpAccountIds, + resolveDefaultXmtpAccountId, + resolveXmtpAccount, + setAccountPublicAddress, + type CoreConfig, + type ResolvedXmtpAccount, +} from "./accounts.js"; +import { xmtpMessageActions } from "./actions.js"; +import { xmtpChannelConfigSchema } from "./config-schema.js"; +import { createAgentFromAccount } from "./lib/xmtp-client.js"; +import { xmtpOnboardingAdapter } from "./onboarding.js"; +import { getClientForAccount, setClientForAccount, xmtpOutbound } from "./outbound.js"; +import { getXmtpRuntime } from "./runtime.js"; + +type RuntimeLogger = { + info: (msg: string) => void; + error: (msg: string) => void; +}; + +const CHANNEL_ID = "xmtp"; + +const meta = { + id: CHANNEL_ID, + label: "XMTP", + selectionLabel: "XMTP (Agent SDK)", + docsPath: "/channels/xmtp", + docsLabel: "xmtp", + blurb: "Decentralized messaging via XMTP protocol", + systemImage: "network", + order: 76, + aliases: [CHANNEL_ID], +}; + +function normalizeXmtpAddress(raw: string): string { + let s = raw.trim(); + if (s.toLowerCase().startsWith("xmtp:")) { + s = s.slice("xmtp:".length).trim(); + } + return s; +} + +function normalizeXmtpMessagingTarget(raw: string): string | undefined { + const s = normalizeXmtpAddress(raw); + return s || undefined; +} + +function isGroupAllowed(params: { account: ResolvedXmtpAccount; conversationId: string }): boolean { + const { account, conversationId } = params; + const policy = account.config.groupPolicy ?? "open"; + if (policy === "open") { + return true; + } + if (policy === "disabled") { + return false; + } + const groups = account.config.groups ?? []; + return groups.includes("*") || groups.includes(conversationId); +} + +async function handleInboundMessage( + account: ResolvedXmtpAccount, + sender: string, + conversationId: string, + content: string, + messageId: string | undefined, + runtime: PluginRuntime, + log?: RuntimeLogger, +) { + if (account.debug) { + log?.info( + `[${account.accountId}] Inbound from ${sender.slice(0, 12)}: ${content.slice(0, 50)}`, + ); + } + + if (!isGroupAllowed({ account, conversationId })) { + if (account.debug) { + log?.info( + `[${account.accountId}] Dropped message from disallowed conversation ${conversationId.slice(0, 12)}`, + ); + } + return; + } + + const cfg = runtime.config.loadConfig(); + const rawBody = content; + const isDirect = conversationId === sender; + + const route = runtime.channel.routing.resolveAgentRoute({ + cfg, + channel: CHANNEL_ID, + accountId: account.accountId, + peer: { + kind: isDirect ? "dm" : "group", + id: conversationId, + }, + }); + + const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + + const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + + const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg); + const body = runtime.channel.reply.formatAgentEnvelope({ + channel: "XMTP", + from: sender.slice(0, 12), + timestamp: Date.now(), + previousTimestamp, + envelope: envelopeOptions, + body: rawBody, + }); + + const ctxPayload = runtime.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: rawBody, + CommandBody: rawBody, + From: `xmtp:${sender}`, + To: `xmtp:${conversationId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isDirect ? "direct" : "group", + ConversationLabel: conversationId.slice(0, 12), + SenderName: undefined, + SenderId: sender, + Provider: CHANNEL_ID, + Surface: CHANNEL_ID, + MessageSid: messageId, + OriginatingChannel: CHANNEL_ID, + OriginatingTo: `xmtp:${conversationId}`, + }); + + await runtime.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + log?.error(`[${account.accountId}] Failed updating session meta: ${String(err)}`); + }, + }); + + const tableMode = runtime.channel.text.resolveMarkdownTableMode({ + cfg, + channel: CHANNEL_ID, + accountId: account.accountId, + }); + + await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + deliver: async (payload: ReplyPayload) => { + await deliverXmtpReply({ + payload, + conversationId, + accountId: account.accountId, + runtime, + log, + tableMode, + }); + }, + onError: (err, info) => { + const msg = String(err); + if (msg.includes("XMTP agent not available")) { + log?.info(`[${account.accountId}] XMTP ${info.kind} reply skipped (agent unavailable).`); + return; + } + log?.error(`[${account.accountId}] XMTP ${info.kind} reply failed: ${msg}`); + }, + }, + }); +} + +async function deliverXmtpReply(params: { + payload: ReplyPayload; + conversationId: string; + accountId: string; + runtime: PluginRuntime; + log?: RuntimeLogger; + tableMode?: "off" | "plain" | "markdown" | "bullets" | "code"; +}): Promise { + const { payload, conversationId, accountId, runtime, log, tableMode = "code" } = params; + + const agent = getClientForAccount(accountId); + if (!agent) { + throw new Error("XMTP agent not available"); + } + + const text = runtime.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + + if (text) { + const conversation = await agent.client.conversations.getConversationById(conversationId); + if (!conversation) { + throw new Error(`Conversation not found: ${conversationId.slice(0, 12)}...`); + } + + const cfg = runtime.config.loadConfig(); + const chunkLimit = runtime.channel.text.resolveTextChunkLimit({ + cfg, + channel: CHANNEL_ID, + accountId, + }); + const chunks = runtime.channel.text.chunkMarkdownText(text, chunkLimit); + + for (const chunk of chunks) { + try { + await conversation.sendText(chunk); + } catch (err) { + log?.error(`[${accountId}] Failed to send message: ${String(err)}`); + throw err; + } + } + } +} + +async function stopAgent(accountId: string, log?: RuntimeLogger): Promise { + const agent = getClientForAccount(accountId); + if (agent) { + try { + await agent.stop(); + } catch (err) { + log?.error(`[${accountId}] Error stopping agent: ${String(err)}`); + } + setClientForAccount(accountId, null); + } +} + +export const xmtpPlugin: ChannelPlugin = { + id: CHANNEL_ID, + meta, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: false, + threads: false, + }, + reload: { configPrefixes: ["channels.xmtp"] }, + gatewayMethods: ["xmtp.setup", "xmtp.setup.status", "xmtp.setup.complete", "xmtp.setup.cancel"], + configSchema: xmtpChannelConfigSchema, + onboarding: xmtpOnboardingAdapter, + config: { + listAccountIds: (cfg) => listXmtpAccountIds(cfg as CoreConfig), + resolveAccount: (cfg, accountId) => resolveXmtpAccount({ cfg: cfg as CoreConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultXmtpAccountId(cfg as CoreConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: CHANNEL_ID, + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: CHANNEL_ID, + accountId, + clearBaseFields: ["name", "walletKey", "dbEncryptionKey", "env", "debug", "publicAddress"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + env: account.env, + publicAddress: account.publicAddress || undefined, + }), + }, + security: { + resolveDmPolicy: ({ account }) => ({ + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: "channels.xmtp.dmPolicy", + allowFromPath: "channels.xmtp.allowFrom", + }), + }, + pairing: { + idLabel: "address", + normalizeAllowEntry: (entry) => normalizeXmtpAddress(entry), + }, + messaging: { + normalizeTarget: normalizeXmtpMessagingTarget, + targetResolver: { + looksLikeId: (raw) => { + const t = raw.trim(); + if (!t) { + return false; + } + return (t.length >= 20 && /^0x[0-9a-fA-F]+$/.test(t)) || t.includes("/"); + }, + hint: "
", + }, + }, + actions: xmtpMessageActions, + agentPrompt: { + messageToolHints: () => [ + "- XMTP targets are wallet addresses or conversation topics. Use `to=
` for `action=send`.", + ], + }, + directory: { + self: async () => null, + listPeers: async () => [], + listGroups: async () => [], + }, + outbound: xmtpOutbound, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; + if (!lastError) { + return []; + } + return [ + { + channel: CHANNEL_ID, + accountId: account.accountId, + kind: "runtime", + message: `Channel error: ${lastError}`, + }, + ]; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + env: snapshot.env ?? "production", + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => { + if (!account.walletKey || !account.dbEncryptionKey) { + return { + ok: false, + error: "Not configured: walletKey and dbEncryptionKey required.", + }; + } + try { + const runtime = getXmtpRuntime(); + const stateDir = runtime.state.resolveStateDir(); + const agent = await createAgentFromAccount(account, stateDir); + const limit = timeoutMs ?? 10000; + await Promise.race([ + agent.start(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Probe timed out")), limit), + ), + ]); + await agent.stop(); + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + }, + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + env: account.env, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastProbeAt: runtime?.lastProbeAt ?? null, + }), + }, + gateway: { + startAccount: async (ctx) => { + const { account, abortSignal, setStatus, log } = ctx; + ensureXmtpConfigured(account); + const runtime = getXmtpRuntime(); + + if (!account.config.publicAddress) { + const cfg = runtime.config.loadConfig(); + const next = setAccountPublicAddress(cfg, account.accountId, account.publicAddress); + await runtime.config.writeConfigFile(next); + log?.info(`[${account.accountId}] backfilled publicAddress to config`); + } + + setStatus({ accountId: account.accountId, env: account.env }); + + log?.info( + `[${account.accountId}] starting XMTP provider (env: ${account.env}, agent: ${account.publicAddress})`, + ); + + const stateDir = runtime.state.resolveStateDir(); + const agent = await createAgentFromAccount(account, stateDir); + + agent.on("text", async (msgCtx) => { + log?.info( + `[${account.accountId}] text event: ${JSON.stringify({ content: msgCtx.message?.content?.slice(0, 50), id: msgCtx.message?.id })}`, + ); + const sender = await msgCtx.getSenderAddress(); + const conversation = msgCtx.conversation; + const conversationId = conversation?.id as string; + handleInboundMessage( + account, + sender, + conversationId, + msgCtx.message.content, + msgCtx.message.id, + runtime, + log, + ).catch((err) => { + log?.error(`[${account.accountId}] Message handling failed: ${String(err)}`); + }); + }); + + await agent.start(); + setClientForAccount(account.accountId, agent); + + log?.info(`[${account.accountId}] XMTP provider started`); + + await new Promise((resolve) => { + const onAbort = () => { + void stopAgent(account.accountId, log).finally(resolve); + }; + if (abortSignal?.aborted) { + onAbort(); + return; + } + abortSignal?.addEventListener("abort", onAbort, { once: true }); + }); + }, + stopAccount: async (ctx) => { + const { account, log } = ctx; + log?.info(`[${account.accountId}] stopping XMTP provider`); + await stopAgent(account.accountId, log); + }, + }, +}; diff --git a/extensions/xmtp/src/config-schema.ts b/extensions/xmtp/src/config-schema.ts new file mode 100644 index 000000000000..40e50dca4fa4 --- /dev/null +++ b/extensions/xmtp/src/config-schema.ts @@ -0,0 +1,60 @@ +/** + * XMTP channel configuration schema + * Used for Control UI form generation + */ + +import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk"; +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +/** + * Zod schema for channels.xmtp.* configuration + */ +export const XMTPConfigSchema = z.object({ + /** Account name (optional display name) */ + name: z.string().optional(), + + /** Whether this channel is enabled */ + enabled: z.boolean().optional(), + + /** Markdown formatting overrides (tables). */ + markdown: MarkdownConfigSchema, + + /** Wallet private key (hex or env var name). Required for agent identity. */ + walletKey: z.string().optional(), + + /** DB encryption key for local XMTP storage. Required. */ + dbEncryptionKey: z.string().optional(), + + /** XMTP environment: production (default) or dev. */ + env: z.enum(["production", "dev"]).optional(), + + /** Enable debug logging for this account. */ + debug: z.boolean().optional(), + + /** Sender access policy (default: pairing). */ + dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + + /** Allowlist of addresses permitted to message the agent. */ + allowFrom: z.array(allowFromEntry).optional(), + + /** Controls how group messages are handled (default: open). */ + groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), + + /** Allowlist of conversation IDs (groupPolicy "allowlist"). Include "*" to allow all. */ + groups: z.array(z.string()).optional(), + + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit: z.number().int().min(100).optional(), + + /** Ethereum address for display; derived from walletKey if not set. */ + publicAddress: z.string().optional(), +}); + +export type XMTPConfigInput = z.infer; + +/** + * JSON Schema for Control UI (converted from Zod) + */ +export const xmtpChannelConfigSchema = buildChannelConfigSchema(XMTPConfigSchema); diff --git a/extensions/xmtp/src/config-types.ts b/extensions/xmtp/src/config-types.ts new file mode 100644 index 000000000000..c3e3ebc2696e --- /dev/null +++ b/extensions/xmtp/src/config-types.ts @@ -0,0 +1,39 @@ +/** + * XMTP channel configuration types. + * Self-contained for the extension to avoid cross-package imports. + */ + +export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; +export type GroupPolicy = "open" | "disabled" | "allowlist"; + +export type XMTPAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this XMTP account. Default: true. */ + enabled?: boolean; + /** Wallet private key (hex or env var name). Required for agent identity. */ + walletKey?: string; + /** DB encryption key for local XMTP storage. Required. */ + dbEncryptionKey?: string; + /** XMTP environment: production (default) or dev. */ + env?: "production" | "dev"; + /** Enable debug logging for this account. */ + debug?: boolean; + /** Sender access policy (default: pairing). Controls who can message the agent. */ + dmPolicy?: DmPolicy; + /** Allowlist of addresses permitted to message the agent. */ + allowFrom?: Array; + /** Controls how group messages are handled (default: open). */ + groupPolicy?: GroupPolicy; + /** Allowlist of conversation IDs the agent listens in (groupPolicy "allowlist"). Include "*" to allow all. */ + groups?: string[]; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Ethereum address for display; derived from walletKey if not set. */ + publicAddress?: string; +}; + +export type XMTPConfig = { + /** Per-account XMTP configuration (multi-account). */ + accounts?: Record; +} & XMTPAccountConfig; diff --git a/extensions/xmtp/src/lib/env-file.ts b/extensions/xmtp/src/lib/env-file.ts new file mode 100644 index 000000000000..1b5ed8b8a27e --- /dev/null +++ b/extensions/xmtp/src/lib/env-file.ts @@ -0,0 +1,56 @@ +/** + * Write XMTP env vars to ~/.openclaw/.env for Agent.createFromEnv(). + */ + +import fs from "node:fs"; +import path from "node:path"; +import { getXmtpRuntime } from "../runtime.js"; + +const XMTP_ENV_KEYS = ["XMTP_WALLET_KEY", "XMTP_DB_ENCRYPTION_KEY", "XMTP_ENV"] as const; + +export function writeXmtpVarsToEnv(params: { + walletKey: string; + dbEncryptionKey: string; + env: "production" | "dev"; +}): string { + const configDir = getXmtpRuntime().state.resolveStateDir(); + const envPath = path.join(configDir, ".env"); + const vars: Record = { + XMTP_WALLET_KEY: params.walletKey, + XMTP_DB_ENCRYPTION_KEY: params.dbEncryptionKey, + XMTP_ENV: params.env, + }; + + let lines: string[] = []; + if (fs.existsSync(envPath)) { + const raw = fs.readFileSync(envPath, "utf-8"); + lines = raw.split(/\r?\n/); + } + + const keyPrefix = (key: string) => + new RegExp(`^\\s*(?:export\\s+)?${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=`); + const updated = new Set(); + + const nextLines = lines.map((line) => { + for (const key of XMTP_ENV_KEYS) { + if (keyPrefix(key).test(line)) { + updated.add(key); + return `${key}=${vars[key]}`; + } + } + return line; + }); + + for (const key of XMTP_ENV_KEYS) { + if (!updated.has(key)) { + nextLines.push(`${key}=${vars[key]}`); + } + } + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(envPath, `${nextLines.join("\n")}\n`, "utf-8"); + fs.chmodSync(envPath, 0o600); + return envPath; +} diff --git a/extensions/xmtp/src/lib/identity.ts b/extensions/xmtp/src/lib/identity.ts new file mode 100644 index 000000000000..4622d2871e76 --- /dev/null +++ b/extensions/xmtp/src/lib/identity.ts @@ -0,0 +1,22 @@ +/** + * XMTP identity helpers: key generation and wallet address derivation. + */ + +import { webcrypto } from "node:crypto"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; + +export function generateEncryptionKeyHex(): string { + const bytes = webcrypto.getRandomValues(new Uint8Array(32)); + return Buffer.from(bytes).toString("hex"); +} + +/** Re-export for onboarding key generation. */ +export { generatePrivateKey }; + +/** + * Derive Ethereum wallet address from a private key (hex, with or without 0x). + */ +export function walletAddressFromPrivateKey(walletKey: string): string { + const hexKey = walletKey.startsWith("0x") ? walletKey : `0x${walletKey}`; + return privateKeyToAccount(hexKey as `0x${string}`).address; +} diff --git a/extensions/xmtp/src/lib/xmtp-client.ts b/extensions/xmtp/src/lib/xmtp-client.ts new file mode 100644 index 000000000000..9059117be085 --- /dev/null +++ b/extensions/xmtp/src/lib/xmtp-client.ts @@ -0,0 +1,66 @@ +/** + * XMTP Agent SDK client helpers: env override, agent creation, temporary client. + */ + +import { Agent } from "@xmtp/agent-sdk"; +import * as path from "node:path"; +import type { ResolvedXmtpAccount } from "../accounts.js"; +import type { XmtpAgentRuntime } from "../types.js"; +import { getXmtpRuntime } from "../runtime.js"; + +export async function withEnv(vars: Record, fn: () => Promise): Promise { + const prev = Object.fromEntries(Object.keys(vars).map((k) => [k, process.env[k]])); + Object.assign(process.env, vars); + try { + return await fn(); + } finally { + Object.assign(process.env, prev); + } +} + +export async function createAgentFromAccount( + account: ResolvedXmtpAccount, + stateDir: string, +): Promise { + const dbDir = path.join(stateDir, "xmtp", account.accountId); + return withEnv( + { + XMTP_WALLET_KEY: account.walletKey, + XMTP_DB_ENCRYPTION_KEY: account.dbEncryptionKey, + XMTP_ENV: account.env, + XMTP_DB_DIRECTORY: dbDir, + }, + async () => (await Agent.createFromEnv()) as unknown as XmtpAgentRuntime, + ); +} + +export async function runTemporaryXmtpClient(params: { + walletKey: string; + dbEncryptionKey: string; + env: "production" | "dev"; + accountId?: string; +}): Promise { + const accountId = params.accountId ?? "default"; + const stateDir = getXmtpRuntime().state.resolveStateDir(); + const dbDir = path.join(stateDir, "xmtp", accountId); + + await withEnv( + { + XMTP_WALLET_KEY: params.walletKey, + XMTP_DB_ENCRYPTION_KEY: params.dbEncryptionKey, + XMTP_ENV: params.env, + XMTP_DB_DIRECTORY: dbDir, + }, + async () => { + const agent = (await Agent.createFromEnv()) as { + start: () => Promise; + stop: () => Promise; + }; + try { + await agent.start(); + } finally { + await agent.stop(); + } + }, + ); +} diff --git a/extensions/xmtp/src/onboarding.ts b/extensions/xmtp/src/onboarding.ts new file mode 100644 index 000000000000..17ada640b85e --- /dev/null +++ b/extensions/xmtp/src/onboarding.ts @@ -0,0 +1,202 @@ +import { + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, + type OpenClawConfig, +} from "openclaw/plugin-sdk"; +import type { DmPolicy } from "./config-types.js"; +import { + getXmtpSection, + listXmtpAccountIds, + resolveXmtpAccount, + updateXmtpSection, + type CoreConfig, +} from "./accounts.js"; +import { writeXmtpVarsToEnv } from "./lib/env-file.js"; +import { + generateEncryptionKeyHex, + generatePrivateKey, + walletAddressFromPrivateKey, +} from "./lib/identity.js"; +import { runTemporaryXmtpClient } from "./lib/xmtp-client.js"; + +const channel = "xmtp" as const; + +function setXmtpDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return updateXmtpSection(cfg, { dmPolicy }); +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "XMTP", + channel, + policyKey: "channels.xmtp.dmPolicy", + allowFromKey: "channels.xmtp.allowFrom", + getCurrent: (cfg) => getXmtpSection(cfg as CoreConfig)?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setXmtpDmPolicy(cfg, policy), +}; + +export const xmtpOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + + getStatus: async ({ cfg }) => { + const configured = listXmtpAccountIds(cfg as CoreConfig).some( + (accountId) => resolveXmtpAccount({ cfg: cfg as CoreConfig, accountId }).configured, + ); + const account = resolveXmtpAccount({ cfg: cfg as CoreConfig }); + + return { + channel, + configured, + statusLines: [ + `XMTP: ${configured ? "configured" : "needs setup"}`, + `Environment: ${account.env}`, + ].filter(Boolean), + selectionHint: configured ? "ready" : "wallet key, db key, env", + quickstartScore: 0, + }; + }, + + configure: async ({ cfg, prompter, createNewIdentity }) => { + let next = cfg; + const account = resolveXmtpAccount({ cfg: next as CoreConfig }); + + if (account.configured && !createNewIdentity) { + const action = await prompter.select({ + message: "XMTP already configured.", + options: [ + { value: "generate" as const, label: "Generate new one" }, + { value: "check" as const, label: "Check our current one" }, + { value: "skip" as const, label: "Skip" }, + ], + initialValue: "skip", + }); + if (action === "check") { + const publicAddress = walletAddressFromPrivateKey(account.walletKey); + await prompter.note("Initializing XMTP client…", "XMTP"); + try { + await runTemporaryXmtpClient({ + walletKey: account.walletKey, + dbEncryptionKey: account.dbEncryptionKey, + env: account.env, + }); + await prompter.note( + `XMTP client verified.\n\nPublic address: ${publicAddress}`, + "Verify client", + ); + } catch (err) { + await prompter.note( + `Client verification failed: ${err instanceof Error ? err.message : String(err)}`, + "Verify client", + ); + } + return { cfg: next }; + } + if (action === "skip") { + return { cfg: next }; + } + } + + const env = await prompter.select({ + message: "Environment", + options: [ + { value: "production" as const, label: "Production" }, + { value: "dev" as const, label: "Dev" }, + ], + initialValue: "production", + }); + + const keySource = await prompter.select({ + message: "Keys", + options: [ + { value: "random" as const, label: "Random (generate new keys)" }, + { value: "custom" as const, label: "Custom (enter existing keys)" }, + ], + initialValue: "random", + }); + + let walletKey: string; + let dbEncryptionKey: string; + + let publicAddress: string; + + if (keySource === "random") { + walletKey = generatePrivateKey(); + dbEncryptionKey = generateEncryptionKeyHex(); + publicAddress = walletAddressFromPrivateKey(walletKey); + } else { + walletKey = await prompter.text({ + message: "Wallet key (private key)", + validate: (value) => { + const raw = String(value ?? "").trim(); + return raw ? undefined : "Required"; + }, + }); + dbEncryptionKey = await prompter.text({ + message: "DB encryption key", + validate: (value) => { + const raw = String(value ?? "").trim(); + return raw ? undefined : "Required"; + }, + }); + walletKey = walletKey.trim(); + dbEncryptionKey = dbEncryptionKey.trim(); + publicAddress = walletAddressFromPrivateKey(walletKey); + } + + next = updateXmtpSection(next, { + enabled: true, + walletKey, + dbEncryptionKey, + env, + publicAddress, + }); + + writeXmtpVarsToEnv({ walletKey, dbEncryptionKey, env }); + + await prompter.note("Initializing XMTP client…", "XMTP"); + + try { + await runTemporaryXmtpClient({ walletKey, dbEncryptionKey, env }); + await prompter.note( + `XMTP configured. Keys saved to config and .env.\n\nPublic address: ${publicAddress}\n\nSave this address; it identifies your XMTP identity.`, + "XMTP", + ); + } catch (err) { + await prompter.note( + `Client initialization failed: ${err instanceof Error ? err.message : String(err)}\n\nKeys were saved to config and .env. You can retry or start the gateway later.`, + "XMTP", + ); + } + + return { cfg: next }; + }, + + dmPolicy, + + disable: (cfg) => updateXmtpSection(cfg, { enabled: false }), + + verifyClient: async ({ cfg, prompter }) => { + const account = resolveXmtpAccount({ cfg: cfg as CoreConfig }); + if (!account.walletKey || !account.dbEncryptionKey) { + await prompter.note("XMTP not configured. Run configure first.", "Verify client"); + return; + } + const publicAddress = walletAddressFromPrivateKey(account.walletKey); + await prompter.note("Initializing XMTP client…", "XMTP"); + try { + await runTemporaryXmtpClient({ + walletKey: account.walletKey, + dbEncryptionKey: account.dbEncryptionKey, + env: account.env, + }); + await prompter.note( + `XMTP client verified.\n\nPublic address: ${publicAddress}`, + "Verify client", + ); + } catch (err) { + await prompter.note( + `Client verification failed: ${err instanceof Error ? err.message : String(err)}`, + "Verify client", + ); + } + }, +}; diff --git a/extensions/xmtp/src/outbound.ts b/extensions/xmtp/src/outbound.ts new file mode 100644 index 000000000000..2609da15d545 --- /dev/null +++ b/extensions/xmtp/src/outbound.ts @@ -0,0 +1,66 @@ +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk"; +import type { XmtpAgentRuntime } from "./types.js"; +import { resolveXmtpAccount, type CoreConfig } from "./accounts.js"; +import { getXmtpRuntime } from "./runtime.js"; + +const CHANNEL_ID = "xmtp"; +const agents = new Map(); + +/** + * Set the agent runtime for an account (called from channel.ts during startAccount) + */ +export function setClientForAccount(accountId: string, agent: XmtpAgentRuntime | null): void { + if (agent) { + agents.set(accountId, agent); + } else { + agents.delete(accountId); + } +} + +/** + * Get the agent runtime for an account + */ +export function getClientForAccount(accountId: string): XmtpAgentRuntime | undefined { + return agents.get(accountId); +} + +/** + * Get the agent runtime for an account or throw + */ +export function getAgentOrThrow(accountId: string): XmtpAgentRuntime { + const agent = agents.get(accountId); + if (!agent) { + throw new Error(`XMTP agent not running for account ${accountId}. Is the gateway started?`); + } + return agent; +} + +export const xmtpOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: (text, limit) => getXmtpRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + + sendText: async ({ cfg, to, text, accountId }) => { + const account = resolveXmtpAccount({ cfg: cfg as CoreConfig, accountId }); + const agent = getAgentOrThrow(account.accountId); + const conversation = await agent.client.conversations.getConversationById(to); + if (!conversation) { + throw new Error(`Conversation not found: ${to.slice(0, 12)}...`); + } + await conversation.sendText(text); + return { channel: CHANNEL_ID, messageId: `xmtp-${Date.now()}` }; + }, + + sendMedia: async ({ cfg, to, accountId, mediaUrl, text }) => { + const account = resolveXmtpAccount({ cfg: cfg as CoreConfig, accountId }); + const agent = getAgentOrThrow(account.accountId); + const conversation = await agent.client.conversations.getConversationById(to); + if (!conversation) { + throw new Error(`Conversation not found: ${to.slice(0, 12)}...`); + } + const url = mediaUrl ?? text ?? ""; + await conversation.sendText(url); + return { channel: CHANNEL_ID, messageId: `xmtp-${Date.now()}` }; + }, +}; diff --git a/extensions/xmtp/src/runtime.ts b/extensions/xmtp/src/runtime.ts new file mode 100644 index 000000000000..70f15b61eb6b --- /dev/null +++ b/extensions/xmtp/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setXmtpRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getXmtpRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("XMTP runtime not initialized"); + } + return runtime; +} diff --git a/extensions/xmtp/src/setup.ts b/extensions/xmtp/src/setup.ts new file mode 100644 index 000000000000..c936fd9a4b95 --- /dev/null +++ b/extensions/xmtp/src/setup.ts @@ -0,0 +1,101 @@ +/** + * XMTP setup: generate identity, show public address, persist. + * No QR, no invite URL, no join logic. + */ + +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; +import { resolveXmtpAccount, updateXmtpSection, type CoreConfig } from "./accounts.js"; +import { writeXmtpVarsToEnv } from "./lib/env-file.js"; +import { + generateEncryptionKeyHex, + generatePrivateKey, + walletAddressFromPrivateKey, +} from "./lib/identity.js"; +import { runTemporaryXmtpClient } from "./lib/xmtp-client.js"; +import { getXmtpRuntime } from "./runtime.js"; + +let setupResult: { + walletKey: string; + dbEncryptionKey: string; + env: "production" | "dev"; + publicAddress: string; +} | null = null; + +export async function handleSetup(params: { + accountId?: string; + env?: "production" | "dev"; +}): Promise<{ publicAddress: string }> { + const env = params.env === "dev" ? "dev" : "production"; + + const walletKey = generatePrivateKey(); + const dbEncryptionKey = generateEncryptionKeyHex(); + const publicAddress = walletAddressFromPrivateKey(walletKey); + + await runTemporaryXmtpClient({ + walletKey, + dbEncryptionKey, + env, + accountId: params.accountId ?? DEFAULT_ACCOUNT_ID, + }); + + setupResult = { walletKey, dbEncryptionKey, env, publicAddress }; + return { publicAddress }; +} + +export function handleSetupStatus(): { + configured: boolean; + publicAddress?: string; + setupPending?: boolean; +} { + if (setupResult) { + return { + configured: false, + setupPending: true, + publicAddress: setupResult.publicAddress, + }; + } + + const runtime = getXmtpRuntime(); + const cfg = runtime.config.loadConfig() as OpenClawConfig; + const account = resolveXmtpAccount({ cfg: cfg as CoreConfig }); + + return { + configured: account.configured, + publicAddress: account.configured ? account.publicAddress : undefined, + }; +} + +export async function handleSetupComplete(): Promise<{ saved: true }> { + if (!setupResult) { + throw new Error("No active setup to complete. Run xmtp.setup first."); + } + + const runtime = getXmtpRuntime(); + const cfg = runtime.config.loadConfig() as OpenClawConfig; + + const next = updateXmtpSection(cfg, { + walletKey: setupResult.walletKey, + dbEncryptionKey: setupResult.dbEncryptionKey, + env: setupResult.env, + publicAddress: setupResult.publicAddress, + enabled: true, + }); + + writeXmtpVarsToEnv({ + walletKey: setupResult.walletKey, + dbEncryptionKey: setupResult.dbEncryptionKey, + env: setupResult.env, + }); + + await runtime.config.writeConfigFile(next); + setupResult = null; + + return { saved: true }; +} + +export function handleSetupCancel(): { cancelled: boolean } { + const wasPending = setupResult !== null; + setupResult = null; + return { cancelled: wasPending }; +} diff --git a/extensions/xmtp/src/types.ts b/extensions/xmtp/src/types.ts new file mode 100644 index 000000000000..cfcefa2c16f9 --- /dev/null +++ b/extensions/xmtp/src/types.ts @@ -0,0 +1,27 @@ +/** + * Types for XMTP agent runtime and message handling. + * Agent from @xmtp/agent-sdk also has sendText(to, text) for sending to an address. + */ + +export interface XmtpConversation { + sendText(text: string, isOptimistic?: boolean): Promise; +} + +export interface XmtpClientConversations { + getConversationById(id: string): Promise; +} + +export interface XmtpAgentRuntime { + readonly client: { conversations: XmtpClientConversations }; + sendText(to: string, text: string): Promise; + on( + event: "text", + handler: (ctx: { + message: { content: string; id?: string }; + conversation?: { id?: string }; + getSenderAddress(): Promise; + }) => void | Promise, + ): void; + start(): Promise; + stop(): Promise; +} diff --git a/extensions/xmtp/src/xmtp-commands.ts b/extensions/xmtp/src/xmtp-commands.ts new file mode 100644 index 000000000000..3144168c72c6 --- /dev/null +++ b/extensions/xmtp/src/xmtp-commands.ts @@ -0,0 +1,22 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { resolveDefaultXmtpAccountId, resolveXmtpAccount, type CoreConfig } from "./accounts.js"; + +export function registerXmtpCommands(api: OpenClawPluginApi): void { + api.registerCommand({ + name: "address", + description: "Print your XMTP public agent address.", + acceptsArgs: false, + requireAuth: true, + handler: async (ctx) => { + const cfg = ctx.config as CoreConfig; + const account = resolveXmtpAccount({ + cfg, + accountId: resolveDefaultXmtpAccountId(cfg), + }); + if (!account.configured) { + return { text: "XMTP is not configured. Run openclaw configure and set up XMTP." }; + } + return { text: `This is your XMTP public address: ${account.publicAddress}` }; + }, + }); +} diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index b0709b9b5785..08309b2efe15 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -241,7 +241,6 @@ function buildPresenceSchema() { function buildChannelManagementSchema() { return { name: Type.Optional(Type.String()), - inviteUrl: Type.Optional(Type.String({ description: "Invite URL or slug for channel-join." })), type: Type.Optional(Type.Number()), parentId: Type.Optional(Type.String()), topic: Type.Optional(Type.String()), diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index 01f9eca2ddd5..a98bdb06991f 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -36,7 +36,6 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "channel-info", "channel-list", "channel-create", - "channel-join", "channel-edit", "channel-delete", "channel-move", diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index 6ee41f155018..f48f51695505 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -1,5 +1,4 @@ import type { GroupPolicy } from "./types.base.js"; -import type { ConvosConfig } from "./types.convos.js"; import type { DiscordConfig } from "./types.discord.js"; import type { FeishuConfig } from "./types.feishu.js"; import type { GoogleChatConfig } from "./types.googlechat.js"; @@ -36,6 +35,5 @@ export type ChannelsConfig = { signal?: SignalConfig; imessage?: IMessageConfig; msteams?: MSTeamsConfig; - convos?: ConvosConfig; [key: string]: unknown; }; diff --git a/src/config/types.convos.ts b/src/config/types.convos.ts deleted file mode 100644 index a3e5a05d195f..000000000000 --- a/src/config/types.convos.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { - BlockStreamingCoalesceConfig, - DmPolicy, - GroupPolicy, - MarkdownConfig, -} from "./types.base.js"; -import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; -import type { DmConfig } from "./types.messages.js"; - -export type ConvosReactionLevel = "off" | "ack" | "minimal" | "extensive"; - -export type ConvosAccountConfig = { - /** Optional display name for this account (used in CLI/UI lists). */ - name?: string; - /** Optional provider capability tags used for agent/runtime guidance. */ - capabilities?: string[]; - /** Markdown formatting overrides (tables). */ - markdown?: MarkdownConfig; - /** Allow channel-initiated config writes (default: true). */ - configWrites?: boolean; - /** If false, do not start this Convos account. Default: true. */ - enabled?: boolean; - /** Hex-encoded XMTP private key (auto-generated on first run). */ - privateKey?: string; - /** XMTP environment: production (default) or dev. */ - env?: "production" | "dev"; - /** Enable debug logging for this account. */ - debug?: boolean; - /** Direct message access policy (default: pairing). */ - dmPolicy?: DmPolicy; - allowFrom?: Array; - /** Optional allowlist for group senders. */ - groupAllowFrom?: Array; - /** - * Controls how group messages are handled: - * - "open": groups bypass allowFrom, no extra gating - * - "disabled": block all group messages - * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom - */ - groupPolicy?: GroupPolicy; - /** Max group messages to keep as history context (0 disables). */ - historyLimit?: number; - /** Max DM turns to keep as history context. */ - dmHistoryLimit?: number; - /** Per-DM config overrides keyed by user ID. */ - dms?: Record; - /** Outbound text chunk size (chars). Default: 4000. */ - textChunkLimit?: number; - /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ - chunkMode?: "length" | "newline"; - blockStreaming?: boolean; - /** Merge streamed block replies before sending. */ - blockStreamingCoalesce?: BlockStreamingCoalesceConfig; - /** Action toggles for message tool capabilities. */ - actions?: { - /** Enable/disable sending reactions via message tool (default: true). */ - reactions?: boolean; - }; - /** - * Controls agent reaction behavior: - * - "off": No reactions - * - "ack": Only automatic ack reactions - * - "minimal": Agent can react sparingly (default) - * - "extensive": Agent can react liberally - */ - reactionLevel?: ConvosReactionLevel; - /** Heartbeat visibility settings for this channel. */ - heartbeat?: ChannelHeartbeatVisibilityConfig; - /** The conversation ID where OpenClaw communicates with its owner. */ - ownerConversationId?: string; -}; - -export type ConvosConfig = { - /** Optional per-account Convos configuration (multi-account). */ - accounts?: Record; -} & ConvosAccountConfig; diff --git a/src/config/types.ts b/src/config/types.ts index 6dfba866632b..ba4ca1d70128 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -7,7 +7,6 @@ export * from "./types.auth.js"; export * from "./types.base.js"; export * from "./types.browser.js"; export * from "./types.channels.js"; -export * from "./types.convos.js"; export * from "./types.openclaw.js"; export * from "./types.cron.js"; export * from "./types.discord.js"; diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index d27577824d17..d2cb9775a4e5 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -41,7 +41,6 @@ export const MESSAGE_ACTION_TARGET_MODE: Record state.handleWhatsAppStart(force), onWhatsAppWait: () => state.handleWhatsAppWait(), onWhatsAppLogout: () => state.handleWhatsAppLogout(), - onConvosSetup: () => state.handleConvosSetup(), - onConvosReset: () => state.handleConvosReset(), - onConvosResetConfirm: (deleteDb) => state.handleConvosResetConfirm(deleteDb), - onConvosResetCancel: () => state.handleConvosResetCancel(), onConfigPatch: (path, value) => updateConfigFormValue(state as unknown as ConfigState, path, value), onConfigSave: () => state.handleChannelConfigSave(), @@ -1094,7 +1083,6 @@ export function renderApp(state: AppViewState) { ${renderExecApprovalPrompt(state)} ${renderGatewayUrlConfirmation(state)} - ${renderConvosResetConfirmation(state)} `; } diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 07b0f321dbb8..20d9dc44f0f0 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -98,12 +98,6 @@ export type AppViewState = { whatsappLoginQrDataUrl: string | null; whatsappLoginConnected: boolean | null; whatsappBusy: boolean; - convosMessage: string | null; - convosInviteUrl: string | null; - convosQrDataUrl: string | null; - convosBusy: boolean; - convosJoined: boolean; - convosResetPending: boolean; nostrProfileFormState: NostrProfileFormState | null; nostrProfileAccountId: string | null; configFormDirty: boolean; @@ -180,10 +174,6 @@ export type AppViewState = { handleWhatsAppStart: (force: boolean) => Promise; handleWhatsAppWait: () => Promise; handleWhatsAppLogout: () => Promise; - handleConvosSetup: () => Promise; - handleConvosReset: () => void; - handleConvosResetConfirm: (deleteDb: boolean) => Promise; - handleConvosResetCancel: () => void; handleChannelConfigSave: () => Promise; handleChannelConfigReload: () => Promise; handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 73e46be8f604..f918a5bd5d06 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -32,10 +32,6 @@ import type { NostrProfileFormState } from "./views/channels.nostr-profile-form. import { handleChannelConfigReload as handleChannelConfigReloadInternal, handleChannelConfigSave as handleChannelConfigSaveInternal, - handleConvosSetup as handleConvosSetupInternal, - handleConvosReset as handleConvosResetInternal, - handleConvosResetCancel as handleConvosResetCancelInternal, - handleConvosResetConfirm as handleConvosResetConfirmInternal, handleNostrProfileCancel as handleNostrProfileCancelInternal, handleNostrProfileEdit as handleNostrProfileEditInternal, handleNostrProfileFieldChange as handleNostrProfileFieldChangeInternal, @@ -193,12 +189,6 @@ export class OpenClawApp extends LitElement { @state() whatsappLoginQrDataUrl: string | null = null; @state() whatsappLoginConnected: boolean | null = null; @state() whatsappBusy = false; - @state() convosMessage: string | null = null; - @state() convosInviteUrl: string | null = null; - @state() convosQrDataUrl: string | null = null; - @state() convosBusy = false; - @state() convosJoined = false; - @state() convosResetPending = false; @state() nostrProfileFormState: NostrProfileFormState | null = null; @state() nostrProfileAccountId: string | null = null; @@ -417,22 +407,6 @@ export class OpenClawApp extends LitElement { await handleWhatsAppLogoutInternal(this); } - async handleConvosSetup() { - await handleConvosSetupInternal(this); - } - - handleConvosReset() { - handleConvosResetInternal(this); - } - - handleConvosResetCancel() { - handleConvosResetCancelInternal(this); - } - - async handleConvosResetConfirm(deleteDb: boolean) { - await handleConvosResetConfirmInternal(this, deleteDb); - } - async handleChannelConfigSave() { await handleChannelConfigSaveInternal(this); } diff --git a/ui/src/ui/controllers/channels.ts b/ui/src/ui/controllers/channels.ts index a03f7a859f74..de50dadd3494 100644 --- a/ui/src/ui/controllers/channels.ts +++ b/ui/src/ui/controllers/channels.ts @@ -92,114 +92,3 @@ export async function logoutWhatsApp(state: ChannelsState) { state.whatsappBusy = false; } } - -// Track active polling interval -let convosJoinPollInterval: ReturnType | null = null; -let convosJoinPollTimeout: ReturnType | null = null; - -function stopConvosJoinPolling() { - if (convosJoinPollInterval) { - clearInterval(convosJoinPollInterval); - convosJoinPollInterval = null; - } - if (convosJoinPollTimeout) { - clearTimeout(convosJoinPollTimeout); - convosJoinPollTimeout = null; - } -} - -/** - * Shared helper: call a convos setup/reset method and start polling for join. - * Used by both `setupConvos` and `resetConvos` to avoid duplicated logic. - */ -async function startConvosSetupSession( - state: ChannelsState, - method: string, - params?: Record, -) { - if (!state.client || !state.connected || state.convosBusy) { - return; - } - - stopConvosJoinPolling(); - - state.convosBusy = true; - state.convosMessage = method === "convos.reset" ? "Resetting Convos..." : "Setting up Convos..."; - state.convosInviteUrl = null; - state.convosQrDataUrl = null; - state.convosJoined = false; - - try { - const res = await state.client.request<{ - inviteUrl?: string; - conversationId?: string; - qrDataUrl?: string; - }>(method, { - ...params, - timeoutMs: 60000, - }); - state.convosInviteUrl = res.inviteUrl ?? null; - state.convosQrDataUrl = res.qrDataUrl ?? null; - state.convosMessage = res.inviteUrl - ? "Scan the QR code or open the invite link in the Convos app..." - : "Setup completed."; - - // Start polling for join status if we got an invite URL - if (res.inviteUrl && state.client) { - const client = state.client; - - convosJoinPollInterval = setInterval(async () => { - try { - const status = await client.request<{ - active?: boolean; - joined?: boolean; - joinerInboxId?: string | null; - }>("convos.setup.status", { timeoutMs: 5000 }); - - if (status.joined) { - stopConvosJoinPolling(); - state.convosMessage = "Saving configuration..."; - - // Call convos.setup.complete to persist config (deferred write) - try { - await client.request("convos.setup.complete", { timeoutMs: 15000 }); - state.convosJoined = true; - state.convosQrDataUrl = null; - state.convosMessage = "Connected! You can now message through Convos."; - } catch (err) { - state.convosMessage = `Join detected but config save failed: ${String(err)}`; - } - } - } catch { - // Ignore polling errors - } - }, 3000); - - // Stop polling after 10 minutes - convosJoinPollTimeout = setTimeout( - () => { - stopConvosJoinPolling(); - if (!state.convosJoined && state.convosInviteUrl) { - state.convosMessage = "Invite still active. Join via the link above."; - } - }, - 10 * 60 * 1000, - ); - } - } catch (err) { - const label = method === "convos.reset" ? "Reset" : "Setup"; - state.convosMessage = `${label} failed: ${String(err)}`; - state.convosInviteUrl = null; - state.convosQrDataUrl = null; - } finally { - state.convosBusy = false; - } -} - -export async function setupConvos(state: ChannelsState) { - await startConvosSetupSession(state, "convos.setup"); -} - -export async function resetConvos(state: ChannelsState, deleteDb: boolean) { - await startConvosSetupSession(state, "convos.reset", { deleteDb }); -} diff --git a/ui/src/ui/controllers/channels.types.ts b/ui/src/ui/controllers/channels.types.ts index f4a42192940e..4fb8e6bc510a 100644 --- a/ui/src/ui/controllers/channels.types.ts +++ b/ui/src/ui/controllers/channels.types.ts @@ -12,10 +12,4 @@ export type ChannelsState = { whatsappLoginQrDataUrl: string | null; whatsappLoginConnected: boolean | null; whatsappBusy: boolean; - convosMessage: string | null; - convosInviteUrl: string | null; - convosQrDataUrl: string | null; - convosBusy: boolean; - convosJoined: boolean; - convosResetPending: boolean; }; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 497525ee9b85..27a1132bf2e9 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -265,22 +265,6 @@ export type MSTeamsStatus = { lastProbeAt?: number | null; }; -export type ConvosProbe = { - ok: boolean; - error?: string | null; -}; - -export type ConvosStatus = { - configured: boolean; - env?: string | null; - running: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - probe?: ConvosProbe | null; - lastProbeAt?: number | null; -}; - export type ConfigSnapshotIssue = { path: string; message: string; diff --git a/ui/src/ui/views/channels.convos.ts b/ui/src/ui/views/channels.convos.ts deleted file mode 100644 index 7afbd336a584..000000000000 --- a/ui/src/ui/views/channels.convos.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { html, nothing } from "lit"; -import type { ConvosStatus } from "../types.ts"; -import type { ChannelsProps } from "./channels.types.ts"; -import { formatAgo } from "../format.ts"; -import { renderChannelConfigSection } from "./channels.config.ts"; - -export function renderConvosCard(params: { - props: ChannelsProps; - convos?: ConvosStatus | null; - accountCountLabel: unknown; -}) { - const { props, convos, accountCountLabel } = params; - - return html` -
-
Convos
-
E2E encrypted messaging via XMTP.
- ${accountCountLabel} - -
-
- Configured - ${convos?.configured ? "Yes" : "No"} -
-
- Running - ${convos?.running ? "Yes" : "No"} -
-
- Environment - ${convos?.env ?? "n/a"} -
-
- Last start - ${convos?.lastStartAt ? formatAgo(convos.lastStartAt) : "n/a"} -
-
- Last probe - ${convos?.lastProbeAt ? formatAgo(convos.lastProbeAt) : "n/a"} -
-
- - ${ - convos?.lastError - ? html`
- ${convos.lastError} -
` - : nothing - } - - ${ - convos?.probe - ? html`
- Probe ${convos.probe.ok ? "ok" : "failed"} ${convos.probe.error ?? ""} -
` - : nothing - } - - ${ - props.convosMessage - ? html`
- ${props.convosMessage} -
` - : nothing - } - - ${ - props.convosJoined - ? html` -
-
-

Connected!

-

- You can now send and receive messages through Convos. -

-
-
- ` - : props.convosInviteUrl - ? html`
-
-

Scan with Convos App

- ${ - props.convosQrDataUrl - ? html`
- Convos Invite QR -
` - : nothing - } -
- (e.target as HTMLInputElement).select()} - /> -
- -

- Scan the QR code with the Convos iOS app, or copy the link and open it on your phone. -

-
-
` - : nothing - } - -
- - - ${ - convos?.configured - ? html`` - : nothing - } -
- - ${renderChannelConfigSection({ channelId: "convos", props })} -
- `; -} diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index c111eb8f55fb..c1983fef076c 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -3,7 +3,6 @@ import type { ChannelAccountSnapshot, ChannelUiMetaEntry, ChannelsStatusSnapshot, - ConvosStatus, DiscordStatus, GoogleChatStatus, IMessageStatus, @@ -17,7 +16,6 @@ import type { import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./channels.types.ts"; import { formatAgo } from "../format.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; -import { renderConvosCard } from "./channels.convos.ts"; import { renderDiscordCard } from "./channels.discord.ts"; import { renderGoogleChatCard } from "./channels.googlechat.ts"; import { renderIMessageCard } from "./channels.imessage.ts"; @@ -38,7 +36,6 @@ export function renderChannels(props: ChannelsProps) { const signal = (channels?.signal ?? null) as SignalStatus | null; const imessage = (channels?.imessage ?? null) as IMessageStatus | null; const nostr = (channels?.nostr ?? null) as NostrStatus | null; - const convos = (channels?.convos ?? null) as ConvosStatus | null; const channelOrder = resolveChannelOrder(props.snapshot); const orderedChannels = channelOrder .map((key, index) => ({ @@ -65,7 +62,6 @@ export function renderChannels(props: ChannelsProps) { signal, imessage, nostr, - convos, channelAccounts: props.snapshot?.channelAccounts ?? null, }), )} @@ -100,7 +96,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe if (snapshot?.channelOrder?.length) { return snapshot.channelOrder; } - return ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage", "nostr", "convos"]; + return ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage", "nostr"]; } function renderChannel(key: ChannelKey, props: ChannelsProps, data: ChannelsChannelData) { @@ -149,12 +145,6 @@ function renderChannel(key: ChannelKey, props: ChannelsProps, data: ChannelsChan imessage: data.imessage, accountCountLabel, }); - case "convos": - return renderConvosCard({ - props, - convos: data.convos, - accountCountLabel, - }); case "nostr": { const nostrAccounts = data.channelAccounts?.nostr ?? []; const primaryAccount = nostrAccounts[0]; diff --git a/ui/src/ui/views/channels.types.ts b/ui/src/ui/views/channels.types.ts index ad7b986bd6bc..59d7ee19f869 100644 --- a/ui/src/ui/views/channels.types.ts +++ b/ui/src/ui/views/channels.types.ts @@ -2,7 +2,6 @@ import type { ChannelAccountSnapshot, ChannelsStatusSnapshot, ConfigUiHints, - ConvosStatus, DiscordStatus, GoogleChatStatus, IMessageStatus, @@ -27,12 +26,6 @@ export type ChannelsProps = { whatsappQrDataUrl: string | null; whatsappConnected: boolean | null; whatsappBusy: boolean; - convosMessage: string | null; - convosInviteUrl: string | null; - convosQrDataUrl: string | null; - convosBusy: boolean; - convosJoined: boolean; - convosResetPending: boolean; configSchema: unknown; configSchemaLoading: boolean; configForm: Record | null; @@ -45,10 +38,6 @@ export type ChannelsProps = { onWhatsAppStart: (force: boolean) => void; onWhatsAppWait: () => void; onWhatsAppLogout: () => void; - onConvosSetup: () => void; - onConvosReset: () => void; - onConvosResetConfirm: (deleteDb: boolean) => void; - onConvosResetCancel: () => void; onConfigPatch: (path: Array, value: unknown) => void; onConfigSave: () => void; onConfigReload: () => void; @@ -69,6 +58,5 @@ export type ChannelsChannelData = { signal?: SignalStatus | null; imessage?: IMessageStatus | null; nostr?: NostrStatus | null; - convos?: ConvosStatus | null; channelAccounts?: Record | null; }; diff --git a/ui/src/ui/views/convos-reset-confirmation.ts b/ui/src/ui/views/convos-reset-confirmation.ts deleted file mode 100644 index a38ac250c450..000000000000 --- a/ui/src/ui/views/convos-reset-confirmation.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { html, nothing } from "lit"; -import type { AppViewState } from "../app-view-state.ts"; - -/** - * Confirmation modal for resetting the Convos integration. - * Requires the user to type "RESET" before the confirm button becomes active. - * Optionally allows deleting local XMTP database files. - */ -export function renderConvosResetConfirmation(state: AppViewState) { - if (!state.convosResetPending) { - return nothing; - } - - // Track confirmation input and checkbox via the DOM (no extra state needed) - let confirmInput: HTMLInputElement | null = null; - let deleteDbCheckbox: HTMLInputElement | null = null; - - function updateConfirmButton() { - const btn = confirmInput - ?.closest(".exec-approval-card") - ?.querySelector("[data-reset-confirm]") as HTMLButtonElement | null; - if (btn && confirmInput) { - btn.disabled = confirmInput.value !== "RESET"; - } - } - - return html` - - `; -}