fix(xmtp): show tag in channel selection and make allowFrom optional for pairing
Bug 1: Missing tag in channel list - Changed selectionLabel from 'XMTP' to 'XMTP (Decentralized Messaging)' - Updated both channel.ts and types.xmtp.ts to match package.json Bug 2: allowFrom incorrectly required in pairing mode - Removed forceAllowFrom condition from allowFrom prompt - Now only prompts for allowFrom when dmPolicy === 'allowlist' - Pairing mode correctly allows anyone to message (approved later)
This commit is contained in:
parent
047070a5cf
commit
893d1e60e9
1188
extensions/xmtp/src/channel.ts
Normal file
1188
extensions/xmtp/src/channel.ts
Normal file
File diff suppressed because it is too large
Load Diff
752
extensions/xmtp/src/onboarding.ts
Normal file
752
extensions/xmtp/src/onboarding.ts
Normal file
@ -0,0 +1,752 @@
|
||||
/**
|
||||
* XMTP Onboarding Adapter
|
||||
*
|
||||
* Provides the CLI onboarding wizard for configuring XMTP channel.
|
||||
* Follows the pattern established by Telegram, Discord, and Signal adapters.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
import { createUser } from "@xmtp/agent-sdk/user";
|
||||
|
||||
import {
|
||||
DEFAULT_XMTP_ACCOUNT_ID,
|
||||
listXmtpAccountIds,
|
||||
normalizeXmtpAccountId,
|
||||
resolveDefaultXmtpAccountId,
|
||||
resolveXmtpAccount,
|
||||
} from "./accounts.js";
|
||||
import type { XmtpConfig, XmtpDmPolicy, XmtpEnv } from "./types.xmtp.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Prompter interface (from clawdbot onboarding system).
|
||||
* Matches the @clack/prompts interface used by clawdbot.
|
||||
*/
|
||||
interface Prompter {
|
||||
text: (options: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
initialValue?: string;
|
||||
validate?: (value: string | undefined) => string | undefined;
|
||||
}) => Promise<string | symbol>;
|
||||
confirm: (options: {
|
||||
message: string;
|
||||
initialValue?: boolean;
|
||||
}) => Promise<boolean | symbol>;
|
||||
select: <T>(options: {
|
||||
message: string;
|
||||
options: Array<{ value: T; label: string; hint?: string }>;
|
||||
initialValue?: T;
|
||||
}) => Promise<T | symbol>;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status result from getStatus.
|
||||
*/
|
||||
interface OnboardingStatus {
|
||||
provider: string;
|
||||
configured: boolean;
|
||||
statusLines: string[];
|
||||
selectionHint: string;
|
||||
quickstartScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure result.
|
||||
*/
|
||||
interface ConfigureResult {
|
||||
cfg: Record<string, unknown>;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DM policy adapter interface.
|
||||
*/
|
||||
interface DmPolicyAdapter {
|
||||
label: string;
|
||||
provider: string;
|
||||
policyKey: string;
|
||||
allowFromKey: string;
|
||||
getCurrent: (cfg: Record<string, unknown>) => XmtpDmPolicy;
|
||||
setPolicy: (cfg: Record<string, unknown>, policy: XmtpDmPolicy) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding adapter interface.
|
||||
*/
|
||||
export interface XmtpOnboardingAdapter {
|
||||
provider: string;
|
||||
getStatus: (params: {
|
||||
cfg: Record<string, unknown>;
|
||||
options?: Record<string, unknown>;
|
||||
accountOverrides?: Record<string, string>;
|
||||
}) => Promise<OnboardingStatus>;
|
||||
configure: (params: {
|
||||
cfg: Record<string, unknown>;
|
||||
prompter: Prompter;
|
||||
accountOverrides: Record<string, string>;
|
||||
shouldPromptAccountIds?: boolean;
|
||||
forceAllowFrom?: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
}) => Promise<ConfigureResult>;
|
||||
dmPolicy: DmPolicyAdapter;
|
||||
disable: (cfg: Record<string, unknown>) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wallet Generation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generated wallet result.
|
||||
*/
|
||||
export interface GeneratedWallet {
|
||||
/** Private key (0x-prefixed, 64 hex chars) */
|
||||
privateKey: `0x${string}`;
|
||||
/** Derived Ethereum address */
|
||||
address: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new Ethereum wallet for XMTP bot.
|
||||
*
|
||||
* Uses cryptographically secure random bytes to generate a private key,
|
||||
* then derives the Ethereum address using the XMTP SDK.
|
||||
*
|
||||
* @returns Generated wallet with private key and address
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const wallet = generateWallet();
|
||||
* console.log(`Address: ${wallet.address}`);
|
||||
* console.log(`Key: ${wallet.privateKey}`);
|
||||
* // Address: 0x1234...abcd
|
||||
* // Key: 0x...
|
||||
* ```
|
||||
*/
|
||||
export function generateWallet(): GeneratedWallet {
|
||||
// Generate 32 random bytes for private key
|
||||
const privateKeyBytes = randomBytes(32);
|
||||
const privateKey = `0x${privateKeyBytes.toString("hex")}` as `0x${string}`;
|
||||
|
||||
// Derive address using XMTP SDK
|
||||
const user = createUser(privateKey);
|
||||
const address = user.account.address;
|
||||
|
||||
return { privateKey, address };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive Ethereum address from a private key.
|
||||
*
|
||||
* @param privateKey - 0x-prefixed private key
|
||||
* @returns Ethereum address or null if invalid
|
||||
*/
|
||||
export function deriveAddress(privateKey: string): string | null {
|
||||
if (!privateKey || !privateKey.startsWith("0x") || privateKey.length !== 66) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const user = createUser(privateKey as `0x${string}`);
|
||||
return user.account.address;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a wallet private key format.
|
||||
*
|
||||
* @param key - Potential private key
|
||||
* @returns Error message or undefined if valid
|
||||
*/
|
||||
function validateWalletKey(key: string | undefined): string | undefined {
|
||||
const trimmed = (key ?? "").trim();
|
||||
if (!trimmed) return "Required";
|
||||
if (!trimmed.startsWith("0x")) return "Must start with 0x";
|
||||
if (trimmed.length !== 66) return "Must be 64 hex characters (+ 0x prefix)";
|
||||
if (!/^0x[a-fA-F0-9]{64}$/.test(trimmed)) return "Must be valid hex characters";
|
||||
|
||||
// Try to derive address to verify it's a valid key
|
||||
const address = deriveAddress(trimmed);
|
||||
if (!address) return "Invalid private key format";
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get XMTP config from flexible config object.
|
||||
*/
|
||||
function getXmtpConfig(cfg: Record<string, unknown>): XmtpConfig | undefined {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
return channels?.xmtp as XmtpConfig | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add wildcard to allowFrom list for open policy.
|
||||
*/
|
||||
function addWildcardAllowFrom(existing: string[] | undefined): string[] {
|
||||
const current = existing ?? [];
|
||||
if (current.includes("*")) return current;
|
||||
return ["*", ...current];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for account ID selection.
|
||||
*/
|
||||
async function promptAccountId(params: {
|
||||
cfg: Record<string, unknown>;
|
||||
prompter: Prompter;
|
||||
label: string;
|
||||
currentId: string;
|
||||
listAccountIds: (cfg: Record<string, unknown>) => string[];
|
||||
defaultAccountId: string | null;
|
||||
}): Promise<string> {
|
||||
const { cfg, prompter, label, currentId, listAccountIds, defaultAccountId } = params;
|
||||
const existingIds = listAccountIds(cfg);
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: DEFAULT_XMTP_ACCOUNT_ID,
|
||||
label: `default${currentId === DEFAULT_XMTP_ACCOUNT_ID ? " (current)" : ""}`,
|
||||
hint: "Main account",
|
||||
},
|
||||
...existingIds
|
||||
.filter((id) => id !== DEFAULT_XMTP_ACCOUNT_ID)
|
||||
.map((id) => ({
|
||||
value: id,
|
||||
label: `${id}${id === currentId ? " (current)" : ""}`,
|
||||
hint: id === defaultAccountId ? "default" : undefined,
|
||||
})),
|
||||
{ value: "__new__", label: "Create new account", hint: "Add another XMTP identity" },
|
||||
];
|
||||
|
||||
const selected = await prompter.select({
|
||||
message: `${label} account`,
|
||||
options,
|
||||
initialValue: currentId,
|
||||
});
|
||||
|
||||
if (typeof selected === "symbol") {
|
||||
return currentId; // Cancelled
|
||||
}
|
||||
|
||||
if (selected === "__new__") {
|
||||
const newId = await prompter.text({
|
||||
message: "New account name",
|
||||
placeholder: "work, personal, bot2",
|
||||
validate: (v) => {
|
||||
const trimmed = (v ?? "").trim();
|
||||
if (!trimmed) return "Required";
|
||||
if (trimmed === DEFAULT_XMTP_ACCOUNT_ID) return "Cannot use 'default' as account name";
|
||||
if (existingIds.includes(trimmed)) return "Account already exists";
|
||||
if (!/^[a-z0-9_-]+$/i.test(trimmed)) return "Use only letters, numbers, dashes, underscores";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
return typeof newId === "symbol" ? currentId : String(newId).trim();
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display XMTP setup help note.
|
||||
*/
|
||||
async function noteXmtpSetupHelp(prompter: Prompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"XMTP is a decentralized messaging protocol for Ethereum wallets.",
|
||||
"",
|
||||
"Your bot needs an Ethereum wallet (private key) to send and receive messages.",
|
||||
"You can either:",
|
||||
" 1) Generate a new wallet (recommended for new bots)",
|
||||
" 2) Use an existing private key",
|
||||
"",
|
||||
"⚠️ Keep your private key secure! Anyone with access can control the bot.",
|
||||
"",
|
||||
"Docs: https://xmtp.org/docs",
|
||||
].join("\n"),
|
||||
"XMTP Setup"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for allowFrom addresses.
|
||||
*/
|
||||
async function promptXmtpAllowFrom(params: {
|
||||
cfg: Record<string, unknown>;
|
||||
prompter: Prompter;
|
||||
accountId: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveXmtpAccount({ cfg, accountId });
|
||||
const existingAllowFrom = resolved?.config.allowFrom ?? [];
|
||||
|
||||
const entry = await prompter.text({
|
||||
message: "XMTP allowFrom (Ethereum address)",
|
||||
placeholder: "0x1234...5678",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!/^0x[a-fA-F0-9]{40}$/i.test(raw)) return "Use a valid Ethereum address (0x + 40 hex)";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof entry === "symbol") {
|
||||
return cfg; // Cancelled
|
||||
}
|
||||
|
||||
const normalized = String(entry).trim().toLowerCase();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
|
||||
if (accountId === DEFAULT_XMTP_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg.channels as Record<string, unknown>),
|
||||
xmtp: {
|
||||
...getXmtpConfig(cfg),
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const xmtpCfg = getXmtpConfig(cfg);
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg.channels as Record<string, unknown>),
|
||||
xmtp: {
|
||||
...xmtpCfg,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...xmtpCfg?.accounts,
|
||||
[accountId]: {
|
||||
...xmtpCfg?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DM Policy Helper
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set XMTP DM policy in config.
|
||||
*/
|
||||
function setXmtpDmPolicy(
|
||||
cfg: Record<string, unknown>,
|
||||
dmPolicy: XmtpDmPolicy
|
||||
): Record<string, unknown> {
|
||||
const xmtpCfg = getXmtpConfig(cfg);
|
||||
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(xmtpCfg?.allowFrom) : undefined;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg.channels as Record<string, unknown>),
|
||||
xmtp: {
|
||||
...xmtpCfg,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Onboarding Adapter
|
||||
// ============================================================================
|
||||
|
||||
const provider = "xmtp";
|
||||
|
||||
const dmPolicyAdapter: DmPolicyAdapter = {
|
||||
label: "XMTP",
|
||||
provider,
|
||||
policyKey: "channels.xmtp.dmPolicy",
|
||||
allowFromKey: "channels.xmtp.allowFrom",
|
||||
getCurrent: (cfg) => {
|
||||
const xmtpCfg = getXmtpConfig(cfg);
|
||||
return (xmtpCfg?.dmPolicy ?? "pairing") as XmtpDmPolicy;
|
||||
},
|
||||
setPolicy: (cfg, policy) => setXmtpDmPolicy(cfg, policy),
|
||||
};
|
||||
|
||||
/**
|
||||
* XMTP onboarding adapter for `clawdbot onboard` wizard.
|
||||
*
|
||||
* Provides interactive setup for:
|
||||
* - Wallet key configuration (generate new or use existing)
|
||||
* - Network selection (dev vs production)
|
||||
* - DM policy configuration (pairing, allowlist, open)
|
||||
* - AllowFrom addresses
|
||||
*/
|
||||
export const xmtpOnboardingAdapter: XmtpOnboardingAdapter = {
|
||||
provider,
|
||||
|
||||
/**
|
||||
* Get current XMTP configuration status.
|
||||
*/
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listXmtpAccountIds(cfg).some((accountId) =>
|
||||
Boolean(resolveXmtpAccount({ cfg, accountId })?.configured)
|
||||
);
|
||||
|
||||
// Check for env var as well
|
||||
const hasEnvKey = Boolean(process.env.XMTP_WALLET_KEY?.trim());
|
||||
|
||||
return {
|
||||
provider,
|
||||
configured: configured || hasEnvKey,
|
||||
statusLines: [
|
||||
`XMTP: ${configured || hasEnvKey ? "configured" : "needs wallet key"}`,
|
||||
],
|
||||
selectionHint: configured || hasEnvKey
|
||||
? "configured"
|
||||
: "decentralized · wallet-to-wallet messaging",
|
||||
quickstartScore: configured || hasEnvKey ? 2 : 3,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Run the XMTP configuration wizard.
|
||||
*/
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
forceAllowFrom,
|
||||
}) => {
|
||||
const xmtpOverride = accountOverrides.xmtp?.trim();
|
||||
const defaultXmtpAccountId = resolveDefaultXmtpAccountId(cfg);
|
||||
let xmtpAccountId = xmtpOverride
|
||||
? normalizeXmtpAccountId(xmtpOverride, cfg)
|
||||
: defaultXmtpAccountId ?? DEFAULT_XMTP_ACCOUNT_ID;
|
||||
|
||||
// Prompt for account selection if multiple accounts or --all flag
|
||||
if (shouldPromptAccountIds && !xmtpOverride) {
|
||||
xmtpAccountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "XMTP",
|
||||
currentId: xmtpAccountId,
|
||||
listAccountIds: listXmtpAccountIds,
|
||||
defaultAccountId: defaultXmtpAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveXmtpAccount({ cfg: next, accountId: xmtpAccountId });
|
||||
const accountConfigured = Boolean(resolvedAccount?.configured);
|
||||
const allowEnv = xmtpAccountId === DEFAULT_XMTP_ACCOUNT_ID;
|
||||
const canUseEnv = allowEnv && Boolean(process.env.XMTP_WALLET_KEY?.trim());
|
||||
const hasConfigKey = Boolean(resolvedAccount?.walletKey);
|
||||
|
||||
let walletKey: string | null = null;
|
||||
let walletAddress: string | null = null;
|
||||
|
||||
// Show setup help if not configured
|
||||
if (!accountConfigured) {
|
||||
await noteXmtpSetupHelp(prompter);
|
||||
}
|
||||
|
||||
// Handle wallet key configuration
|
||||
if (canUseEnv && !hasConfigKey) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "XMTP_WALLET_KEY detected in environment. Use env var?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (keepEnv) {
|
||||
// Just enable, key comes from env
|
||||
const derivedAddress = deriveAddress(process.env.XMTP_WALLET_KEY ?? "");
|
||||
if (derivedAddress) {
|
||||
await prompter.note(`Using wallet from environment: ${derivedAddress}`, "XMTP");
|
||||
}
|
||||
} else {
|
||||
// Ask if they want to generate or enter
|
||||
const keySource = await prompter.select({
|
||||
message: "How would you like to configure the wallet?",
|
||||
options: [
|
||||
{ value: "generate", label: "Generate new wallet", hint: "Recommended for new bots" },
|
||||
{ value: "existing", label: "Enter existing private key", hint: "Use your own key" },
|
||||
],
|
||||
});
|
||||
|
||||
if (typeof keySource !== "symbol") {
|
||||
if (keySource === "generate") {
|
||||
const wallet = generateWallet();
|
||||
walletKey = wallet.privateKey;
|
||||
walletAddress = wallet.address;
|
||||
await prompter.note(
|
||||
[
|
||||
`Generated new wallet:`,
|
||||
`Address: ${walletAddress}`,
|
||||
``,
|
||||
`⚠️ IMPORTANT: Save your private key securely!`,
|
||||
`The key will be stored in your clawdbot.json config.`,
|
||||
`Back it up if you need to recover this bot identity.`,
|
||||
].join("\n"),
|
||||
"New Wallet"
|
||||
);
|
||||
} else {
|
||||
walletKey = String(
|
||||
await prompter.text({
|
||||
message: "Enter wallet private key",
|
||||
placeholder: "0x...",
|
||||
validate: validateWalletKey,
|
||||
})
|
||||
).trim();
|
||||
if (walletKey && typeof walletKey !== "symbol") {
|
||||
walletAddress = deriveAddress(walletKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (hasConfigKey) {
|
||||
// Already configured
|
||||
const keep = await prompter.confirm({
|
||||
message: `XMTP wallet configured (${resolvedAccount?.walletAddress ?? "unknown"}). Keep it?`,
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (!keep) {
|
||||
const keySource = await prompter.select({
|
||||
message: "How would you like to configure the wallet?",
|
||||
options: [
|
||||
{ value: "generate", label: "Generate new wallet", hint: "Create fresh identity" },
|
||||
{ value: "existing", label: "Enter existing private key", hint: "Use your own key" },
|
||||
],
|
||||
});
|
||||
|
||||
if (typeof keySource !== "symbol") {
|
||||
if (keySource === "generate") {
|
||||
const wallet = generateWallet();
|
||||
walletKey = wallet.privateKey;
|
||||
walletAddress = wallet.address;
|
||||
await prompter.note(
|
||||
[
|
||||
`Generated new wallet:`,
|
||||
`Address: ${walletAddress}`,
|
||||
``,
|
||||
`⚠️ Your previous wallet will be replaced!`,
|
||||
].join("\n"),
|
||||
"New Wallet"
|
||||
);
|
||||
} else {
|
||||
walletKey = String(
|
||||
await prompter.text({
|
||||
message: "Enter wallet private key",
|
||||
placeholder: "0x...",
|
||||
validate: validateWalletKey,
|
||||
})
|
||||
).trim();
|
||||
if (walletKey && typeof walletKey !== "symbol") {
|
||||
walletAddress = deriveAddress(walletKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No config, no env - need to configure
|
||||
const keySource = await prompter.select({
|
||||
message: "How would you like to configure the wallet?",
|
||||
options: [
|
||||
{ value: "generate", label: "Generate new wallet", hint: "Recommended for new bots" },
|
||||
{ value: "existing", label: "Enter existing private key", hint: "Use your own key" },
|
||||
],
|
||||
});
|
||||
|
||||
if (typeof keySource !== "symbol") {
|
||||
if (keySource === "generate") {
|
||||
const wallet = generateWallet();
|
||||
walletKey = wallet.privateKey;
|
||||
walletAddress = wallet.address;
|
||||
await prompter.note(
|
||||
[
|
||||
`Generated new wallet:`,
|
||||
`Address: ${walletAddress}`,
|
||||
``,
|
||||
`⚠️ IMPORTANT: Save your private key securely!`,
|
||||
`The key will be stored in your clawdbot.json config.`,
|
||||
].join("\n"),
|
||||
"New Wallet"
|
||||
);
|
||||
} else {
|
||||
walletKey = String(
|
||||
await prompter.text({
|
||||
message: "Enter wallet private key",
|
||||
placeholder: "0x...",
|
||||
validate: validateWalletKey,
|
||||
})
|
||||
).trim();
|
||||
if (walletKey && typeof walletKey !== "symbol") {
|
||||
walletAddress = deriveAddress(walletKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt for network selection
|
||||
const currentEnv = resolvedAccount?.env ?? "dev";
|
||||
const envChoice = await prompter.select<XmtpEnv>({
|
||||
message: "XMTP network",
|
||||
options: [
|
||||
{
|
||||
value: "dev" as XmtpEnv,
|
||||
label: "Development",
|
||||
hint: "Free, for testing (recommended to start)",
|
||||
},
|
||||
{
|
||||
value: "production" as XmtpEnv,
|
||||
label: "Production",
|
||||
hint: "Real network, costs apply",
|
||||
},
|
||||
],
|
||||
initialValue: currentEnv as XmtpEnv,
|
||||
});
|
||||
|
||||
const env: XmtpEnv = typeof envChoice === "symbol" ? currentEnv : envChoice;
|
||||
|
||||
// Prompt for DM policy
|
||||
const currentPolicy = resolvedAccount?.config.dmPolicy ?? "pairing";
|
||||
const policyChoice = await prompter.select<XmtpDmPolicy>({
|
||||
message: "DM policy (who can message the bot?)",
|
||||
options: [
|
||||
{
|
||||
value: "pairing" as XmtpDmPolicy,
|
||||
label: "Pairing",
|
||||
hint: "Users request access, you approve (most secure)",
|
||||
},
|
||||
{
|
||||
value: "allowlist" as XmtpDmPolicy,
|
||||
label: "Allowlist",
|
||||
hint: "Only pre-approved addresses",
|
||||
},
|
||||
{
|
||||
value: "open" as XmtpDmPolicy,
|
||||
label: "Open",
|
||||
hint: "Anyone can message (not recommended)",
|
||||
},
|
||||
],
|
||||
initialValue: currentPolicy as XmtpDmPolicy,
|
||||
});
|
||||
|
||||
const dmPolicy: XmtpDmPolicy = typeof policyChoice === "symbol" ? currentPolicy : policyChoice;
|
||||
|
||||
// Build updated config
|
||||
const xmtpCfg = getXmtpConfig(next);
|
||||
|
||||
if (xmtpAccountId === DEFAULT_XMTP_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...(next.channels as Record<string, unknown>),
|
||||
xmtp: {
|
||||
...xmtpCfg,
|
||||
enabled: true,
|
||||
...(walletKey ? { walletKey } : {}),
|
||||
env,
|
||||
dmPolicy,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...(next.channels as Record<string, unknown>),
|
||||
xmtp: {
|
||||
...xmtpCfg,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...xmtpCfg?.accounts,
|
||||
[xmtpAccountId]: {
|
||||
...xmtpCfg?.accounts?.[xmtpAccountId],
|
||||
enabled: true,
|
||||
...(walletKey ? { walletKey } : {}),
|
||||
env,
|
||||
dmPolicy,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Prompt for allowFrom only if allowlist mode (pairing doesn't need it)
|
||||
if (dmPolicy === "allowlist") {
|
||||
next = await promptXmtpAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId: xmtpAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
// Show final config note
|
||||
const finalAddress = walletAddress ?? resolvedAccount?.walletAddress;
|
||||
await prompter.note(
|
||||
[
|
||||
`XMTP configured successfully!`,
|
||||
``,
|
||||
`Wallet: ${finalAddress ?? "(from env)"}`,
|
||||
`Network: ${env}`,
|
||||
`DM Policy: ${dmPolicy}`,
|
||||
``,
|
||||
`Start with: clawdbot gateway start`,
|
||||
``,
|
||||
env === "dev"
|
||||
? "💡 Dev network is free for testing. Switch to production when ready."
|
||||
: "⚠️ Production network has costs. See XMTP pricing for details.",
|
||||
].join("\n"),
|
||||
"XMTP Setup Complete"
|
||||
);
|
||||
|
||||
return { cfg: next, accountId: xmtpAccountId };
|
||||
},
|
||||
|
||||
dmPolicy: dmPolicyAdapter,
|
||||
|
||||
/**
|
||||
* Disable XMTP channel.
|
||||
*/
|
||||
disable: (cfg) => {
|
||||
const xmtpCfg = getXmtpConfig(cfg);
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg.channels as Record<string, unknown>),
|
||||
xmtp: {
|
||||
...xmtpCfg,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
473
extensions/xmtp/src/types.xmtp.ts
Normal file
473
extensions/xmtp/src/types.xmtp.ts
Normal file
@ -0,0 +1,473 @@
|
||||
/**
|
||||
* XMTP Channel Type Definitions
|
||||
*
|
||||
* Type definitions for the XMTP channel plugin, following the patterns
|
||||
* established by Telegram/Discord channels in Clawdbot core.
|
||||
*/
|
||||
|
||||
// Re-export common types from clawdbot for convenience
|
||||
// (These would be imported in an integrated extension)
|
||||
export type { ClawdbotRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
// ============================================================================
|
||||
// Base Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* XMTP network environment.
|
||||
* - "dev": Development/testnet network (free, for testing)
|
||||
* - "production": Production mainnet (real XMTP network)
|
||||
*/
|
||||
export type XmtpEnv = "dev" | "production";
|
||||
|
||||
/**
|
||||
* DM policy for XMTP channel.
|
||||
* Controls how direct messages from unknown senders are handled.
|
||||
*
|
||||
* - "pairing": Unknown senders get a pairing code; owner must approve
|
||||
* - "allowlist": Only allow senders explicitly in allowFrom
|
||||
* - "open": Allow all inbound DMs (but only allowlisted users can run commands)
|
||||
*/
|
||||
export type XmtpDmPolicy = "pairing" | "allowlist" | "open";
|
||||
|
||||
/**
|
||||
* Markdown table rendering mode.
|
||||
* - "off": Leave tables as-is (may not render well in XMTP clients)
|
||||
* - "bullets": Convert tables to bullet lists (recommended for chat)
|
||||
* - "code": Wrap tables in code blocks
|
||||
*/
|
||||
export type XmtpMarkdownTableMode = "off" | "bullets" | "code";
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Markdown formatting configuration for XMTP messages.
|
||||
*/
|
||||
export type XmtpMarkdownConfig = {
|
||||
/** Table rendering mode (off|bullets|code). Default: "bullets" */
|
||||
tables?: XmtpMarkdownTableMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-action tool gating for XMTP channel.
|
||||
* Controls which actions the bot can perform.
|
||||
*/
|
||||
export type XmtpActionConfig = {
|
||||
/** Enable/disable reaction support. Default: true */
|
||||
reactions?: boolean;
|
||||
/** Enable/disable sending messages. Default: true */
|
||||
sendMessage?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Outbound retry configuration for XMTP API calls.
|
||||
*/
|
||||
export type XmtpRetryConfig = {
|
||||
/** Max retry attempts for outbound requests. Default: 3 */
|
||||
attempts?: number;
|
||||
/** Minimum retry delay in ms. Default: 500 */
|
||||
minDelayMs?: number;
|
||||
/** Maximum retry delay cap in ms. Default: 30000 */
|
||||
maxDelayMs?: number;
|
||||
/** Jitter factor (0-1) applied to delays. Default: 0.1 */
|
||||
jitter?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-account XMTP configuration.
|
||||
* Defines all settings for a single XMTP bot account.
|
||||
*/
|
||||
export type XmtpAccountConfig = {
|
||||
/**
|
||||
* Optional display name for this account.
|
||||
* Used in CLI/UI lists and status displays.
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* If false, do not start this XMTP account.
|
||||
* Default: true (when walletKey is configured)
|
||||
*/
|
||||
enabled?: boolean;
|
||||
|
||||
/**
|
||||
* Ethereum wallet private key for XMTP identity.
|
||||
* Must be a 0x-prefixed 64-character hex string.
|
||||
* Can also be set via XMTP_WALLET_KEY environment variable.
|
||||
*/
|
||||
walletKey?: string;
|
||||
|
||||
/**
|
||||
* XMTP network environment.
|
||||
* - "dev": Development network (free, for testing)
|
||||
* - "production": Production mainnet
|
||||
* Default: "dev"
|
||||
*/
|
||||
env?: XmtpEnv;
|
||||
|
||||
/**
|
||||
* Path to XMTP database directory.
|
||||
* The actual database file will be created at {dbPath}/{env}/xmtp-{inboxId}.db3
|
||||
* Default: ".xmtp/db"
|
||||
*/
|
||||
dbPath?: string;
|
||||
|
||||
/**
|
||||
* Optional encryption key for XMTP database.
|
||||
* Must be a 0x-prefixed hex string if provided.
|
||||
* Can also be set via XMTP_DB_ENCRYPTION_KEY environment variable.
|
||||
*/
|
||||
encryptionKey?: string;
|
||||
|
||||
/**
|
||||
* Controls how direct messages from unknown senders are handled.
|
||||
* - "pairing": Unknown senders get a pairing code; owner must approve
|
||||
* - "allowlist": Only allow senders explicitly in allowFrom
|
||||
* - "open": Allow all inbound DMs
|
||||
* Default: "pairing"
|
||||
*/
|
||||
dmPolicy?: XmtpDmPolicy;
|
||||
|
||||
/**
|
||||
* List of authorized Ethereum addresses.
|
||||
* Used for allowlist/pairing policies.
|
||||
* Addresses should be lowercase 0x-prefixed.
|
||||
*/
|
||||
allowFrom?: string[];
|
||||
|
||||
/**
|
||||
* Markdown formatting configuration.
|
||||
*/
|
||||
markdown?: XmtpMarkdownConfig;
|
||||
|
||||
/**
|
||||
* Outbound text chunk size (chars).
|
||||
* Messages longer than this will be split into multiple sends.
|
||||
* Default: 4000
|
||||
*/
|
||||
textChunkLimit?: number;
|
||||
|
||||
/**
|
||||
* Chunking mode for long messages.
|
||||
* - "length": Split by character count (default)
|
||||
* - "newline": Split on every newline
|
||||
*/
|
||||
chunkMode?: "length" | "newline";
|
||||
|
||||
/**
|
||||
* Per-action tool gating.
|
||||
*/
|
||||
actions?: XmtpActionConfig;
|
||||
|
||||
/**
|
||||
* Retry policy for outbound XMTP API calls.
|
||||
*/
|
||||
retry?: XmtpRetryConfig;
|
||||
|
||||
/**
|
||||
* Controls which user reactions trigger notifications.
|
||||
* - "off": Ignore all reactions (default)
|
||||
* - "own": Notify when users react to bot messages
|
||||
* - "all": Notify agent of all reactions
|
||||
*/
|
||||
reactionNotifications?: "off" | "own" | "all";
|
||||
|
||||
/**
|
||||
* Controls agent's reaction capability.
|
||||
* - "off": Agent cannot react
|
||||
* - "ack": Bot sends acknowledgment reactions (default)
|
||||
* - "minimal": Agent can react sparingly
|
||||
* - "extensive": Agent can react liberally
|
||||
*/
|
||||
reactionLevel?: "off" | "ack" | "minimal" | "extensive";
|
||||
};
|
||||
|
||||
/**
|
||||
* Top-level XMTP channel configuration.
|
||||
* Supports both single-account and multi-account setups.
|
||||
*
|
||||
* Single account:
|
||||
* ```json
|
||||
* { "channels": { "xmtp": { "walletKey": "0x...", "env": "dev" } } }
|
||||
* ```
|
||||
*
|
||||
* Multi-account:
|
||||
* ```json
|
||||
* { "channels": { "xmtp": { "accounts": { "main": { "walletKey": "0x..." } } } } }
|
||||
* ```
|
||||
*/
|
||||
export type XmtpConfig = {
|
||||
/** Optional per-account XMTP configuration (multi-account). */
|
||||
accounts?: Record<string, XmtpAccountConfig>;
|
||||
} & XmtpAccountConfig;
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Resolved XMTP account state.
|
||||
* This is the fully-resolved configuration for a single account,
|
||||
* with defaults applied and derived values computed.
|
||||
*/
|
||||
export interface ResolvedXmtpAccount {
|
||||
/** Account identifier (e.g., "default", "main", etc.) */
|
||||
accountId: string;
|
||||
/** Display name for this account */
|
||||
name: string | null;
|
||||
/** Whether this account is enabled */
|
||||
enabled: boolean;
|
||||
/** Whether wallet key is configured */
|
||||
configured: boolean;
|
||||
/** Wallet private key (if configured) */
|
||||
walletKey: string | null;
|
||||
/** Derived Ethereum address from wallet key */
|
||||
walletAddress: string | null;
|
||||
/** XMTP network environment */
|
||||
env: XmtpEnv;
|
||||
/** Path to XMTP database directory */
|
||||
dbPath: string;
|
||||
/** Database encryption key (if configured) */
|
||||
encryptionKey: string | null;
|
||||
/** Resolved config values */
|
||||
config: {
|
||||
/** DM handling policy */
|
||||
dmPolicy: XmtpDmPolicy;
|
||||
/** Authorized addresses */
|
||||
allowFrom: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime state for an XMTP account.
|
||||
* Tracks the operational status of the XMTP gateway.
|
||||
* Extends Record<string, unknown> for clawdbot SDK compatibility.
|
||||
*/
|
||||
export interface XmtpRuntimeState extends Record<string, unknown> {
|
||||
/** Account identifier */
|
||||
accountId: string;
|
||||
/** Whether the gateway is currently running */
|
||||
running: boolean;
|
||||
/** Timestamp of last gateway start */
|
||||
lastStartAt: number | null;
|
||||
/** Timestamp of last gateway stop */
|
||||
lastStopAt: number | null;
|
||||
/** Last error message (if any) */
|
||||
lastError: string | null;
|
||||
/** Timestamp of last inbound message received */
|
||||
lastInboundAt?: number | null;
|
||||
/** Timestamp of last outbound message sent */
|
||||
lastOutboundAt?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status snapshot for an XMTP account.
|
||||
* Used for CLI status displays and health checks.
|
||||
* Extends Record<string, unknown> for clawdbot SDK compatibility.
|
||||
*/
|
||||
export interface XmtpAccountSnapshot extends Record<string, unknown> {
|
||||
/** Account identifier */
|
||||
accountId: string;
|
||||
/** Display name */
|
||||
name: string | null;
|
||||
/** Whether account is enabled in config */
|
||||
enabled: boolean;
|
||||
/** Whether wallet key is configured */
|
||||
configured: boolean;
|
||||
/** Ethereum wallet address */
|
||||
walletAddress: string | null;
|
||||
/** Network environment */
|
||||
env: XmtpEnv;
|
||||
/** Whether gateway is currently running */
|
||||
running: boolean;
|
||||
/** Timestamp of last gateway start */
|
||||
lastStartAt: number | null;
|
||||
/** Timestamp of last gateway stop */
|
||||
lastStopAt: number | null;
|
||||
/** Last error message */
|
||||
lastError: string | null;
|
||||
/** Timestamp of last inbound message */
|
||||
lastInboundAt?: number | null;
|
||||
/** Timestamp of last outbound message */
|
||||
lastOutboundAt?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel-level status summary for XMTP.
|
||||
* Aggregated view across all accounts.
|
||||
* Extends Record<string, unknown> for clawdbot SDK compatibility.
|
||||
*/
|
||||
export interface XmtpChannelSummary extends Record<string, unknown> {
|
||||
/** Whether any account is configured */
|
||||
configured: boolean;
|
||||
/** Primary wallet address */
|
||||
walletAddress: string | null;
|
||||
/** Network environment */
|
||||
env: XmtpEnv;
|
||||
/** Whether any account is running */
|
||||
running: boolean;
|
||||
/** Timestamp of most recent start */
|
||||
lastStartAt: number | null;
|
||||
/** Timestamp of most recent stop */
|
||||
lastStopAt: number | null;
|
||||
/** Most recent error */
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Inbound message context for XMTP.
|
||||
* Contains all information about an incoming message.
|
||||
*/
|
||||
export interface XmtpInboundContext {
|
||||
/** Message body text */
|
||||
Body: string;
|
||||
/** Raw message body (unprocessed) */
|
||||
RawBody: string;
|
||||
/** Sender address with xmtp: prefix */
|
||||
From: string;
|
||||
/** Conversation ID with xmtp: prefix */
|
||||
To: string;
|
||||
/** Session key for conversation tracking */
|
||||
SessionKey: string;
|
||||
/** Account ID handling this message */
|
||||
AccountId: string;
|
||||
/** Chat type: "direct" or "group" */
|
||||
ChatType: "direct" | "group";
|
||||
/** Human-readable label for conversation */
|
||||
ConversationLabel: string;
|
||||
/** Sender display name (usually address) */
|
||||
SenderName: string;
|
||||
/** Sender identifier */
|
||||
SenderId: string;
|
||||
/** Provider name */
|
||||
Provider: "xmtp";
|
||||
/** Surface/platform */
|
||||
Surface: "xmtp";
|
||||
/** Unique message ID */
|
||||
MessageSid: string;
|
||||
/** Conversation ID */
|
||||
ConversationId: string;
|
||||
/** Sender's XMTP inbox ID */
|
||||
SenderInboxId: string;
|
||||
/** Message timestamp */
|
||||
Timestamp: number;
|
||||
/** Whether sender can execute commands */
|
||||
CommandAuthorized: boolean;
|
||||
/** Original channel name */
|
||||
OriginatingChannel: "xmtp";
|
||||
/** Original conversation target */
|
||||
OriginatingTo: string;
|
||||
/** Reply-to message ID (if this is a reply) */
|
||||
ReplyToId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization check result for an XMTP sender.
|
||||
*/
|
||||
export interface XmtpAuthorizationResult {
|
||||
/** Whether the sender is allowed to message */
|
||||
allowed: boolean;
|
||||
/** Whether the sender can execute commands */
|
||||
commandAuthorized: boolean;
|
||||
/** Reason for denial (if not allowed) */
|
||||
reason?: "needs_pairing" | "not_in_allowlist" | "unknown_policy";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Directory Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Directory entry for XMTP peer (user).
|
||||
*/
|
||||
export interface XmtpPeerEntry {
|
||||
kind: "user";
|
||||
/** Ethereum address or inbox ID */
|
||||
id: string;
|
||||
/** Display name (usually same as address) */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory entry for XMTP group.
|
||||
*/
|
||||
export interface XmtpGroupEntry {
|
||||
kind: "group";
|
||||
/** Group conversation ID */
|
||||
id: string;
|
||||
/** Group name */
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Action Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result from an XMTP action (e.g., react).
|
||||
*/
|
||||
export interface XmtpActionResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the react action.
|
||||
* All fields are optional because params come from untyped input.
|
||||
*/
|
||||
export interface XmtpReactParams {
|
||||
/** Conversation ID (or "to" as alias) */
|
||||
conversationId?: string;
|
||||
/** Alias for conversationId */
|
||||
to?: string;
|
||||
/** Message ID to react to */
|
||||
messageId?: string;
|
||||
/** Emoji to react with. Default: "👍" */
|
||||
emoji?: string;
|
||||
/** Whether to remove the reaction. Default: false */
|
||||
remove?: boolean;
|
||||
/** Sender inbox ID (required for group messages) */
|
||||
senderInboxId?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Status issue from XMTP channel.
|
||||
*/
|
||||
export interface XmtpStatusIssue {
|
||||
channel: "xmtp";
|
||||
accountId: string;
|
||||
kind: "runtime";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* XMTP plugin metadata.
|
||||
*/
|
||||
export interface XmtpPluginMeta {
|
||||
id: "xmtp";
|
||||
label: "XMTP";
|
||||
selectionLabel: "XMTP (Decentralized Messaging)";
|
||||
docsPath: "/channels/xmtp";
|
||||
docsLabel: "xmtp";
|
||||
blurb: "Decentralized messaging via XMTP protocol";
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* XMTP plugin capabilities.
|
||||
*/
|
||||
export interface XmtpPluginCapabilities {
|
||||
chatTypes: Array<"direct" | "group">;
|
||||
reactions: boolean;
|
||||
media: boolean;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user