Skip to content
This repository was archived by the owner on Feb 27, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 134 additions & 1 deletion extensions/xmtp/src/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
* No network access needed — tests pure config parsing.
*/

import { describe, expect, it } from "vitest";
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import {
autoProvisionAccount,
resolveXmtpAccount,
listXmtpAccountIds,
resolveDefaultXmtpAccountId,
listEnabledXmtpAccounts,
type CoreConfig,
type ResolvedXmtpAccount,
} from "./accounts.js";

// Use a real 32-byte hex private key for tests that need address derivation
Expand Down Expand Up @@ -350,3 +353,133 @@ describe("listEnabledXmtpAccounts", () => {
expect(enabled[0]?.accountId).toBe("bot1");
});
});

// ---------------------------------------------------------------------------
// autoProvisionAccount
// ---------------------------------------------------------------------------

function makeAccount(overrides?: Partial<ResolvedXmtpAccount>): ResolvedXmtpAccount {
return {
accountId: "default",
enabled: true,
configured: false,
walletKey: "",
dbEncryptionKey: "",
env: "dev",
debug: false,
publicAddress: "",
config: {},
...overrides,
};
}

function makeMockRuntime(): { runtime: PluginRuntime; writeConfigFile: ReturnType<typeof vi.fn> } {
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 log = { info: vi.fn(), error: vi.fn() };

const result = await autoProvisionAccount(account, runtime, log as any);

expect(result.walletKey).toBeTruthy();
expect(result.walletKey).toMatch(/^0x[0-9a-f]{64}$/);
expect(result.dbEncryptionKey).toBeTruthy();
expect(result.dbEncryptionKey).toMatch(/^[0-9a-f]{64}$/);
expect(result.publicAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
expect(result.configured).toBe(true);
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0][0];
expect(written.channels.xmtp.walletKey).toBe(result.walletKey);
expect(written.channels.xmtp.dbEncryptionKey).toBe(result.dbEncryptionKey);
expect(written.channels.xmtp.publicAddress).toBe(result.publicAddress);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining("walletKey, dbEncryptionKey"));
});

it("generates only dbEncryptionKey when walletKey is present", async () => {
const account = makeAccount({
walletKey: VALID_WALLET_KEY,
publicAddress: VALID_ADDRESS,
});
const { runtime, writeConfigFile } = makeMockRuntime();
const log = { info: vi.fn(), error: vi.fn() };

const result = await autoProvisionAccount(account, runtime, log as any);

expect(result.walletKey).toBe(VALID_WALLET_KEY);
expect(result.publicAddress).toBe(VALID_ADDRESS);
expect(result.dbEncryptionKey).toMatch(/^[0-9a-f]{64}$/);
expect(result.configured).toBe(true);
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0][0];
expect(written.channels.xmtp.walletKey).toBeUndefined();
expect(written.channels.xmtp.dbEncryptionKey).toBe(result.dbEncryptionKey);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining("dbEncryptionKey"));
expect(log.info).not.toHaveBeenCalledWith(expect.stringContaining("walletKey"));
});

it("generates only walletKey when dbEncryptionKey is present", async () => {
const existingEncKey = "ab".repeat(32);
const account = makeAccount({ dbEncryptionKey: existingEncKey });
const { runtime, writeConfigFile } = makeMockRuntime();
const log = { info: vi.fn(), error: vi.fn() };

const result = await autoProvisionAccount(account, runtime, log as any);

expect(result.walletKey).toMatch(/^0x[0-9a-f]{64}$/);
expect(result.dbEncryptionKey).toBe(existingEncKey);
expect(result.publicAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
expect(result.configured).toBe(true);
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0][0];
expect(written.channels.xmtp.walletKey).toBe(result.walletKey);
expect(written.channels.xmtp.publicAddress).toBe(result.publicAddress);
expect(written.channels.xmtp.dbEncryptionKey).toBeUndefined();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining("walletKey"));
expect(log.info).not.toHaveBeenCalledWith(expect.stringContaining("dbEncryptionKey"));
});

it("returns account unchanged when both keys are present", async () => {
const account = makeAccount({
walletKey: VALID_WALLET_KEY,
dbEncryptionKey: "deadbeef",
publicAddress: VALID_ADDRESS,
configured: true,
});
const { runtime, writeConfigFile } = makeMockRuntime();

const result = await autoProvisionAccount(account, runtime);

expect(result).toBe(account);
expect(writeConfigFile).not.toHaveBeenCalled();
});

it("generated wallet key produces a valid Ethereum address", async () => {
const account = makeAccount();
const { runtime } = makeMockRuntime();

const result = await autoProvisionAccount(account, runtime);

// Address should be a valid checksummed or lowercase Ethereum address
expect(result.publicAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
// Verify it's derived from the generated wallet key
const { walletAddressFromPrivateKey } = await import("./lib/identity.js");
expect(result.publicAddress).toBe(walletAddressFromPrivateKey(result.walletKey));
});

it("generated encryption key is 32-byte hex", async () => {
const account = makeAccount({ walletKey: VALID_WALLET_KEY, publicAddress: VALID_ADDRESS });
const { runtime } = makeMockRuntime();

const result = await autoProvisionAccount(account, runtime);

expect(result.dbEncryptionKey).toHaveLength(64);
expect(result.dbEncryptionKey).toMatch(/^[0-9a-f]{64}$/);
});
});
66 changes: 64 additions & 2 deletions extensions/xmtp/src/accounts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig } from "openclaw/plugin-sdk";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type OpenClawConfig,
type PluginRuntime,
type RuntimeLogger,
} from "openclaw/plugin-sdk";
import type { XMTPConfig } from "./config-types.js";
import { walletAddressFromPrivateKey } from "./lib/identity.js";
import {
generateEncryptionKeyHex,
generatePrivateKey,
walletAddressFromPrivateKey,
} from "./lib/identity.js";

export type CoreConfig = {
channels?: {
Expand Down Expand Up @@ -137,3 +147,55 @@ export function ensureXmtpConfigured(account: ResolvedXmtpAccount): void {
);
}
}

/**
* Auto-generate missing walletKey and/or dbEncryptionKey, persist to config,
* and return the updated account. If both keys are already present, returns
* the account unchanged (no-op).
*/
export async function autoProvisionAccount(
account: ResolvedXmtpAccount,
runtime: PluginRuntime,
log?: RuntimeLogger,
): Promise<ResolvedXmtpAccount> {
const needWalletKey = !account.walletKey;
const needEncryptionKey = !account.dbEncryptionKey;

if (!needWalletKey && !needEncryptionKey) {
return account;
}

const update: Partial<XMTPConfig> = {};
let walletKey = account.walletKey;
let dbEncryptionKey = account.dbEncryptionKey;
let publicAddress = account.publicAddress;

if (needWalletKey) {
walletKey = generatePrivateKey();
publicAddress = walletAddressFromPrivateKey(walletKey);
update.walletKey = walletKey;
update.publicAddress = publicAddress;
}

if (needEncryptionKey) {
dbEncryptionKey = generateEncryptionKeyHex();
update.dbEncryptionKey = dbEncryptionKey;
}

const cfg = runtime.config.loadConfig();
const next = updateXmtpSection(cfg, update);
await runtime.config.writeConfigFile(next);

const generated = [needWalletKey && "walletKey", needEncryptionKey && "dbEncryptionKey"]
.filter(Boolean)
.join(", ");
log?.info(`[${account.accountId}] auto-provisioned XMTP keys: ${generated}`);

return {
...account,
walletKey,
dbEncryptionKey,
publicAddress,
configured: true,
};
}
27 changes: 25 additions & 2 deletions extensions/xmtp/src/actions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "openclaw/plugin-sdk";
import { jsonResult, readStringParam } from "openclaw/plugin-sdk";
import { jsonResult, readReactionParams, readStringParam } from "openclaw/plugin-sdk";
import { listXmtpAccountIds, resolveXmtpAccount, type CoreConfig } from "./accounts.js";
import { getAgentOrThrow } from "./outbound.js";

export const xmtpMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) =>
listXmtpAccountIds(cfg as CoreConfig).length > 0
? (["send"] as ChannelMessageActionName[])
? (["send", "react"] as ChannelMessageActionName[])
: [],

supportsButtons: (_params) => false,
Expand All @@ -29,6 +29,29 @@ export const xmtpMessageActions: ChannelMessageActionAdapter = {
return jsonResult({ ok: true, to, messageId });
}

if (action === "react") {
const to = readStringParam(params, "to", { required: true });
const messageId = readStringParam(params, "messageId", { required: true });
const { emoji, remove } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove an XMTP reaction.",
});

const conversation = await agent.client.conversations.getConversationById(to);
if (!conversation) {
throw new Error(`Conversation not found: ${to.slice(0, 12)}...`);
}

await conversation.sendReaction({
reference: messageId,
referenceInboxId: "",
action: remove ? 2 : 1,
content: emoji,
schema: 1,
});

return jsonResult(remove ? { ok: true, removed: emoji } : { ok: true, added: emoji });
}

throw new Error(`Action "${action}" is not supported for XMTP.`);
},
};
81 changes: 80 additions & 1 deletion extensions/xmtp/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,84 @@ export async function handleInboundMessage(params: {
});
}

// ---------------------------------------------------------------------------
// Inbound reaction handler
// ---------------------------------------------------------------------------

export async function handleInboundReaction(params: {
account: ResolvedXmtpAccount;
sender: string;
conversationId: string;
reaction: { content: string; action: number | string; reference: string };
messageId: string | undefined;
isDirect: boolean;
runtime: PluginRuntime;
log?: RuntimeLogger;
}) {
const { account, sender, conversationId, reaction, messageId, isDirect, runtime, log } = params;

const actionLabel = reaction.action === 2 ? "removed" : "added";

if (account.debug) {
log?.info(
`[${account.accountId}] Reaction from ${sender.slice(0, 12)}: ${reaction.content} ${actionLabel}`,
);
}

// 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;
}
}

// 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,
});

await runInboundPipeline({
account,
sender,
conversationId,
content,
messageId,
isDirect,
runtime,
log,
deliverReply: async (payload: ReplyPayload) => {
await deliverXmtpReply({
payload,
conversationId,
accountId: account.accountId,
runtime,
log,
tableMode,
});
},
});
}

// ---------------------------------------------------------------------------
// Reply delivery
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -195,7 +273,7 @@ export const xmtpPlugin: ChannelPlugin<ResolvedXmtpAccount> = {
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: false,
reactions: true,
threads: false,
},
reload: { configPrefixes: ["channels.xmtp"] },
Expand Down Expand Up @@ -263,6 +341,7 @@ export const xmtpPlugin: ChannelPlugin<ResolvedXmtpAccount> = {
agentPrompt: {
messageToolHints: () => [
"- XMTP targets are wallet addresses or conversation topics. Use `to=<address>` for `action=send`.",
"- Use `action=react` with `to=<conversation>`, `messageId=<id>`, and `emoji=<emoji>` to react to messages.",
],
},
directory: {
Expand Down
Loading
Loading