diff --git a/extensions/xmtp/skills/xmtp-channel.md b/extensions/xmtp/skills/xmtp-channel.md deleted file mode 100644 index 0ac9eec062e6..000000000000 --- a/extensions/xmtp/skills/xmtp-channel.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -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.test.ts b/extensions/xmtp/src/accounts.test.ts index a732b302e210..09876f76275a 100644 --- a/extensions/xmtp/src/accounts.test.ts +++ b/extensions/xmtp/src/accounts.test.ts @@ -3,7 +3,6 @@ * No network access needed — tests pure config parsing. */ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; import { autoProvisionAccount, @@ -14,6 +13,7 @@ import { type CoreConfig, type ResolvedXmtpAccount, } from "./accounts.js"; +import { createMockRuntime } from "./test-utils/unit-helpers.js"; // Use a real 32-byte hex private key for tests that need address derivation const VALID_WALLET_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; @@ -373,17 +373,13 @@ function makeAccount(overrides?: Partial): ResolvedXmtpAcco }; } -function makeMockRuntime(): { runtime: PluginRuntime; writeConfigFile: ReturnType } { - const writeConfigFile = vi.fn(async () => {}); - const loadConfig = vi.fn(() => ({ channels: { xmtp: {} } })); - const runtime = { config: { loadConfig, writeConfigFile } } as unknown as PluginRuntime; - return { runtime, writeConfigFile }; -} - describe("autoProvisionAccount", () => { it("generates both keys when both are missing", async () => { const account = makeAccount(); - const { runtime, writeConfigFile } = makeMockRuntime(); + const { + runtime, + mocks: { writeConfigFile }, + } = createMockRuntime(); const log = { info: vi.fn(), error: vi.fn() }; const result = await autoProvisionAccount(account, runtime, log as any); @@ -407,7 +403,10 @@ describe("autoProvisionAccount", () => { walletKey: VALID_WALLET_KEY, publicAddress: VALID_ADDRESS, }); - const { runtime, writeConfigFile } = makeMockRuntime(); + const { + runtime, + mocks: { writeConfigFile }, + } = createMockRuntime(); const log = { info: vi.fn(), error: vi.fn() }; const result = await autoProvisionAccount(account, runtime, log as any); @@ -427,7 +426,10 @@ describe("autoProvisionAccount", () => { it("generates only walletKey when dbEncryptionKey is present", async () => { const existingEncKey = "ab".repeat(32); const account = makeAccount({ dbEncryptionKey: existingEncKey }); - const { runtime, writeConfigFile } = makeMockRuntime(); + const { + runtime, + mocks: { writeConfigFile }, + } = createMockRuntime(); const log = { info: vi.fn(), error: vi.fn() }; const result = await autoProvisionAccount(account, runtime, log as any); @@ -452,7 +454,10 @@ describe("autoProvisionAccount", () => { publicAddress: VALID_ADDRESS, configured: true, }); - const { runtime, writeConfigFile } = makeMockRuntime(); + const { + runtime, + mocks: { writeConfigFile }, + } = createMockRuntime(); const result = await autoProvisionAccount(account, runtime); @@ -462,7 +467,7 @@ describe("autoProvisionAccount", () => { it("generated wallet key produces a valid Ethereum address", async () => { const account = makeAccount(); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await autoProvisionAccount(account, runtime); @@ -475,7 +480,7 @@ describe("autoProvisionAccount", () => { it("generated encryption key is 32-byte hex", async () => { const account = makeAccount({ walletKey: VALID_WALLET_KEY, publicAddress: VALID_ADDRESS }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await autoProvisionAccount(account, runtime); diff --git a/extensions/xmtp/src/accounts.ts b/extensions/xmtp/src/accounts.ts index 4b3034c24cbf..cfadf41f1934 100644 --- a/extensions/xmtp/src/accounts.ts +++ b/extensions/xmtp/src/accounts.ts @@ -5,10 +5,11 @@ import { type PluginRuntime, type RuntimeLogger, } from "openclaw/plugin-sdk"; -import type { XMTPConfig } from "./config-types.js"; +import type { XMTPConfig } from "./config-schema.js"; import { generateEncryptionKeyHex, generatePrivateKey, + generateXmtpIdentity, walletAddressFromPrivateKey, } from "./lib/identity.js"; @@ -173,14 +174,20 @@ export async function autoProvisionAccount( let dbEncryptionKey = account.dbEncryptionKey; let publicAddress = account.publicAddress; - if (needWalletKey) { + if (needWalletKey && needEncryptionKey) { + const identity = generateXmtpIdentity(); + walletKey = identity.walletKey; + dbEncryptionKey = identity.dbEncryptionKey; + publicAddress = identity.publicAddress; + update.walletKey = walletKey; + update.dbEncryptionKey = dbEncryptionKey; + update.publicAddress = publicAddress; + } else if (needWalletKey) { walletKey = generatePrivateKey(); publicAddress = walletAddressFromPrivateKey(walletKey); update.walletKey = walletKey; update.publicAddress = publicAddress; - } - - if (needEncryptionKey) { + } else if (needEncryptionKey) { dbEncryptionKey = generateEncryptionKeyHex(); update.dbEncryptionKey = dbEncryptionKey; } @@ -192,7 +199,9 @@ export async function autoProvisionAccount( const generated = [needWalletKey && "walletKey", needEncryptionKey && "dbEncryptionKey"] .filter(Boolean) .join(", "); - log?.info(`[${account.accountId}] auto-provisioned XMTP keys: ${generated}`); + const addressSuffix = + publicAddress !== account.publicAddress ? ` (address: ${publicAddress})` : ""; + log?.info(`[${account.accountId}] auto-provisioned XMTP keys: ${generated}${addressSuffix}`); return { ...account, diff --git a/extensions/xmtp/src/actions.ts b/extensions/xmtp/src/actions.ts index 068123431614..dc124511138e 100644 --- a/extensions/xmtp/src/actions.ts +++ b/extensions/xmtp/src/actions.ts @@ -1,6 +1,7 @@ import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "openclaw/plugin-sdk"; import { jsonResult, readReactionParams, readStringParam } from "openclaw/plugin-sdk"; import { listXmtpAccountIds, resolveXmtpAccount, type CoreConfig } from "./accounts.js"; +import { getOrCreateConversation } from "./lib/xmtp-client.js"; import { getAgentOrThrow } from "./outbound.js"; export const xmtpMessageActions: ChannelMessageActionAdapter = { @@ -18,13 +19,7 @@ export const xmtpMessageActions: ChannelMessageActionAdapter = { if (action === "send") { const to = readStringParam(params, "to", { required: true }); const message = readStringParam(params, "message", { required: true, allowEmpty: true }); - let conversation = await agent.client.conversations.getConversationById(to); - if (!conversation && to.startsWith("0x")) { - conversation = await agent.createDmWithAddress(to as `0x${string}`); - } - if (!conversation) { - throw new Error(`Conversation not found: ${to.slice(0, 12)}...`); - } + const conversation = await getOrCreateConversation(agent, to); const messageId = await conversation.sendText(message ?? ""); return jsonResult({ ok: true, to, messageId }); } diff --git a/extensions/xmtp/src/channel.messaging.test.ts b/extensions/xmtp/src/channel.messaging.test.ts index e4ed32c6ca39..2fe3f63b9581 100644 --- a/extensions/xmtp/src/channel.messaging.test.ts +++ b/extensions/xmtp/src/channel.messaging.test.ts @@ -164,52 +164,19 @@ describe("XMTP message flow", () => { }); describe("group policy enforcement", () => { - it("groupPolicy 'open' allows any group", () => { + it.each([ + ["open allows any group", "open", undefined, "any-group", true], + ["disabled blocks all groups", "disabled", undefined, "any-group", false], + ["allowlist allows listed group", "allowlist", ["group-123"], "group-123", true], + ["allowlist blocks unlisted group", "allowlist", ["group-123"], "group-456", false], + ["allowlist wildcard allows all", "allowlist", ["*"], "any-group", true], + ] as const)("%s", (_desc, groupPolicy, groups, conversationId, expected) => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, - groupPolicy: "open", - }); - - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(true); - }); - - it("groupPolicy 'disabled' blocks all groups", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "disabled", + groupPolicy: groupPolicy as any, + groups: groups as any, }); - - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(false); - }); - - it("groupPolicy 'allowlist' allows listed group", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["group-123"], - }); - - expect(isGroupAllowed({ account, conversationId: "group-123" })).toBe(true); - }); - - it("groupPolicy 'allowlist' blocks unlisted group", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["group-123"], - }); - - expect(isGroupAllowed({ account, conversationId: "group-456" })).toBe(false); - }); - - it("groupPolicy 'allowlist' with '*' allows all groups", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["*"], - }); - - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(true); + expect(isGroupAllowed({ account, conversationId })).toBe(expected); }); it("drops message from disabled group conversation", async () => { @@ -263,24 +230,45 @@ describe("XMTP message flow", () => { describe("ENS-aware target resolution", () => { const looksLikeId = xmtpPlugin.messaging!.targetResolver!.looksLikeId!; - it("recognizes Ethereum addresses", () => { - expect(looksLikeId("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")).toBe(true); - }); - - it("recognizes ENS names", () => { - expect(looksLikeId("nick.eth")).toBe(true); - expect(looksLikeId("pay.nick.eth")).toBe(true); - expect(looksLikeId("vitalik.eth")).toBe(true); + it.each([ + ["Ethereum address", "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", true], + ["simple ENS name", "nick.eth", true], + ["subdomain ENS name", "pay.nick.eth", true], + ["well-known ENS name", "vitalik.eth", true], + ["empty string", "", false], + ["whitespace only", " ", false], + ["plain word", "hello", false], + ["hyphenated string", "not-an-address", false], + ] as const)("%s → %s", (_desc, input, expected) => { + expect(looksLikeId(input)).toBe(expected); }); - it("rejects empty strings", () => { - expect(looksLikeId("")).toBe(false); - expect(looksLikeId(" ")).toBe(false); + it.each([ + [ + "32-char conversation ID via normalized", + "xmtp:8f83e95ea30dda840dce97bd9b8b21e4", + "8f83e95ea30dda840dce97bd9b8b21e4", + true, + ], + [ + "64-char conversation topic via normalized", + "xmtp:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + true, + ], + ["short hex (15 chars) rejected", "xmtp:abcdef012345678", "abcdef012345678", false], + [ + "non-hex chars in normalized rejected", + "xmtp:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + false, + ], + ] as const)("conversation ID: %s", (_desc, raw, normalized, expected) => { + expect(looksLikeId(raw, normalized)).toBe(expected); }); - it("rejects non-ENS non-address strings", () => { - expect(looksLikeId("hello")).toBe(false); - expect(looksLikeId("not-an-address")).toBe(false); + it("recognizes bare hex conversation ID without normalized param", () => { + expect(looksLikeId("8f83e95ea30dda840dce97bd9b8b21e4")).toBe(true); }); it("hint mentions ENS name", () => { diff --git a/extensions/xmtp/src/channel.ts b/extensions/xmtp/src/channel.ts index f938bd74f316..a907ca111196 100644 --- a/extensions/xmtp/src/channel.ts +++ b/extensions/xmtp/src/channel.ts @@ -23,14 +23,10 @@ import { } from "./accounts.js"; import { xmtpMessageActions } from "./actions.js"; import { xmtpChannelConfigSchema } from "./config-schema.js"; -import { - evaluateDmAccess, - isGroupAllowed, - normalizeXmtpAddress, - sendPairingReply, -} from "./dm-policy.js"; +import { normalizeXmtpAddress } from "./dm-policy.js"; import { startAccount, stopAccountHandler } from "./gateway-lifecycle.js"; import { runInboundPipeline } from "./inbound-pipeline.js"; +import { enforceInboundAccessControl } from "./lib/access-control.js"; import { isEnsName } from "./lib/ens-resolver.js"; import { createAgentFromAccount } from "./lib/xmtp-client.js"; import { xmtpOnboardingAdapter } from "./onboarding.js"; @@ -59,6 +55,53 @@ function normalizeXmtpMessagingTarget(raw: string): string | undefined { // Re-export for existing test imports export { isGroupAllowed } from "./dm-policy.js"; +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function resolveTableMode(runtime: PluginRuntime, accountId: string) { + return runtime.channel.text.resolveMarkdownTableMode({ + cfg: runtime.config.loadConfig(), + channel: CHANNEL_ID, + accountId, + }); +} + +/** Save attachment buffers via the media API and return paths + filenames. */ +async function saveInboundMedia( + items: Array<{ content: Uint8Array; mimeType: string; filename?: string }>, + runtime: PluginRuntime, + accountId: string, + log?: RuntimeLogger, +): Promise<{ media: Array<{ path: string; contentType?: string }>; filenames: string[] }> { + const media: Array<{ path: string; contentType?: string }> = []; + const filenames: string[] = []; + + for (const item of items) { + try { + const saved = await runtime.channel.media.saveMediaBuffer( + Buffer.from(item.content), + item.mimeType, + "inbound", + undefined, + item.filename, + ); + media.push({ path: saved.path, contentType: saved.contentType }); + filenames.push(item.filename ?? "attachment"); + } catch (err) { + log?.error(`[${accountId}] Failed to save attachment: ${String(err)}`); + } + } + + return { media, filenames }; +} + +function formatAttachmentLabel(filenames: string[]): string { + return filenames.length === 1 + ? `[Attachment: ${filenames[0]}]` + : `[Attachments: ${filenames.join(", ")}]`; +} + // --------------------------------------------------------------------------- // Inbound message handler (thin orchestrator) // --------------------------------------------------------------------------- @@ -84,48 +127,18 @@ export async function handleInboundMessage(params: { ); } - // Group access control - if (!isDirect && !isGroupAllowed({ account, conversationId })) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped message from disallowed conversation ${conversationId.slice(0, 12)}`, - ); - } - return; - } - - // DM access control - if (isDirect) { - const decision = await evaluateDmAccess({ account, sender, runtime }); - if (!decision.allowed) { - if (decision.reason === "pairing" && decision.created && decision.code) { - await sendPairingReply({ - account, - sender, - conversationId, - code: decision.code, - runtime, - log, - }); - } else if (decision.reason === "blocked" && account.debug) { - log?.info( - `[${account.accountId}] Blocked DM from ${sender.slice(0, 12)} (dmPolicy=${decision.dmPolicy})`, - ); - } else if (decision.reason === "disabled" && account.debug) { - log?.info( - `[${account.accountId}] Dropped DM from ${sender.slice(0, 12)} (dmPolicy=disabled)`, - ); - } - return; - } - } - - // Pipeline: route -> envelope -> session -> dispatch - const tableMode = runtime.channel.text.resolveMarkdownTableMode({ - cfg: runtime.config.loadConfig(), - channel: CHANNEL_ID, - accountId: account.accountId, + const allowed = await enforceInboundAccessControl({ + account, + sender, + conversationId, + isDirect, + runtime, + log, + label: "message", }); + if (!allowed) return; + + const tableMode = resolveTableMode(runtime, account.accountId); await runInboundPipeline({ account, @@ -179,37 +192,21 @@ export async function handleInboundReaction(params: { ); } - // Group access control (same as handleInboundMessage) - if (!isDirect && !isGroupAllowed({ account, conversationId })) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped reaction from disallowed conversation ${conversationId.slice(0, 12)}`, - ); - } - return; - } - - // DM access control (same as handleInboundMessage) - if (isDirect) { - const decision = await evaluateDmAccess({ account, sender, runtime }); - if (!decision.allowed) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped reaction from ${sender.slice(0, 12)} (dm access denied)`, - ); - } - return; - } - } + const allowed = await enforceInboundAccessControl({ + account, + sender, + conversationId, + isDirect, + runtime, + log, + label: "reaction", + }); + if (!allowed) return; // Format reaction as descriptive content for the inbound pipeline const content = `[Reaction: ${reaction.content} ${actionLabel} to message ${reaction.reference}]`; - const tableMode = runtime.channel.text.resolveMarkdownTableMode({ - cfg: runtime.config.loadConfig(), - channel: CHANNEL_ID, - accountId: account.accountId, - }); + const tableMode = resolveTableMode(runtime, account.accountId); await runInboundPipeline({ account, @@ -256,66 +253,47 @@ export async function handleInboundAttachment(params: { const { account, sender, conversationId, remoteAttachments, messageId, isDirect, runtime, log } = params; - // Group access control (same as handleInboundMessage) - if (!isDirect && !isGroupAllowed({ account, conversationId })) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped attachment from disallowed conversation ${conversationId.slice(0, 12)}`, - ); - } - return; - } - - // DM access control (same as handleInboundMessage) - if (isDirect) { - const decision = await evaluateDmAccess({ account, sender, runtime }); - if (!decision.allowed) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped attachment from ${sender.slice(0, 12)} (dm access denied)`, - ); - } - return; - } - } + const allowed = await enforceInboundAccessControl({ + account, + sender, + conversationId, + isDirect, + runtime, + log, + label: "attachment", + }); + if (!allowed) return; // Download, decrypt, and save each attachment - const media: Array<{ path: string; contentType?: string }> = []; - const filenames: string[] = []; - + const decryptedItems: Array<{ content: Uint8Array; mimeType: string; filename?: string }> = []; for (const ra of remoteAttachments) { try { const decrypted = await downloadRemoteAttachment(ra); - const saved = await runtime.channel.media.saveMediaBuffer( - Buffer.from(decrypted.content), - decrypted.mimeType, - "inbound", - undefined, - decrypted.filename, - ); - media.push({ path: saved.path, contentType: saved.contentType }); - filenames.push(decrypted.filename ?? ra.filename ?? "attachment"); + decryptedItems.push({ + content: decrypted.content, + mimeType: decrypted.mimeType, + filename: decrypted.filename ?? ra.filename, + }); } catch (err) { log?.error(`[${account.accountId}] Failed to download remote attachment: ${String(err)}`); } } + const { media, filenames } = await saveInboundMedia( + decryptedItems, + runtime, + account.accountId, + log, + ); if (media.length === 0) return; - const content = - filenames.length === 1 - ? `[Attachment: ${filenames[0]}]` - : `[Attachments: ${filenames.join(", ")}]`; + const content = formatAttachmentLabel(filenames); if (account.debug) { log?.info(`[${account.accountId}] Inbound attachment from ${sender.slice(0, 12)}: ${content}`); } - const tableMode = runtime.channel.text.resolveMarkdownTableMode({ - cfg: runtime.config.loadConfig(), - channel: CHANNEL_ID, - accountId: account.accountId, - }); + const tableMode = resolveTableMode(runtime, account.accountId); await runInboundPipeline({ account, @@ -363,55 +341,31 @@ export async function handleInboundInlineAttachment(params: { const { account, sender, conversationId, attachments, messageId, isDirect, runtime, log } = params; - // Group access control - if (!isDirect && !isGroupAllowed({ account, conversationId })) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped inline attachment from disallowed conversation ${conversationId.slice(0, 12)}`, - ); - } - return; - } - - // DM access control - if (isDirect) { - const decision = await evaluateDmAccess({ account, sender, runtime }); - if (!decision.allowed) { - if (account.debug) { - log?.info( - `[${account.accountId}] Dropped inline attachment from ${sender.slice(0, 12)} (dm access denied)`, - ); - } - return; - } - } + const allowed = await enforceInboundAccessControl({ + account, + sender, + conversationId, + isDirect, + runtime, + log, + label: "inline attachment", + }); + if (!allowed) return; // Save each inline attachment directly (no download needed) - const media: Array<{ path: string; contentType?: string }> = []; - const filenames: string[] = []; - - for (const att of attachments) { - try { - const saved = await runtime.channel.media.saveMediaBuffer( - Buffer.from(att.content), - att.mimeType, - "inbound", - undefined, - att.filename, - ); - media.push({ path: saved.path, contentType: saved.contentType }); - filenames.push(att.filename ?? "attachment"); - } catch (err) { - log?.error(`[${account.accountId}] Failed to save inline attachment: ${String(err)}`); - } - } - + const { media, filenames } = await saveInboundMedia( + attachments.map((att) => ({ + content: att.content, + mimeType: att.mimeType, + filename: att.filename, + })), + runtime, + account.accountId, + log, + ); if (media.length === 0) return; - const content = - filenames.length === 1 - ? `[Attachment: ${filenames[0]}]` - : `[Attachments: ${filenames.join(", ")}]`; + const content = formatAttachmentLabel(filenames); if (account.debug) { log?.info( @@ -419,11 +373,7 @@ export async function handleInboundInlineAttachment(params: { ); } - const tableMode = runtime.channel.text.resolveMarkdownTableMode({ - cfg: runtime.config.loadConfig(), - channel: CHANNEL_ID, - accountId: account.accountId, - }); + const tableMode = resolveTableMode(runtime, account.accountId); await runInboundPipeline({ account, @@ -537,7 +487,9 @@ export const xmtpPlugin: ChannelPlugin = { "ownerAddress", ], }), - isConfigured: (account) => account.configured, + // Always return true for isConfigued so that the auto-provisioning has a chance to run + // Otherwise there is a chicken-and-egg problem that prevents the account from being configured. + isConfigured: (_account) => true, describeAccount: (account) => ({ accountId: account.accountId, name: account.name, @@ -564,12 +516,15 @@ export const xmtpPlugin: ChannelPlugin = { messaging: { normalizeTarget: normalizeXmtpMessagingTarget, targetResolver: { - looksLikeId: (raw) => { + looksLikeId: (raw, normalized) => { const t = raw.trim(); if (!t) return false; // Ethereum address: exactly 42 hex chars (0x + 40) if (t.length === 42 && /^0x[0-9a-fA-F]{40}$/.test(t)) return true; if (isEnsName(t)) return true; + // Conversation topic/ID: hex string (uses normalized to handle xmtp: prefix) + const n = (normalized ?? t).trim(); + if (/^[0-9a-fA-F]{16,}$/.test(n)) return true; return false; }, hint: "", @@ -581,7 +536,7 @@ export const xmtpPlugin: ChannelPlugin = { const hints = [ "- XMTP targets are wallet addresses, ENS names, or conversation topics. Use `to=
` for `action=send`.", "- When ENS names are available (in SenderName, GroupMembers, or [ENS Context] blocks), always refer to users by their ENS name (e.g., nick.eth) rather than raw Ethereum addresses.", - "- Use `action=react` with `to=`, `messageId=`, and `emoji=` to react to messages.", + "- To react to a message, use `action=react` with `emoji=` and `messageId` set to the `[message_id:...]` tag from the message. The `to` parameter is auto-filled from context.", ]; try { const account = resolveXmtpAccount({ cfg: cfg as CoreConfig, accountId }); diff --git a/extensions/xmtp/src/config-schema.ts b/extensions/xmtp/src/config-schema.ts index 92159393989b..4fe448b8930b 100644 --- a/extensions/xmtp/src/config-schema.ts +++ b/extensions/xmtp/src/config-schema.ts @@ -69,6 +69,14 @@ export const XMTPConfigSchema = z.object({ export type XMTPConfigInput = z.infer; +/** Derived union types (replaces config-types.ts) */ +export type DmPolicy = NonNullable; +export type GroupPolicy = NonNullable; +export type XMTPAccountConfig = XMTPConfigInput; +export type XMTPConfig = XMTPConfigInput & { + accounts?: Record; +}; + /** * JSON Schema for Control UI (converted from Zod) */ diff --git a/extensions/xmtp/src/config-types.ts b/extensions/xmtp/src/config-types.ts deleted file mode 100644 index dd4d775baf61..000000000000 --- a/extensions/xmtp/src/config-types.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * 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; - /** Pinata API key for IPFS upload of media attachments. */ - pinataApiKey?: string; - /** Pinata secret key for IPFS upload of media attachments. */ - pinataSecretKey?: string; - /** Custom IPFS gateway URL (default: https://gateway.pinata.cloud/ipfs/). */ - ipfsGatewayUrl?: string; - /** Ethereum address of the owner. Auto-allowed for DMs; a conversation is created on startup. */ - ownerAddress?: string; - /** web3.bio API key for ENS resolution (optional, improves rate limits). */ - web3BioApiKey?: string; -}; - -export type XMTPConfig = { - /** Per-account XMTP configuration (multi-account). */ - accounts?: Record; -} & XMTPAccountConfig; diff --git a/extensions/xmtp/src/dm-policy.test.ts b/extensions/xmtp/src/dm-policy.test.ts index 279273e44cb0..4b500be175b6 100644 --- a/extensions/xmtp/src/dm-policy.test.ts +++ b/extensions/xmtp/src/dm-policy.test.ts @@ -13,6 +13,7 @@ import { import { setResolverForAccount, createEnsResolver } from "./lib/ens-resolver.js"; import { setClientForAccount } from "./outbound.js"; import { + createMockRuntime, createTestAccount, makeFakeAgent, TEST_OWNER_ADDRESS, @@ -24,30 +25,19 @@ import { // --------------------------------------------------------------------------- describe("normalizeXmtpAddress", () => { - it("strips xmtp: prefix", () => { - expect(normalizeXmtpAddress("xmtp:0xABC")).toBe("0xABC"); - }); - - it("strips xmtp: prefix case-insensitively", () => { - expect(normalizeXmtpAddress("XMTP:0xABC")).toBe("0xABC"); - }); - - it("trims whitespace", () => { - expect(normalizeXmtpAddress(" 0xABC ")).toBe("0xABC"); - }); - - it("handles combined prefix and whitespace", () => { - expect(normalizeXmtpAddress(" xmtp: 0xABC ")).toBe("0xABC"); - }); - - it("returns empty string for empty input", () => { - expect(normalizeXmtpAddress("")).toBe(""); - }); - - it("passes through normal addresses unchanged", () => { - expect(normalizeXmtpAddress("0xAbCdEf1234567890abcdef1234567890AbCdEf12")).toBe( + it.each([ + ["strips xmtp: prefix", "xmtp:0xABC", "0xABC"], + ["strips prefix case-insensitively", "XMTP:0xABC", "0xABC"], + ["trims whitespace", " 0xABC ", "0xABC"], + ["handles combined prefix and whitespace", " xmtp: 0xABC ", "0xABC"], + ["returns empty for empty input", "", ""], + [ + "passes through normal addresses", "0xAbCdEf1234567890abcdef1234567890AbCdEf12", - ); + "0xAbCdEf1234567890abcdef1234567890AbCdEf12", + ], + ])("%s", (_desc, input, expected) => { + expect(normalizeXmtpAddress(input)).toBe(expected); }); }); @@ -56,46 +46,23 @@ describe("normalizeXmtpAddress", () => { // --------------------------------------------------------------------------- describe("isGroupAllowed", () => { - it("open policy allows any group", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, groupPolicy: "open" }); - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(true); - }); - - it("disabled policy blocks all groups", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, groupPolicy: "disabled" }); - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(false); - }); - - it("allowlist policy allows listed group", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["group-123"], - }); - expect(isGroupAllowed({ account, conversationId: "group-123" })).toBe(true); - }); - - it("allowlist policy blocks unlisted group", () => { + it.each([ + ["open allows any group", "open", undefined, "any-group", true], + ["disabled blocks all groups", "disabled", undefined, "any-group", false], + ["allowlist allows listed group", "allowlist", ["group-123"], "group-123", true], + ["allowlist blocks unlisted group", "allowlist", ["group-123"], "group-456", false], + ["allowlist wildcard allows all", "allowlist", ["*"], "any-group", true], + ] as const)("%s", (_desc, groupPolicy, groups, conversationId, expected) => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["group-123"], + groupPolicy: groupPolicy as any, + groups: groups as any, }); - expect(isGroupAllowed({ account, conversationId: "group-456" })).toBe(false); - }); - - it("allowlist with wildcard allows all groups", () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - groupPolicy: "allowlist", - groups: ["*"], - }); - expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(true); + expect(isGroupAllowed({ account, conversationId })).toBe(expected); }); it("defaults to open when groupPolicy not set", () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS }); - // groupPolicy defaults to "open" in createTestAccountConfig account.config.groupPolicy = undefined; expect(isGroupAllowed({ account, conversationId: "any-group" })).toBe(true); }); @@ -105,32 +72,11 @@ describe("isGroupAllowed", () => { // evaluateDmAccess // --------------------------------------------------------------------------- -function makeMockRuntime(overrides?: { - storeAllowFrom?: string[]; - pairingResult?: { code: string; created: boolean }; -}) { - const readAllowFromStore = vi.fn(async () => overrides?.storeAllowFrom ?? []); - const upsertPairingRequest = vi.fn( - async () => overrides?.pairingResult ?? { code: "TESTCODE", created: true }, - ); - return { - runtime: { - channel: { - pairing: { - readAllowFromStore, - upsertPairingRequest, - }, - }, - } as any, - mocks: { readAllowFromStore, upsertPairingRequest }, - }; -} - describe("evaluateDmAccess", () => { describe("dmPolicy: open", () => { it("allows any sender", async () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -141,7 +87,7 @@ describe("evaluateDmAccess", () => { describe("dmPolicy: disabled", () => { it("blocks all senders", async () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "disabled" }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -156,7 +102,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: [TEST_SENDER_ADDRESS], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -169,7 +115,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: [], }); - const { runtime } = makeMockRuntime({ storeAllowFrom: [TEST_SENDER_ADDRESS] }); + const { runtime } = createMockRuntime({ storeAllowFrom: [TEST_SENDER_ADDRESS] }); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -182,7 +128,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: ["0xOther"], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -195,7 +141,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: ["*"], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -208,7 +154,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: [TEST_SENDER_ADDRESS.toUpperCase()], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, @@ -225,7 +171,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: [`xmtp:${TEST_SENDER_ADDRESS}`], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -241,7 +187,7 @@ describe("evaluateDmAccess", () => { allowFrom: [], ownerAddress: TEST_SENDER_ADDRESS, }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -255,7 +201,7 @@ describe("evaluateDmAccess", () => { allowFrom: [], ownerAddress: TEST_SENDER_ADDRESS, }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -268,7 +214,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "disabled", ownerAddress: TEST_SENDER_ADDRESS, }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -282,7 +228,7 @@ describe("evaluateDmAccess", () => { allowFrom: [], ownerAddress: TEST_SENDER_ADDRESS.toUpperCase(), }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, @@ -300,7 +246,7 @@ describe("evaluateDmAccess", () => { allowFrom: [], ownerAddress: `xmtp:${TEST_SENDER_ADDRESS}`, }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -313,7 +259,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "pairing", allowFrom: [], }); - const { runtime, mocks } = makeMockRuntime(); + const { runtime, mocks } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -334,7 +280,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "pairing", allowFrom: [], }); - const { runtime, mocks } = makeMockRuntime(); + const { runtime, mocks } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -355,7 +301,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "pairing", allowFrom: [], }); - const { runtime } = makeMockRuntime({ + const { runtime } = createMockRuntime({ pairingResult: { code: "TESTCODE", created: false }, }); @@ -375,7 +321,7 @@ describe("evaluateDmAccess", () => { dmPolicy: "pairing", allowFrom: [], }); - const { runtime } = makeMockRuntime({ storeAllowFrom: [TEST_SENDER_ADDRESS] }); + const { runtime } = createMockRuntime({ storeAllowFrom: [TEST_SENDER_ADDRESS] }); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -388,7 +334,7 @@ describe("evaluateDmAccess", () => { allowFrom: [], }); account.config.dmPolicy = undefined; - const { runtime, mocks } = makeMockRuntime(); + const { runtime, mocks } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); @@ -437,7 +383,7 @@ describe("ENS resolution in evaluateDmAccess", () => { dmPolicy: "pairing", ownerAddress: "owner.eth", }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); expect(result).toEqual({ allowed: true }); @@ -452,7 +398,7 @@ describe("ENS resolution in evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: ["friend.eth"], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); expect(result).toEqual({ allowed: true }); @@ -467,7 +413,7 @@ describe("ENS resolution in evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: ["other.eth"], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); expect(result).toEqual({ allowed: false, reason: "blocked", dmPolicy: "allowlist" }); @@ -480,7 +426,7 @@ describe("ENS resolution in evaluateDmAccess", () => { dmPolicy: "allowlist", allowFrom: ["friend.eth"], }); - const { runtime } = makeMockRuntime(); + const { runtime } = createMockRuntime(); const result = await evaluateDmAccess({ account, sender: TEST_SENDER_ADDRESS, runtime }); expect(result).toEqual({ allowed: false, reason: "blocked", dmPolicy: "allowlist" }); diff --git a/extensions/xmtp/src/dm-policy.ts b/extensions/xmtp/src/dm-policy.ts index f537abe587e6..2c45a61d4c36 100644 --- a/extensions/xmtp/src/dm-policy.ts +++ b/extensions/xmtp/src/dm-policy.ts @@ -5,7 +5,7 @@ import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; import type { ResolvedXmtpAccount } from "./accounts.js"; -import { getResolverForAccount, isEnsName } from "./lib/ens-resolver.js"; +import { getResolverForAccount, isEnsName, resolveOwnerAddress } from "./lib/ens-resolver.js"; import { getClientForAccount } from "./outbound.js"; const CHANNEL_ID = "xmtp"; @@ -68,12 +68,11 @@ export async function evaluateDmAccess(params: { // Owner is always allowed (unless DMs are fully disabled) if (account.ownerAddress) { - let ownerAddr = normalizeXmtpAddress(account.ownerAddress); - if (isEnsName(ownerAddr)) { - const resolver = getResolverForAccount(account.accountId); - const resolved = resolver ? await resolver.resolveEnsName(ownerAddr) : null; - if (resolved) ownerAddr = resolved; - } + const resolver = getResolverForAccount(account.accountId); + const ownerAddr = await resolveOwnerAddress( + normalizeXmtpAddress(account.ownerAddress), + resolver, + ); if (ownerAddr && normalizedSender.toLowerCase() === ownerAddr.toLowerCase()) { return { allowed: true }; } diff --git a/extensions/xmtp/src/gateway-lifecycle.test.ts b/extensions/xmtp/src/gateway-lifecycle.test.ts index feb3df9fa4a0..98ca10206a7a 100644 --- a/extensions/xmtp/src/gateway-lifecycle.test.ts +++ b/extensions/xmtp/src/gateway-lifecycle.test.ts @@ -23,9 +23,10 @@ import { import { setClientForAccount, getClientForAccount } from "./outbound.js"; import { setXmtpRuntime } from "./runtime.js"; import { + createMockEnsResolver, + createMockRuntime, createTestAccount, makeFakeAgent, - createMockRuntime, TEST_OWNER_ADDRESS, TEST_SENDER_ADDRESS, } from "./test-utils/unit-helpers.js"; @@ -135,10 +136,46 @@ describe("backfillPublicAddress", () => { }); // --------------------------------------------------------------------------- -// buildTextHandler +// Shared handler guard-clause tests (all 5 handler builders) // --------------------------------------------------------------------------- -describe("buildTextHandler", () => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const handlerCases: Array<{ + name: string; + build: (p: { account: any; runtime: any; log?: any }) => (ctx: any) => Promise; + validMsg: Record; +}> = [ + { + name: "buildTextHandler", + build: buildTextHandler, + validMsg: { content: "hello", id: "msg-1" }, + }, + { + name: "buildReactionHandler", + build: buildReactionHandler, + validMsg: { content: { content: "\u2764\uFE0F", action: 1, reference: "msg-1" }, id: "r-1" }, + }, + { + name: "buildAttachmentHandler", + build: buildAttachmentHandler, + validMsg: { content: { url: "https://example.com/file" }, id: "att-1" }, + }, + { + name: "buildInlineAttachmentHandler", + build: buildInlineAttachmentHandler, + validMsg: { + content: { filename: "test.png", mimeType: "image/png", content: new Uint8Array([1]) }, + id: "att-1", + }, + }, + { + name: "buildMultiAttachmentHandler", + build: buildMultiAttachmentHandler, + validMsg: { content: { attachments: [{ url: "https://example.com/file" }] }, id: "multi-1" }, + }, +]; + +describe.each(handlerCases)("$name", ({ build, validMsg }) => { beforeEach(() => { setClientForAccount("default", null); setXmtpRuntime({ @@ -151,10 +188,7 @@ describe("buildTextHandler", () => { it("returns a function", () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime } = createMockRuntime(); - - const handler = buildTextHandler({ account, runtime }); - - expect(typeof handler).toBe("function"); + expect(typeof build({ account, runtime })).toBe("function"); }); it("skips denied contacts", async () => { @@ -166,10 +200,10 @@ describe("buildTextHandler", () => { const { runtime, mocks } = createMockRuntime(); const log = { info: vi.fn(), error: vi.fn() }; - const handler = buildTextHandler({ account, runtime, log: log as any }); + const handler = build({ account, runtime, log: log as any }); await handler({ isDenied: true, - message: { content: "hello", id: "msg-1" }, + message: validMsg, conversation: { id: "convo-1" }, isDm: () => true, getSenderAddress: async () => "0xSender", @@ -179,14 +213,14 @@ describe("buildTextHandler", () => { expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); - it("skips messages with non-string content", async () => { + it("skips messages with null content", async () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime, mocks } = createMockRuntime(); - const handler = buildTextHandler({ account, runtime }); + const handler = build({ account, runtime }); await handler({ isDenied: false, - message: { content: undefined, id: "msg-1" }, + message: { content: undefined, id: "null-1" }, conversation: { id: "convo-1" }, isDm: () => true, getSenderAddress: async () => "0xSender", @@ -199,250 +233,10 @@ describe("buildTextHandler", () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime, mocks } = createMockRuntime(); - const handler = buildTextHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: "hello", id: "msg-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => undefined, - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); -}); - -// --------------------------------------------------------------------------- -// buildReactionHandler -// --------------------------------------------------------------------------- - -describe("buildReactionHandler", () => { - beforeEach(() => { - setClientForAccount("default", null); - setXmtpRuntime({ - channel: { - text: { chunkMarkdownText: (text: string) => [text] }, - }, - } as unknown as PluginRuntime); - }); - - it("returns a function", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime } = createMockRuntime(); - - const handler = buildReactionHandler({ account, runtime }); - - expect(typeof handler).toBe("function"); - }); - - it("skips denied contacts", async () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - dmPolicy: "open", - debug: true, - }); - const { runtime, mocks } = createMockRuntime(); - const log = { info: vi.fn(), error: vi.fn() }; - - const handler = buildReactionHandler({ account, runtime, log: log as any }); - await handler({ - isDenied: true, - message: { content: { content: "\u2764\uFE0F", action: 1, reference: "msg-1" }, id: "r-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("denied contact")); - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips reactions with null content", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildReactionHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: undefined, id: "r-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips reactions when sender address is empty", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildReactionHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: { content: "\u2764\uFE0F", action: 1, reference: "msg-1" }, id: "r-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => undefined, - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); -}); - -// --------------------------------------------------------------------------- -// buildAttachmentHandler -// --------------------------------------------------------------------------- - -describe("buildAttachmentHandler", () => { - beforeEach(() => { - setClientForAccount("default", null); - setXmtpRuntime({ - channel: { - text: { chunkMarkdownText: (text: string) => [text] }, - }, - } as unknown as PluginRuntime); - }); - - it("returns a function", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime } = createMockRuntime(); - - const handler = buildAttachmentHandler({ account, runtime }); - - expect(typeof handler).toBe("function"); - }); - - it("skips denied contacts", async () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - dmPolicy: "open", - debug: true, - }); - const { runtime, mocks } = createMockRuntime(); - const log = { info: vi.fn(), error: vi.fn() }; - - const handler = buildAttachmentHandler({ account, runtime, log: log as any }); - await handler({ - isDenied: true, - message: { content: { url: "https://example.com/file" }, id: "att-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("denied contact")); - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips attachments with null content", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildAttachmentHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: undefined, id: "att-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips attachments when sender address is empty", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildAttachmentHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: { url: "https://example.com/file" }, id: "att-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => undefined, - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); -}); - -// --------------------------------------------------------------------------- -// buildInlineAttachmentHandler -// --------------------------------------------------------------------------- - -describe("buildInlineAttachmentHandler", () => { - beforeEach(() => { - setClientForAccount("default", null); - setXmtpRuntime({ - channel: { - text: { chunkMarkdownText: (text: string) => [text] }, - }, - } as unknown as PluginRuntime); - }); - - it("returns a function", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime } = createMockRuntime(); - - const handler = buildInlineAttachmentHandler({ account, runtime }); - - expect(typeof handler).toBe("function"); - }); - - it("skips denied contacts", async () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - dmPolicy: "open", - debug: true, - }); - const { runtime, mocks } = createMockRuntime(); - const log = { info: vi.fn(), error: vi.fn() }; - - const handler = buildInlineAttachmentHandler({ account, runtime, log: log as any }); - await handler({ - isDenied: true, - message: { - content: { filename: "test.png", mimeType: "image/png", content: new Uint8Array([1]) }, - id: "att-1", - }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("denied contact")); - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips inline attachments with null content", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildInlineAttachmentHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: undefined, id: "att-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips inline attachments when sender address is empty", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildInlineAttachmentHandler({ account, runtime }); + const handler = build({ account, runtime }); await handler({ isDenied: false, - message: { - content: { filename: "test.png", mimeType: "image/png", content: new Uint8Array([1]) }, - id: "att-1", - }, + message: validMsg, conversation: { id: "convo-1" }, isDm: () => true, getSenderAddress: async () => undefined, @@ -452,11 +246,8 @@ describe("buildInlineAttachmentHandler", () => { }); }); -// --------------------------------------------------------------------------- -// buildMultiAttachmentHandler -// --------------------------------------------------------------------------- - -describe("buildMultiAttachmentHandler", () => { +// buildMultiAttachmentHandler has one additional edge case +describe("buildMultiAttachmentHandler (empty array)", () => { beforeEach(() => { setClientForAccount("default", null); setXmtpRuntime({ @@ -466,56 +257,6 @@ describe("buildMultiAttachmentHandler", () => { } as unknown as PluginRuntime); }); - it("returns a function", () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime } = createMockRuntime(); - - const handler = buildMultiAttachmentHandler({ account, runtime }); - - expect(typeof handler).toBe("function"); - }); - - it("skips denied contacts", async () => { - const account = createTestAccount({ - address: TEST_OWNER_ADDRESS, - dmPolicy: "open", - debug: true, - }); - const { runtime, mocks } = createMockRuntime(); - const log = { info: vi.fn(), error: vi.fn() }; - - const handler = buildMultiAttachmentHandler({ account, runtime, log: log as any }); - await handler({ - isDenied: true, - message: { - content: { attachments: [{ url: "https://example.com/file" }] }, - id: "multi-1", - }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(log.info).toHaveBeenCalledWith(expect.stringContaining("denied contact")); - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("skips multi-attachments with null content", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildMultiAttachmentHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { content: undefined, id: "multi-1" }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => "0xSender", - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - it("skips multi-attachments with empty attachments array", async () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime, mocks } = createMockRuntime(); @@ -531,25 +272,6 @@ describe("buildMultiAttachmentHandler", () => { expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); - - it("skips multi-attachments when sender address is empty", async () => { - const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); - const { runtime, mocks } = createMockRuntime(); - - const handler = buildMultiAttachmentHandler({ account, runtime }); - await handler({ - isDenied: false, - message: { - content: { attachments: [{ url: "https://example.com/file" }] }, - id: "multi-1", - }, - conversation: { id: "convo-1" }, - isDm: () => true, - getSenderAddress: async () => undefined, - } as any); - - expect(mocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); }); // --------------------------------------------------------------------------- @@ -627,12 +349,7 @@ describe("owner DM creation with ENS resolution", () => { }); const resolvedAddr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; - // Create a mock ENS resolver - const ensResolver = { - resolveEnsName: vi.fn(async () => resolvedAddr), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const ensResolver = createMockEnsResolver({ resolveEnsName: resolvedAddr }); // Simulate the ENS-aware owner DM creation logic from startAccount if (account.ownerAddress) { @@ -672,12 +389,7 @@ describe("owner DM creation with ENS resolution", () => { ownerAddress: "nonexistent.eth", }); - // Create a mock ENS resolver that returns null (resolution failure) - const ensResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const ensResolver = createMockEnsResolver(); // Simulate the ENS-aware owner DM creation logic from startAccount if (account.ownerAddress) { @@ -717,12 +429,7 @@ describe("owner DM creation with ENS resolution", () => { ownerAddress: regularAddr, }); - // Create a mock ENS resolver (should not be called) - const ensResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const ensResolver = createMockEnsResolver(); // Simulate the ENS-aware owner DM creation logic from startAccount if (account.ownerAddress) { @@ -775,11 +482,7 @@ describe("resolveInboundEns", () => { }); it("resolves sender address to ENS name", async () => { - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => "vitalik.eth"), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver({ resolveAddress: "vitalik.eth" }); setResolverForAccount("default", mockResolver); const result = await resolveInboundEns({ @@ -794,11 +497,7 @@ describe("resolveInboundEns", () => { }); it("does not set senderName when resolveAddress returns null", async () => { - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver(); setResolverForAccount("default", mockResolver); const result = await resolveInboundEns({ @@ -815,11 +514,7 @@ describe("resolveInboundEns", () => { const resolved = new Map([ ["nick.eth", "0xb8c2C29ee19D8307cb7255e1Cd9CbDE883A267d5"], ]); - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => resolved), - }; + const mockResolver = createMockEnsResolver({ resolveAll: resolved }); setResolverForAccount("default", mockResolver); const result = await resolveInboundEns({ @@ -836,11 +531,7 @@ describe("resolveInboundEns", () => { it("resolves Ethereum addresses mentioned in message content", async () => { const addr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; const resolved = new Map([[addr, "vitalik.eth"]]); - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => resolved), - }; + const mockResolver = createMockEnsResolver({ resolveAll: resolved }); setResolverForAccount("default", mockResolver); const result = await resolveInboundEns({ @@ -855,11 +546,7 @@ describe("resolveInboundEns", () => { }); it("does not set ensContext when no identifiers found in content", async () => { - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver(); setResolverForAccount("default", mockResolver); const result = await resolveInboundEns({ @@ -877,11 +564,7 @@ describe("resolveInboundEns", () => { it("resolves group members for non-DM conversations", async () => { const memberAddr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; const resolved = new Map([[memberAddr, "vitalik.eth"]]); - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => resolved), - }; + const mockResolver = createMockEnsResolver({ resolveAll: resolved }); setResolverForAccount("default", mockResolver); const conversation = { @@ -905,11 +588,7 @@ describe("resolveInboundEns", () => { }); it("skips group member resolution for DM conversations", async () => { - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver(); setResolverForAccount("default", mockResolver); const conversation = { @@ -928,11 +607,7 @@ describe("resolveInboundEns", () => { }); it("handles group member resolution failure gracefully", async () => { - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver(); setResolverForAccount("default", mockResolver); const conversation = { @@ -978,11 +653,7 @@ describe("buildTextHandler ENS integration", () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime, mocks } = createMockRuntime(); - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => "sender.eth"), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver({ resolveAddress: "sender.eth" }); setResolverForAccount("default", mockResolver); const handler = buildTextHandler({ account, runtime }); @@ -1043,11 +714,7 @@ describe("buildReactionHandler ENS integration", () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); const { runtime, mocks } = createMockRuntime(); - const mockResolver = { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => "reactor.eth"), - resolveAll: vi.fn(async () => new Map()), - }; + const mockResolver = createMockEnsResolver({ resolveAddress: "reactor.eth" }); setResolverForAccount("default", mockResolver); const handler = buildReactionHandler({ account, runtime }); diff --git a/extensions/xmtp/src/gateway-lifecycle.ts b/extensions/xmtp/src/gateway-lifecycle.ts index 2b22eaa27c10..048ef211556a 100644 --- a/extensions/xmtp/src/gateway-lifecycle.ts +++ b/extensions/xmtp/src/gateway-lifecycle.ts @@ -31,6 +31,7 @@ import { extractEthAddresses, formatEnsContext, formatGroupMembersWithEns, + resolveOwnerAddress, } from "./lib/ens-resolver.js"; import { createAgentFromAccount } from "./lib/xmtp-client.js"; import { getClientForAccount, setClientForAccount } from "./outbound.js"; @@ -46,6 +47,7 @@ export async function stopAgent(accountId: string, log?: RuntimeLogger): Promise if (agent) { try { await agent.stop(); + log?.info(`[${accountId}] XMTP provider stopped`); } catch (err) { log?.error(`[${accountId}] Error stopping agent: ${String(err)}`); } @@ -109,19 +111,13 @@ export async function startAccount(ctx: { // Proactively open DM with owner so the channel is ready if (account.ownerAddress) { try { - let ownerAddr = account.ownerAddress; - if (isEnsName(ownerAddr)) { - const resolved = await ensResolver.resolveEnsName(ownerAddr); - if (resolved) { - ownerAddr = resolved; - log?.info( - `[${account.accountId}] Resolved owner ENS ${account.ownerAddress} → ${ownerAddr.slice(0, 12)}...`, - ); - } else { - log?.warn?.( - `[${account.accountId}] Could not resolve owner ENS: ${account.ownerAddress}`, - ); - } + const ownerAddr = await resolveOwnerAddress(account.ownerAddress, ensResolver); + if (ownerAddr !== account.ownerAddress) { + log?.info( + `[${account.accountId}] Resolved owner ENS ${account.ownerAddress} → ${ownerAddr.slice(0, 12)}...`, + ); + } else if (isEnsName(account.ownerAddress)) { + log?.warn?.(`[${account.accountId}] Could not resolve owner ENS: ${account.ownerAddress}`); } if (/^0x[0-9a-fA-F]{40}$/.test(ownerAddr)) { await agent.createDmWithAddress(ownerAddr as `0x${string}`); @@ -134,6 +130,7 @@ export async function startAccount(ctx: { await new Promise((resolve) => { const onAbort = () => { + log?.info(`[${account.accountId}] XMTP provider disconnecting (abort signal)`); void stopAgent(account.accountId, log).finally(resolve); }; if (abortSignal?.aborted) { @@ -230,32 +227,52 @@ export async function resolveInboundEns(params: { return result; } -/** Build the reaction event handler for inbound reactions. */ -export function buildReactionHandler(params: { +// --------------------------------------------------------------------------- +// Generic inbound handler factory +// --------------------------------------------------------------------------- + +type HandlerBaseParams = { account: ResolvedXmtpAccount; runtime: PluginRuntime; log?: RuntimeLogger; -}): (msgCtx: MessageContext) => Promise { - const { account, runtime, log } = params; +}; - return async (msgCtx: MessageContext) => { +/** + * Create an inbound event handler with shared boilerplate: + * denied-check → content extraction → getSenderAddress → resolveInboundEns → dispatch. + */ +function createInboundHandler( + base: HandlerBaseParams, + config: { + label: string; + /** Extract and validate content from the message context. Return null to skip. */ + extractContent: (msgCtx: MessageContext) => T | null | undefined; + /** Return text to use for ENS resolution (empty string for non-text content). */ + ensText: (content: T) => string; + /** Dispatch the validated content to the appropriate channel handler. */ + dispatch: (ctx: { + content: T; + sender: string; + conversationId: string; + messageId: string | undefined; + isDirect: boolean; + ens: { senderName?: string; groupMembers?: string; ensContext?: string }; + }) => Promise; + }, +): (msgCtx: MessageContext) => Promise { + const { account, log } = base; + const { label, extractContent, ensText, dispatch } = config; + + return async (msgCtx: MessageContext) => { if (msgCtx.isDenied) { if (account.debug) { - log?.info(`[${account.accountId}] Skipped reaction from denied contact`); + log?.info(`[${account.accountId}] Skipped ${label} from denied contact`); } return; } - const reaction = msgCtx.message?.content; - if (!reaction) return; - - log?.info( - `[${account.accountId}] reaction event: ${JSON.stringify({ - content: reaction.content, - action: reaction.action, - reference: reaction.reference, - })}`, - ); + const content = extractContent(msgCtx); + if (content == null) return; const sender = await msgCtx.getSenderAddress(); if (!sender) return; @@ -263,235 +280,171 @@ export function buildReactionHandler(params: { const conversationId = msgCtx.conversation?.id as string; const isDirect = msgCtx.isDm(); - const reactionContent = `[Reaction: ${reaction.content}]`; const ens = await resolveInboundEns({ accountId: account.accountId, sender, - content: reactionContent, + content: ensText(content), isDirect, conversation: msgCtx.conversation as any, log, }); - handleInboundReaction({ - account, - sender, - conversationId, - reaction, - messageId: msgCtx.message.id, - isDirect, - runtime, - log, - ...ens, - }).catch((err) => { - log?.error(`[${account.accountId}] Reaction handling failed: ${String(err)}`); - }); + try { + await dispatch({ + content, + sender, + conversationId, + messageId: msgCtx.message.id, + isDirect, + ens, + }); + } catch (err) { + log?.error(`[${account.accountId}] ${label} handling failed: ${String(err)}`); + } }; } +// --------------------------------------------------------------------------- +// Concrete handler builders (thin wrappers over createInboundHandler) +// --------------------------------------------------------------------------- + /** Build the text/markdown event handler for inbound messages. */ -export function buildTextHandler(params: { - account: ResolvedXmtpAccount; - runtime: PluginRuntime; - log?: RuntimeLogger; -}): (msgCtx: MessageContext) => Promise { +export function buildTextHandler(params: HandlerBaseParams) { const { account, runtime, log } = params; + return createInboundHandler(params, { + label: "message", + extractContent: (msgCtx) => { + const content = msgCtx.message?.content; + if (typeof content !== "string") return null; + log?.info( + `[${account.accountId}] text event: ${JSON.stringify({ content: content.slice(0, 50), id: msgCtx.message?.id })}`, + ); + return content; + }, + ensText: (content) => content, + dispatch: async ({ content, sender, conversationId, messageId, isDirect, ens }) => { + log?.info( + `[${account.accountId}] dispatching text event: ${JSON.stringify({ content: content.slice(0, 50), id: messageId })}`, + ); + await handleInboundMessage({ + account, + sender, + conversationId, + content, + messageId, + isDirect, + runtime, + log, + ...ens, + }); + }, + }); +} - return async (msgCtx: MessageContext) => { - // Skip messages from denied contacts - if (msgCtx.isDenied) { - if (account.debug) { - log?.info(`[${account.accountId}] Skipped message from denied contact`); - } - return; - } - - const content = msgCtx.message?.content; - if (typeof content !== "string") return; - - log?.info( - `[${account.accountId}] text event: ${JSON.stringify({ content: content.slice(0, 50), id: msgCtx.message?.id })}`, - ); - const sender = await msgCtx.getSenderAddress(); - if (!sender) return; - const conversation = msgCtx.conversation; - const conversationId = conversation?.id as string; - const isDirect = msgCtx.isDm(); - - const ens = await resolveInboundEns({ - accountId: account.accountId, - sender, - content, - isDirect, - conversation: conversation as any, - log, - }); - - handleInboundMessage({ - account, - sender, - conversationId, - content, - messageId: msgCtx.message.id, - isDirect, - runtime, - log, - ...ens, - }).catch((err) => { - log?.error(`[${account.accountId}] Message handling failed: ${String(err)}`); - }); - }; +/** Build the reaction event handler for inbound reactions. */ +export function buildReactionHandler(params: HandlerBaseParams) { + const { account, runtime, log } = params; + return createInboundHandler(params, { + label: "reaction", + extractContent: (msgCtx) => { + const reaction = msgCtx.message?.content; + if (!reaction) return null; + log?.info( + `[${account.accountId}] reaction event: ${JSON.stringify({ + content: reaction.content, + action: reaction.action, + reference: reaction.reference, + })}`, + ); + return reaction; + }, + ensText: (reaction) => `[Reaction: ${reaction.content}]`, + dispatch: async ({ content: reaction, sender, conversationId, messageId, isDirect, ens }) => { + await handleInboundReaction({ + account, + sender, + conversationId, + reaction, + messageId, + isDirect, + runtime, + log, + ...ens, + }); + }, + }); } /** Build the attachment event handler for inbound RemoteAttachments. */ -export function buildAttachmentHandler(params: { - account: ResolvedXmtpAccount; - runtime: PluginRuntime; - log?: RuntimeLogger; -}): (msgCtx: MessageContext) => Promise { +export function buildAttachmentHandler(params: HandlerBaseParams) { const { account, runtime, log } = params; - - return async (msgCtx: MessageContext) => { - if (msgCtx.isDenied) { - if (account.debug) { - log?.info(`[${account.accountId}] Skipped attachment from denied contact`); - } - return; - } - - const remoteAttachment = msgCtx.message?.content; - if (!remoteAttachment) return; - - const sender = await msgCtx.getSenderAddress(); - if (!sender) return; - - const conversationId = msgCtx.conversation?.id as string; - const isDirect = msgCtx.isDm(); - - const ens = await resolveInboundEns({ - accountId: account.accountId, - sender, - content: "", - isDirect, - conversation: msgCtx.conversation as any, - log, - }); - - handleInboundAttachment({ - account, - sender, - conversationId, - remoteAttachments: [remoteAttachment], - messageId: msgCtx.message.id, - isDirect, - runtime, - log, - ...ens, - }).catch((err) => { - log?.error(`[${account.accountId}] Attachment handling failed: ${String(err)}`); - }); - }; + return createInboundHandler(params, { + label: "attachment", + extractContent: (msgCtx) => msgCtx.message?.content ?? null, + ensText: () => "", + dispatch: async ({ content, sender, conversationId, messageId, isDirect, ens }) => { + await handleInboundAttachment({ + account, + sender, + conversationId, + remoteAttachments: [content], + messageId, + isDirect, + runtime, + log, + ...ens, + }); + }, + }); } /** Build the inline-attachment event handler for inbound Attachments (raw bytes). */ -export function buildInlineAttachmentHandler(params: { - account: ResolvedXmtpAccount; - runtime: PluginRuntime; - log?: RuntimeLogger; -}): (msgCtx: MessageContext) => Promise { +export function buildInlineAttachmentHandler(params: HandlerBaseParams) { const { account, runtime, log } = params; - - return async (msgCtx: MessageContext) => { - if (msgCtx.isDenied) { - if (account.debug) { - log?.info(`[${account.accountId}] Skipped inline attachment from denied contact`); - } - return; - } - - const attachment = msgCtx.message?.content; - if (!attachment) return; - - const sender = await msgCtx.getSenderAddress(); - if (!sender) return; - - const conversationId = msgCtx.conversation?.id as string; - const isDirect = msgCtx.isDm(); - - const ens = await resolveInboundEns({ - accountId: account.accountId, - sender, - content: "", - isDirect, - conversation: msgCtx.conversation as any, - log, - }); - - handleInboundInlineAttachment({ - account, - sender, - conversationId, - attachments: [attachment], - messageId: msgCtx.message.id, - isDirect, - runtime, - log, - ...ens, - }).catch((err) => { - log?.error(`[${account.accountId}] Inline attachment handling failed: ${String(err)}`); - }); - }; + return createInboundHandler(params, { + label: "inline attachment", + extractContent: (msgCtx) => msgCtx.message?.content ?? null, + ensText: () => "", + dispatch: async ({ content, sender, conversationId, messageId, isDirect, ens }) => { + await handleInboundInlineAttachment({ + account, + sender, + conversationId, + attachments: [content], + messageId, + isDirect, + runtime, + log, + ...ens, + }); + }, + }); } /** Build the multi-attachment event handler for inbound MultiRemoteAttachments. */ -export function buildMultiAttachmentHandler(params: { - account: ResolvedXmtpAccount; - runtime: PluginRuntime; - log?: RuntimeLogger; -}): (msgCtx: MessageContext) => Promise { +export function buildMultiAttachmentHandler(params: HandlerBaseParams) { const { account, runtime, log } = params; - - return async (msgCtx: MessageContext) => { - if (msgCtx.isDenied) { - if (account.debug) { - log?.info(`[${account.accountId}] Skipped multi-attachment from denied contact`); - } - return; - } - - const multiAttachment = msgCtx.message?.content; - if (!multiAttachment?.attachments?.length) return; - - const sender = await msgCtx.getSenderAddress(); - if (!sender) return; - - const conversationId = msgCtx.conversation?.id as string; - const isDirect = msgCtx.isDm(); - - const ens = await resolveInboundEns({ - accountId: account.accountId, - sender, - content: "", - isDirect, - conversation: msgCtx.conversation as any, - log, - }); - - // RemoteAttachmentInfo is structurally compatible with RemoteAttachment - const remoteAttachments = multiAttachment.attachments as unknown as RemoteAttachment[]; - - handleInboundAttachment({ - account, - sender, - conversationId, - remoteAttachments, - messageId: msgCtx.message.id, - isDirect, - runtime, - log, - ...ens, - }).catch((err) => { - log?.error(`[${account.accountId}] Multi-attachment handling failed: ${String(err)}`); - }); - }; + return createInboundHandler(params, { + label: "multi-attachment", + extractContent: (msgCtx) => { + const multi = msgCtx.message?.content; + return multi?.attachments?.length ? multi : null; + }, + ensText: () => "", + dispatch: async ({ content: multi, sender, conversationId, messageId, isDirect, ens }) => { + // RemoteAttachmentInfo is structurally compatible with RemoteAttachment + const remoteAttachments = multi.attachments as unknown as RemoteAttachment[]; + await handleInboundAttachment({ + account, + sender, + conversationId, + remoteAttachments, + messageId, + isDirect, + runtime, + log, + ...ens, + }); + }, + }); } diff --git a/extensions/xmtp/src/inbound-pipeline.test.ts b/extensions/xmtp/src/inbound-pipeline.test.ts index 498f19fef6a2..5b2bb119d570 100644 --- a/extensions/xmtp/src/inbound-pipeline.test.ts +++ b/extensions/xmtp/src/inbound-pipeline.test.ts @@ -94,7 +94,7 @@ describe("runInboundPipeline", () => { expect.objectContaining({ channel: "XMTP", from: TEST_SENDER_ADDRESS.slice(0, 12), - body: "test message", + body: "test message [message_id:msg-1]", }), ); }); @@ -111,6 +111,7 @@ describe("runInboundPipeline", () => { expect.objectContaining({ RawBody: "hello world", CommandBody: "hello world", + BodyForAgent: "hello world [message_id:msg-42]", From: `xmtp:${TEST_SENDER_ADDRESS}`, To: `xmtp:${CONVERSATION_ID}`, ChatType: "direct", @@ -190,6 +191,45 @@ describe("runInboundPipeline", () => { }); }); +describe("message ID tagging", () => { + it("sets BodyForAgent with [message_id:...] tag when messageId is provided", async () => { + const { promise, mocks } = callPipeline({ content: "hello", messageId: "msg-42" }); + await promise; + + expect(mocks.finalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + BodyForAgent: "hello [message_id:msg-42]", + CommandBody: "hello", + RawBody: "hello", + }), + ); + }); + + it("does not set BodyForAgent tag when messageId is undefined", async () => { + const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); + const { runtime, mocks } = createMockRuntime(); + + await runInboundPipeline({ + account, + sender: TEST_SENDER_ADDRESS, + conversationId: CONVERSATION_ID, + content: "hello", + messageId: undefined, + isDirect: true, + runtime, + deliverReply: vi.fn(async () => {}), + }); + + expect(mocks.finalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + BodyForAgent: "hello", + CommandBody: "hello", + RawBody: "hello", + }), + ); + }); +}); + describe("ENS enrichment", () => { it("uses senderName in envelope from field when provided", async () => { const account = createTestAccount({ address: TEST_OWNER_ADDRESS, dmPolicy: "open" }); diff --git a/extensions/xmtp/src/inbound-pipeline.ts b/extensions/xmtp/src/inbound-pipeline.ts index 9b18cea62722..333c5696f2f1 100644 --- a/extensions/xmtp/src/inbound-pipeline.ts +++ b/extensions/xmtp/src/inbound-pipeline.ts @@ -61,6 +61,9 @@ export async function runInboundPipeline(params: { sessionKey: route.sessionKey, }); + // Tag the content with the message ID so the LLM can reference it (e.g. for reactions) + const taggedContent = messageId ? `${content} [message_id:${messageId}]` : content; + const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg); const rawBody = runtime.channel.reply.formatAgentEnvelope({ channel: "XMTP", @@ -68,7 +71,7 @@ export async function runInboundPipeline(params: { timestamp: Date.now(), previousTimestamp, envelope: envelopeOptions, - body: content, + body: taggedContent, }); const body = params.ensContext ? `${params.ensContext}\n${rawBody}` : rawBody; @@ -79,6 +82,7 @@ export async function runInboundPipeline(params: { Body: body, RawBody: content, CommandBody: content, + BodyForAgent: taggedContent, From: `xmtp:${sender}`, To: `xmtp:${conversationId}`, SessionKey: route.sessionKey, diff --git a/extensions/xmtp/src/lib/access-control.ts b/extensions/xmtp/src/lib/access-control.ts new file mode 100644 index 000000000000..a593accbce1b --- /dev/null +++ b/extensions/xmtp/src/lib/access-control.ts @@ -0,0 +1,74 @@ +/** + * Inbound access control for XMTP messages. + * Consolidates the group + DM policy checks used by all inbound handlers. + */ + +import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; +import type { ResolvedXmtpAccount } from "../accounts.js"; +import { evaluateDmAccess, isGroupAllowed, sendPairingReply } from "../dm-policy.js"; + +/** + * Enforce inbound access control for a message/reaction/attachment. + * + * Returns `true` if the message should be processed, `false` if it should be dropped. + * + * Handles: + * - Group allowlist filtering (for non-DM conversations) + * - DM policy evaluation (open / disabled / allowlist / pairing) + * - Sending pairing replies for first-time DM senders + */ +export async function enforceInboundAccessControl(params: { + account: ResolvedXmtpAccount; + sender: string; + conversationId: string; + isDirect: boolean; + runtime: PluginRuntime; + log?: RuntimeLogger; + /** Label for log messages (e.g. "message", "reaction", "attachment"). */ + label?: string; +}): Promise { + const { account, sender, conversationId, isDirect, runtime, log } = params; + const label = params.label ?? "message"; + + // Group access control + if (!isDirect && !isGroupAllowed({ account, conversationId })) { + if (account.debug) { + log?.info( + `[${account.accountId}] Dropped ${label} from disallowed conversation ${conversationId.slice(0, 12)}`, + ); + } + return false; + } + + // DM access control + if (isDirect) { + const decision = await evaluateDmAccess({ account, sender, runtime }); + if (!decision.allowed) { + if (decision.reason === "pairing" && decision.created && decision.code) { + await sendPairingReply({ + account, + sender, + conversationId, + code: decision.code, + runtime, + log, + }); + } else if (decision.reason === "blocked" && account.debug) { + log?.info( + `[${account.accountId}] Blocked ${label} from ${sender.slice(0, 12)} (dmPolicy=${decision.dmPolicy})`, + ); + } else if (decision.reason === "disabled" && account.debug) { + log?.info( + `[${account.accountId}] Dropped ${label} from ${sender.slice(0, 12)} (dmPolicy=disabled)`, + ); + } else if (account.debug) { + log?.info( + `[${account.accountId}] Dropped ${label} from ${sender.slice(0, 12)} (dm access denied)`, + ); + } + return false; + } + } + + return true; +} diff --git a/extensions/xmtp/src/lib/ens-resolver.test.ts b/extensions/xmtp/src/lib/ens-resolver.test.ts index 8642cbc24ecd..6cc17f46279a 100644 --- a/extensions/xmtp/src/lib/ens-resolver.test.ts +++ b/extensions/xmtp/src/lib/ens-resolver.test.ts @@ -8,96 +8,53 @@ import { } from "./ens-resolver.js"; describe("isEnsName", () => { - it("returns true for simple .eth name", () => { - expect(isEnsName("nick.eth")).toBe(true); - }); - - it("returns true for subdomain .eth name", () => { - expect(isEnsName("pay.nick.eth")).toBe(true); - }); - - it("returns false for bare string", () => { - expect(isEnsName("nick")).toBe(false); - }); - - it("returns false for ethereum address", () => { - expect(isEnsName("0xd8da6bf26964af9d7eed9e03e53415d37aa96045")).toBe(false); - }); - - it("returns false for empty string", () => { - expect(isEnsName("")).toBe(false); + it.each([ + ["simple .eth name", "nick.eth", true], + ["subdomain .eth name", "pay.nick.eth", true], + ["bare string", "nick", false], + ["ethereum address", "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", false], + ["empty string", "", false], + ] as const)("%s → %s", (_desc, input, expected) => { + expect(isEnsName(input)).toBe(expected); }); }); describe("isEthAddress", () => { - it("returns true for valid checksum address", () => { - expect(isEthAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")).toBe(true); - }); - - it("returns true for lowercase address", () => { - expect(isEthAddress("0xd8da6bf26964af9d7eed9e03e53415d37aa96045")).toBe(true); - }); - - it("returns false for short hex", () => { - expect(isEthAddress("0xd8da6b")).toBe(false); - }); - - it("returns false for ENS name", () => { - expect(isEthAddress("vitalik.eth")).toBe(false); + it.each([ + ["valid checksum address", "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", true], + ["lowercase address", "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", true], + ["short hex", "0xd8da6b", false], + ["ENS name", "vitalik.eth", false], + ] as const)("%s → %s", (_desc, input, expected) => { + expect(isEthAddress(input)).toBe(expected); }); }); describe("extractEnsNames", () => { - it("extracts .eth names from text", () => { - expect(extractEnsNames("send 1 ETH to nick.eth please")).toEqual(["nick.eth"]); - }); - - it("extracts multiple names", () => { - const result = extractEnsNames("nick.eth and vitalik.eth are friends"); - expect(result).toEqual(["nick.eth", "vitalik.eth"]); - }); - - it("extracts subdomain names", () => { - expect(extractEnsNames("check pay.nick.eth")).toEqual(["pay.nick.eth"]); - }); - - it("deduplicates", () => { - expect(extractEnsNames("nick.eth sent to nick.eth")).toEqual(["nick.eth"]); - }); - - it("returns empty array for no matches", () => { - expect(extractEnsNames("no names here")).toEqual([]); - }); - - it("filters parent when subdomain present", () => { - const result = extractEnsNames("pay.nick.eth and nick.eth"); - expect(result).toEqual(["pay.nick.eth"]); - }); - - it("extracts case-insensitive .eth names", () => { - expect(extractEnsNames("send to NICK.ETH please")).toEqual(["NICK.ETH"]); + it.each([ + ["single name", "send 1 ETH to nick.eth please", ["nick.eth"]], + ["multiple names", "nick.eth and vitalik.eth are friends", ["nick.eth", "vitalik.eth"]], + ["subdomain", "check pay.nick.eth", ["pay.nick.eth"]], + ["deduplicates", "nick.eth sent to nick.eth", ["nick.eth"]], + ["no matches", "no names here", []], + ["filters parent when subdomain present", "pay.nick.eth and nick.eth", ["pay.nick.eth"]], + ["case-insensitive", "send to NICK.ETH please", ["NICK.ETH"]], + ])("%s", (_desc, input, expected) => { + expect(extractEnsNames(input as string)).toEqual(expected); }); }); describe("extractEthAddresses", () => { - it("extracts addresses from text", () => { - const addr = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; - expect(extractEthAddresses(`send to ${addr}`)).toEqual([addr]); - }); - - it("extracts multiple addresses", () => { - const a1 = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; - const a2 = "0x1234567890abcdef1234567890abcdef12345678"; - expect(extractEthAddresses(`${a1} and ${a2}`)).toEqual([a1, a2]); - }); - - it("deduplicates", () => { - const addr = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; - expect(extractEthAddresses(`${addr} ${addr}`)).toEqual([addr]); - }); - - it("returns empty for no matches", () => { - expect(extractEthAddresses("no addresses")).toEqual([]); + const A1 = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; + const A2 = "0x1234567890abcdef1234567890abcdef12345678"; + + it.each([ + ["single address", `send to ${A1}`, [A1]], + ["multiple addresses", `${A1} and ${A2}`, [A1, A2]], + ["deduplicates", `${A1} ${A1}`, [A1]], + ["no matches", "no addresses", []], + ])("%s", (_desc, input, expected) => { + expect(extractEthAddresses(input)).toEqual(expected); }); }); diff --git a/extensions/xmtp/src/lib/ens-resolver.ts b/extensions/xmtp/src/lib/ens-resolver.ts index e4f595f4f960..0f0218fb2901 100644 --- a/extensions/xmtp/src/lib/ens-resolver.ts +++ b/extensions/xmtp/src/lib/ens-resolver.ts @@ -188,6 +188,25 @@ export function setResolverForAccount(accountId: string, resolver: EnsResolver | } } +// --------------------------------------------------------------------------- +// Owner address resolution +// --------------------------------------------------------------------------- + +/** + * Resolve an owner address that might be an ENS name to a raw Ethereum address. + * Returns the resolved address, or the original value if not an ENS name or + * resolution fails. + */ +export async function resolveOwnerAddress( + ownerAddress: string, + resolver: EnsResolver | null, +): Promise { + if (!isEnsName(ownerAddress)) return ownerAddress; + if (!resolver) return ownerAddress; + const resolved = await resolver.resolveEnsName(ownerAddress); + return resolved ?? ownerAddress; +} + // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- diff --git a/extensions/xmtp/src/lib/identity.ts b/extensions/xmtp/src/lib/identity.ts index 4622d2871e76..7b20d373e777 100644 --- a/extensions/xmtp/src/lib/identity.ts +++ b/extensions/xmtp/src/lib/identity.ts @@ -20,3 +20,17 @@ export function walletAddressFromPrivateKey(walletKey: string): string { const hexKey = walletKey.startsWith("0x") ? walletKey : `0x${walletKey}`; return privateKeyToAccount(hexKey as `0x${string}`).address; } + +/** + * Generate a complete XMTP identity: wallet key, encryption key, and public address. + */ +export function generateXmtpIdentity(): { + walletKey: string; + dbEncryptionKey: string; + publicAddress: string; +} { + const walletKey = generatePrivateKey(); + const dbEncryptionKey = generateEncryptionKeyHex(); + const publicAddress = walletAddressFromPrivateKey(walletKey); + return { walletKey, dbEncryptionKey, publicAddress }; +} diff --git a/extensions/xmtp/src/lib/index.ts b/extensions/xmtp/src/lib/index.ts new file mode 100644 index 000000000000..20eb736fc2ee --- /dev/null +++ b/extensions/xmtp/src/lib/index.ts @@ -0,0 +1,33 @@ +/** + * Barrel file for lib/ — re-exports public API from sub-modules. + */ + +export { enforceInboundAccessControl } from "./access-control.js"; + +export { + isEnsName, + isEthAddress, + extractEnsNames, + extractEthAddresses, + createEnsResolver, + getResolverForAccount, + setResolverForAccount, + resolveOwnerAddress, + formatEnsContext, + formatGroupMembersWithEns, + type EnsResolver, +} from "./ens-resolver.js"; + +export { + generateEncryptionKeyHex, + generatePrivateKey, + walletAddressFromPrivateKey, + generateXmtpIdentity, +} from "./identity.js"; + +export { + runTemporaryXmtpClient, + createAgentFromAccount, + getOrCreateConversation, + ensureHexPrefix, +} from "./xmtp-client.js"; diff --git a/extensions/xmtp/src/lib/xmtp-client.ts b/extensions/xmtp/src/lib/xmtp-client.ts index 41bfeba816f2..25d3ae910a38 100644 --- a/extensions/xmtp/src/lib/xmtp-client.ts +++ b/extensions/xmtp/src/lib/xmtp-client.ts @@ -6,20 +6,40 @@ */ import { Agent, createSigner, createUser, type HexString } from "@xmtp/agent-sdk"; +import * as fs from "node:fs"; import * as path from "node:path"; import type { ResolvedXmtpAccount } from "../accounts.js"; import { getXmtpRuntime } from "../runtime.js"; /** Ensure a hex string has the `0x` prefix required by the SDK. */ -function ensureHexPrefix(hex: string): HexString { +export function ensureHexPrefix(hex: string): HexString { return (hex.startsWith("0x") ? hex : `0x${hex}`) as HexString; } +/** + * Look up a conversation by ID, or create a DM if the target looks like + * an Ethereum address (starts with 0x). Throws if neither succeeds. + */ +export async function getOrCreateConversation( + agent: Pick, + target: string, +) { + let conversation = await agent.client.conversations.getConversationById(target); + if (!conversation && target.startsWith("0x")) { + conversation = await agent.createDmWithAddress(target as `0x${string}`); + } + if (!conversation) { + throw new Error(`Conversation not found: ${target.slice(0, 12)}...`); + } + return conversation; +} + export async function createAgentFromAccount( account: ResolvedXmtpAccount, stateDir: string, ): Promise { const dbPath = path.join(stateDir, "xmtp", account.accountId); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const user = createUser(ensureHexPrefix(account.walletKey)); const signer = createSigner(user); return Agent.create(signer, { @@ -38,6 +58,7 @@ export async function runTemporaryXmtpClient(params: { const accountId = params.accountId ?? "default"; const stateDir = getXmtpRuntime().state.resolveStateDir(); const dbPath = path.join(stateDir, "xmtp", accountId); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const user = createUser(ensureHexPrefix(params.walletKey)); const signer = createSigner(user); const agent = await Agent.create(signer, { diff --git a/extensions/xmtp/src/onboarding.ts b/extensions/xmtp/src/onboarding.ts index d6f403428dd4..ece52da66b6e 100644 --- a/extensions/xmtp/src/onboarding.ts +++ b/extensions/xmtp/src/onboarding.ts @@ -3,7 +3,7 @@ import { type ChannelOnboardingDmPolicy, type OpenClawConfig, } from "openclaw/plugin-sdk"; -import type { DmPolicy } from "./config-types.js"; +import type { DmPolicy } from "./config-schema.js"; import { getXmtpSection, listXmtpAccountIds, @@ -12,11 +12,7 @@ import { type CoreConfig, } from "./accounts.js"; import { isEnsName } from "./lib/ens-resolver.js"; -import { - generateEncryptionKeyHex, - generatePrivateKey, - walletAddressFromPrivateKey, -} from "./lib/identity.js"; +import { generateXmtpIdentity, walletAddressFromPrivateKey } from "./lib/identity.js"; import { runTemporaryXmtpClient } from "./lib/xmtp-client.js"; const channel = "xmtp" as const; @@ -119,9 +115,10 @@ export const xmtpOnboardingAdapter: ChannelOnboardingAdapter = { let publicAddress: string; if (keySource === "random") { - walletKey = generatePrivateKey(); - dbEncryptionKey = generateEncryptionKeyHex(); - publicAddress = walletAddressFromPrivateKey(walletKey); + const identity = generateXmtpIdentity(); + walletKey = identity.walletKey; + dbEncryptionKey = identity.dbEncryptionKey; + publicAddress = identity.publicAddress; } else { walletKey = await prompter.text({ message: "Wallet key (private key)", diff --git a/extensions/xmtp/src/outbound.test.ts b/extensions/xmtp/src/outbound.test.ts index e4021560dec0..7adf8d5a7467 100644 --- a/extensions/xmtp/src/outbound.test.ts +++ b/extensions/xmtp/src/outbound.test.ts @@ -15,7 +15,7 @@ import { getAgentOrThrow, } from "./outbound.js"; import { getXmtpRuntime, setXmtpRuntime } from "./runtime.js"; -import { makeFakeAgent } from "./test-utils/unit-helpers.js"; +import { createMockEnsResolver, makeFakeAgent } from "./test-utils/unit-helpers.js"; vi.mock("@xmtp/agent-sdk", () => ({ encryptAttachment: vi.fn( @@ -467,11 +467,10 @@ describe("XMTP outbound adapter", () => { it("sendText resolves ENS name to address before sending", async () => { const { agent, fakeConversation } = makeFakeAgent(); setClientForAccount(ACCOUNT_ID, agent as any); - setResolverForAccount(ACCOUNT_ID, { - resolveEnsName: vi.fn(async () => RESOLVED_ADDRESS), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }); + setResolverForAccount( + ACCOUNT_ID, + createMockEnsResolver({ resolveEnsName: RESOLVED_ADDRESS }), + ); const cfg = makeCfg(); await xmtpOutbound.sendText!({ @@ -488,11 +487,10 @@ describe("XMTP outbound adapter", () => { it("sendMedia resolves ENS name to address before sending", async () => { const { agent, fakeConversation } = makeFakeAgent(); setClientForAccount(ACCOUNT_ID, agent as any); - setResolverForAccount(ACCOUNT_ID, { - resolveEnsName: vi.fn(async () => RESOLVED_ADDRESS), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }); + setResolverForAccount( + ACCOUNT_ID, + createMockEnsResolver({ resolveEnsName: RESOLVED_ADDRESS }), + ); const cfg = makeCfg(); await xmtpOutbound.sendMedia!({ @@ -509,11 +507,7 @@ describe("XMTP outbound adapter", () => { it("falls back to original value when ENS resolution returns null", async () => { const { agent } = makeFakeAgent({ conversationId: CONVERSATION_ID }); setClientForAccount(ACCOUNT_ID, agent as any); - setResolverForAccount(ACCOUNT_ID, { - resolveEnsName: vi.fn(async () => null), - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }); + setResolverForAccount(ACCOUNT_ID, createMockEnsResolver()); const cfg = makeCfg(); // nick.eth will be unresolved (null) — should use original "nick.eth" as target @@ -534,12 +528,8 @@ describe("XMTP outbound adapter", () => { it("passes non-ENS targets through without resolution", async () => { const { agent, fakeConversation } = makeFakeAgent({ conversationId: CONVERSATION_ID }); setClientForAccount(ACCOUNT_ID, agent as any); - const mockResolveEnsName = vi.fn(async () => RESOLVED_ADDRESS); - setResolverForAccount(ACCOUNT_ID, { - resolveEnsName: mockResolveEnsName, - resolveAddress: vi.fn(async () => null), - resolveAll: vi.fn(async () => new Map()), - }); + const mockResolver = createMockEnsResolver({ resolveEnsName: RESOLVED_ADDRESS }); + setResolverForAccount(ACCOUNT_ID, mockResolver); const cfg = makeCfg(); await xmtpOutbound.sendText!({ @@ -550,7 +540,7 @@ describe("XMTP outbound adapter", () => { }); // Should NOT have called resolveEnsName since CONVERSATION_ID is not an ENS name - expect(mockResolveEnsName).not.toHaveBeenCalled(); + expect(mockResolver.resolveEnsName).not.toHaveBeenCalled(); expect(agent.client.conversations.getConversationById).toHaveBeenCalledWith(CONVERSATION_ID); }); }); diff --git a/extensions/xmtp/src/outbound.ts b/extensions/xmtp/src/outbound.ts index df18df3a8939..39ee003bac5c 100644 --- a/extensions/xmtp/src/outbound.ts +++ b/extensions/xmtp/src/outbound.ts @@ -4,6 +4,7 @@ import { createRemoteAttachment, encryptAttachment } from "@xmtp/agent-sdk"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import { resolveXmtpAccount, type CoreConfig } from "./accounts.js"; import { getResolverForAccount, isEnsName } from "./lib/ens-resolver.js"; +import { getOrCreateConversation } from "./lib/xmtp-client.js"; import { getXmtpRuntime } from "./runtime.js"; const MAX_MEDIA_BYTES = 25 * 1024 * 1024; // 25 MB @@ -140,13 +141,7 @@ export const xmtpOutbound: ChannelOutboundAdapter = { const account = resolveXmtpAccount({ cfg: cfg as CoreConfig, accountId }); const agent = getAgentOrThrow(account.accountId); const target = await resolveOutboundTarget(to, account.accountId); - let conversation = await agent.client.conversations.getConversationById(target); - if (!conversation && target.startsWith("0x")) { - conversation = await agent.createDmWithAddress(target as `0x${string}`); - } - if (!conversation) { - throw new Error(`Conversation not found: ${target.slice(0, 12)}...`); - } + const conversation = await getOrCreateConversation(agent, target); const messageId = await conversation.sendText(text); return { channel: CHANNEL_ID, messageId }; }, @@ -155,13 +150,7 @@ export const xmtpOutbound: ChannelOutboundAdapter = { const account = resolveXmtpAccount({ cfg: cfg as CoreConfig, accountId }); const agent = getAgentOrThrow(account.accountId); const target = await resolveOutboundTarget(to, account.accountId); - let conversation = await agent.client.conversations.getConversationById(target); - if (!conversation && target.startsWith("0x")) { - conversation = await agent.createDmWithAddress(target as `0x${string}`); - } - if (!conversation) { - throw new Error(`Conversation not found: ${target.slice(0, 12)}...`); - } + const conversation = await getOrCreateConversation(agent, target); // Send caption text first if provided alongside media if (text && mediaUrl) { diff --git a/extensions/xmtp/src/setup.test.ts b/extensions/xmtp/src/setup.test.ts new file mode 100644 index 000000000000..77b251cbc4bc --- /dev/null +++ b/extensions/xmtp/src/setup.test.ts @@ -0,0 +1,139 @@ +/** + * Unit tests for handleSetup — verifies key reuse logic. + */ + +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const VALID_WALLET_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const VALID_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const EXISTING_DB_ENC_KEY = "ab".repeat(32); +const GENERATED_DB_ENC_KEY = "cd".repeat(32); +const GENERATED_WALLET_KEY = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; +const GENERATED_ADDRESS = "0xGeneratedAddr"; + +// --- Mocks --- + +vi.mock("./runtime.js", () => ({ + getXmtpRuntime: vi.fn(), +})); + +vi.mock("./lib/identity.js", () => ({ + generateXmtpIdentity: vi.fn(() => ({ + walletKey: GENERATED_WALLET_KEY, + dbEncryptionKey: GENERATED_DB_ENC_KEY, + publicAddress: GENERATED_ADDRESS, + })), + generateEncryptionKeyHex: vi.fn(() => GENERATED_DB_ENC_KEY), + walletAddressFromPrivateKey: vi.fn(() => VALID_ADDRESS), +})); + +vi.mock("./lib/xmtp-client.js", () => ({ + runTemporaryXmtpClient: vi.fn(async () => {}), +})); + +import { + generateXmtpIdentity, + generateEncryptionKeyHex, + walletAddressFromPrivateKey, +} from "./lib/identity.js"; +import { runTemporaryXmtpClient } from "./lib/xmtp-client.js"; +import { getXmtpRuntime } from "./runtime.js"; +import { handleSetup } from "./setup.js"; + +type MockedFn = ReturnType; + +function setupRuntime(xmtpConfig: Record = {}) { + const loadConfig = vi.fn(() => ({ + channels: { xmtp: xmtpConfig }, + })); + const writeConfigFile = vi.fn(async () => {}); + const log = { info: vi.fn(), error: vi.fn() }; + + (getXmtpRuntime as MockedFn).mockReturnValue({ + config: { loadConfig, writeConfigFile }, + logging: { getChildLogger: () => log }, + }); + + return { loadConfig, writeConfigFile, log }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("handleSetup", () => { + it("reuses both keys when walletKey and dbEncryptionKey exist in config", async () => { + setupRuntime({ + walletKey: VALID_WALLET_KEY, + dbEncryptionKey: EXISTING_DB_ENC_KEY, + publicAddress: VALID_ADDRESS, + }); + + const result = await handleSetup({ env: "dev" }); + + expect(generateXmtpIdentity).not.toHaveBeenCalled(); + expect(generateEncryptionKeyHex).not.toHaveBeenCalled(); + expect(result.publicAddress).toBe(VALID_ADDRESS); + + expect(runTemporaryXmtpClient).toHaveBeenCalledWith( + expect.objectContaining({ + walletKey: VALID_WALLET_KEY, + dbEncryptionKey: EXISTING_DB_ENC_KEY, + }), + ); + }); + + it("generates only dbEncryptionKey when walletKey exists but dbEncryptionKey is missing", async () => { + setupRuntime({ + walletKey: VALID_WALLET_KEY, + }); + + const result = await handleSetup({ env: "dev" }); + + expect(generateXmtpIdentity).not.toHaveBeenCalled(); + expect(generateEncryptionKeyHex).toHaveBeenCalledTimes(1); + expect(walletAddressFromPrivateKey).toHaveBeenCalledWith(VALID_WALLET_KEY); + expect(result.publicAddress).toBe(VALID_ADDRESS); + + expect(runTemporaryXmtpClient).toHaveBeenCalledWith( + expect.objectContaining({ + walletKey: VALID_WALLET_KEY, + dbEncryptionKey: GENERATED_DB_ENC_KEY, + }), + ); + }); + + it("generates full identity when no keys exist in config", async () => { + setupRuntime({}); + + const result = await handleSetup({ env: "dev" }); + + expect(generateXmtpIdentity).toHaveBeenCalledTimes(1); + expect(result.publicAddress).toBe(GENERATED_ADDRESS); + + expect(runTemporaryXmtpClient).toHaveBeenCalledWith( + expect.objectContaining({ + walletKey: GENERATED_WALLET_KEY, + dbEncryptionKey: GENERATED_DB_ENC_KEY, + }), + ); + }); + + it("generates full identity when only dbEncryptionKey exists (no walletKey)", async () => { + setupRuntime({ + dbEncryptionKey: EXISTING_DB_ENC_KEY, + }); + + const result = await handleSetup({ env: "dev" }); + + expect(generateXmtpIdentity).toHaveBeenCalledTimes(1); + expect(result.publicAddress).toBe(GENERATED_ADDRESS); + + expect(runTemporaryXmtpClient).toHaveBeenCalledWith( + expect.objectContaining({ + walletKey: GENERATED_WALLET_KEY, + dbEncryptionKey: GENERATED_DB_ENC_KEY, + }), + ); + }); +}); diff --git a/extensions/xmtp/src/setup.ts b/extensions/xmtp/src/setup.ts index b5f8414c2196..a200daac6cad 100644 --- a/extensions/xmtp/src/setup.ts +++ b/extensions/xmtp/src/setup.ts @@ -8,7 +8,7 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; import { resolveXmtpAccount, updateXmtpSection, type CoreConfig } from "./accounts.js"; import { generateEncryptionKeyHex, - generatePrivateKey, + generateXmtpIdentity, walletAddressFromPrivateKey, } from "./lib/identity.js"; import { runTemporaryXmtpClient } from "./lib/xmtp-client.js"; @@ -27,11 +27,38 @@ export async function handleSetup(params: { env?: "production" | "dev"; ownerAddress?: string; }): Promise<{ publicAddress: string }> { + const runtime = getXmtpRuntime(); + const log = runtime.logging.getChildLogger(); const env = params.env === "dev" ? "dev" : "production"; - const walletKey = generatePrivateKey(); - const dbEncryptionKey = generateEncryptionKeyHex(); - const publicAddress = walletAddressFromPrivateKey(walletKey); + log?.info(`[xmtp] setup started (env: ${env})`); + + const cfg = runtime.config.loadConfig() as OpenClawConfig; + const account = resolveXmtpAccount({ + cfg: cfg as CoreConfig, + accountId: params.accountId, + }); + + log.info(`[xmtp] account resolved: ${account}`); + + let walletKey: string; + let dbEncryptionKey: string; + let publicAddress: string; + + if (account.walletKey && account.dbEncryptionKey) { + walletKey = account.walletKey; + dbEncryptionKey = account.dbEncryptionKey; + publicAddress = account.publicAddress; + } else if (account.walletKey) { + walletKey = account.walletKey; + dbEncryptionKey = generateEncryptionKeyHex(); + publicAddress = walletAddressFromPrivateKey(walletKey); + } else { + const identity = generateXmtpIdentity(); + walletKey = identity.walletKey; + dbEncryptionKey = identity.dbEncryptionKey; + publicAddress = identity.publicAddress; + } await runTemporaryXmtpClient({ walletKey, @@ -47,6 +74,8 @@ export async function handleSetup(params: { publicAddress, ownerAddress: params.ownerAddress, }; + + log?.info(`[xmtp] setup identity generated (address: ${publicAddress})`); return { publicAddress }; } @@ -79,8 +108,11 @@ export async function handleSetupComplete(): Promise<{ saved: true }> { } const runtime = getXmtpRuntime(); + const log = runtime.logging.getChildLogger(); const cfg = runtime.config.loadConfig() as OpenClawConfig; + log?.info("[xmtp] setup complete — writing config"); + const next = updateXmtpSection(cfg, { walletKey: setupResult.walletKey, dbEncryptionKey: setupResult.dbEncryptionKey, @@ -93,11 +125,16 @@ export async function handleSetupComplete(): Promise<{ saved: true }> { await runtime.config.writeConfigFile(next); setupResult = null; + log?.info("[xmtp] setup config saved"); return { saved: true }; } export function handleSetupCancel(): { cancelled: boolean } { + const log = getXmtpRuntime().logging.getChildLogger(); const wasPending = setupResult !== null; setupResult = null; + if (wasPending) { + log?.info("[xmtp] setup cancelled"); + } return { cancelled: wasPending }; } diff --git a/extensions/xmtp/src/test-utils/unit-helpers.ts b/extensions/xmtp/src/test-utils/unit-helpers.ts index 8c4c9adaa8d7..3e9aaab6f125 100644 --- a/extensions/xmtp/src/test-utils/unit-helpers.ts +++ b/extensions/xmtp/src/test-utils/unit-helpers.ts @@ -5,7 +5,7 @@ import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; import { vi } from "vitest"; import type { ResolvedXmtpAccount, CoreConfig } from "../accounts.js"; -import type { DmPolicy, GroupPolicy, XMTPAccountConfig } from "../config-types.js"; +import type { DmPolicy, GroupPolicy, XMTPAccountConfig } from "../config-schema.js"; import { handleInboundMessage } from "../channel.js"; // --------------------------------------------------------------------------- @@ -105,6 +105,7 @@ export function createMockRuntime(overrides?: { dmPolicy?: DmPolicy; allowFrom?: Array; storeAllowFrom?: string[]; + pairingResult?: { code: string; created: boolean }; }): MockRuntime { const cfg: CoreConfig = { channels: { @@ -138,7 +139,9 @@ export function createMockRuntime(overrides?: { ({ code }: { channel: string; idLine: string; code: string }) => `Pairing code: ${code}`, ); const readAllowFromStore = vi.fn(async () => overrides?.storeAllowFrom ?? []); - const upsertPairingRequest = vi.fn(async () => ({ code: "TESTCODE", created: true })); + const upsertPairingRequest = vi.fn( + async () => overrides?.pairingResult ?? { code: "TESTCODE", created: true }, + ); const runtime = { config: { loadConfig, writeConfigFile }, @@ -193,6 +196,26 @@ export function createMockRuntime(overrides?: { }; } +// --------------------------------------------------------------------------- +// Mock ENS resolver +// --------------------------------------------------------------------------- + +/** + * Create a mock EnsResolver for unit tests. + * Each method returns the provided default value (or null/empty Map). + */ +export function createMockEnsResolver(overrides?: { + resolveEnsName?: string | null; + resolveAddress?: string | null; + resolveAll?: Map; +}) { + return { + resolveEnsName: vi.fn(async () => overrides?.resolveEnsName ?? null), + resolveAddress: vi.fn(async () => overrides?.resolveAddress ?? null), + resolveAll: vi.fn(async () => overrides?.resolveAll ?? new Map()), + }; +} + // --------------------------------------------------------------------------- // Fake XMTP agent (consolidated from access-control + outbound tests) // ---------------------------------------------------------------------------