diff --git a/extensions/xmtp/src/channel.ts b/extensions/xmtp/src/channel.ts new file mode 100644 index 000000000..ace9b7d6c --- /dev/null +++ b/extensions/xmtp/src/channel.ts @@ -0,0 +1,1188 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { Agent } from "@xmtp/agent-sdk"; +import { createSigner, createUser } from "@xmtp/agent-sdk/user"; +import { ContentTypeReaction, type Reaction } from "@xmtp/content-type-reaction"; +import { ContentTypeReply, type Reply } from "@xmtp/content-type-reply"; +import { ContentTypeText } from "@xmtp/content-type-text"; +import type { ChannelPlugin } from "clawdbot/plugin-sdk"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + formatPairingApproveHint, +} from "clawdbot/plugin-sdk"; + +import { + DEFAULT_XMTP_ACCOUNT_ID, + getAccountIdByAddress, + isXmtpAccountEnabled, + listXmtpAccountIds, + normalizeXmtpAccountId, + resolveDefaultXmtpAccountId, + resolveXmtpAccount, + validateMultiAccountConfig, +} from "./accounts.js"; +import { XmtpSendError } from "./errors.js"; +import { xmtpOnboardingAdapter } from "./onboarding.js"; +import { createLogger } from "./logger.js"; +import { getXmtpRuntime } from "./runtime.js"; +import { XmtpChannelConfigSchema } from "./schemas.xmtp.js"; +import type { + ResolvedXmtpAccount, + XmtpAccountSnapshot, + XmtpActionResult, + XmtpAuthorizationResult, + XmtpChannelSummary, + XmtpConfig, + XmtpDmPolicy, + XmtpEnv, + XmtpInboundContext, + XmtpPluginCapabilities, + XmtpPluginMeta, + XmtpReactParams, + XmtpRuntimeState, + XmtpStatusIssue, +} from "./types.xmtp.js"; + +// Store active agent handles per account +const activeAgents = new Map(); + +// Re-export for external consumers +export { + DEFAULT_XMTP_ACCOUNT_ID, + getAccountIdByAddress, + isXmtpAccountEnabled, + listXmtpAccountIds, + normalizeXmtpAccountId, + resolveDefaultXmtpAccountId, + resolveXmtpAccount, + validateMultiAccountConfig, +}; + +/** + * Detect if an error is due to corrupted session history (orphaned tool_result blocks). + * This happens when the conversation history has tool_result messages without matching + * tool_use messages in the previous assistant turn. + */ +function isCorruptedSessionError(error: unknown): boolean { + const message = String(error); + // Match errors like: "unexpected 'tool_use_id' found in 'tool_result' blocks" + // or "each tool_result block must have a corresponding tool_use block" + return ( + message.includes("tool_result") && + (message.includes("tool_use") || message.includes("tool_use_id")) && + (message.includes("unexpected") || message.includes("corresponding") || message.includes("must have")) + ); +} + +/** + * Clear a corrupted session by deleting the session file and resetting the session entry. + * Returns true if the session was successfully cleared. + */ +async function clearCorruptedSession(params: { + sessionKey: string; + log?: { info: (msg: string) => void; warn: (msg: string) => void }; +}): Promise { + const { sessionKey, log } = params; + + try { + // @ts-ignore - accessing internal clawdbot modules + const clawdbotPath = "/opt/homebrew/lib/node_modules/clawdbot/dist"; + const { loadSessionStore, updateSessionStore } = await import(`${clawdbotPath}/config/sessions/store.js`); + const { resolveStorePath } = await import(`${clawdbotPath}/config/sessions.js`); + const { loadConfig } = await import(`${clawdbotPath}/config/config.js`); + + const cfg = loadConfig(); + const storePath = resolveStorePath(cfg.session?.store, {}); + const store = loadSessionStore(storePath); + const sessionEntry = store[sessionKey]; + + if (!sessionEntry) { + log?.warn(`Session not found for key: ${sessionKey}`); + return false; + } + + // Delete the session transcript file if it exists + const sessionFile = sessionEntry.sessionFile; + if (sessionFile && fs.existsSync(sessionFile)) { + fs.unlinkSync(sessionFile); + log?.info(`Deleted corrupted session file: ${sessionFile}`); + } + + // Reset the session entry with a new session ID + await updateSessionStore(storePath, (s: Record) => { + const entry = s[sessionKey]; + if (entry) { + entry.sessionId = crypto.randomUUID(); + entry.sessionFile = undefined; // Will be regenerated + entry.updatedAt = Date.now(); + entry.systemSent = false; + entry.compactionCount = 0; + entry.abortedLastRun = false; + } + }); + + log?.info(`Reset session entry for key: ${sessionKey}`); + return true; + } catch (err) { + log?.warn(`Failed to clear corrupted session: ${String(err)}`); + return false; + } +} + +// Re-export types for consumers +export type { ResolvedXmtpAccount } from "./types.xmtp.js"; + +/** + * Plugin metadata for XMTP channel. + */ +const xmtpMeta: XmtpPluginMeta = { + id: "xmtp", + label: "XMTP", + selectionLabel: "XMTP (Decentralized Messaging)", + docsPath: "/channels/xmtp", + docsLabel: "xmtp", + blurb: "Decentralized messaging via XMTP protocol", + order: 110, +}; + +/** + * Plugin capabilities for XMTP channel. + */ +const xmtpCapabilities: XmtpPluginCapabilities = { + chatTypes: ["direct", "group"], + reactions: true, + media: true, +}; + +// Cast to any to allow additional plugin properties (actions, directory) +// not yet typed in ChannelPlugin interface +export const xmtpPlugin: ChannelPlugin & Record = { + id: "xmtp", + meta: { + ...xmtpMeta, + quickstartAllowFrom: true, + }, + capabilities: xmtpCapabilities, + reload: { configPrefixes: ["channels.xmtp"] }, + configSchema: buildChannelConfigSchema(XmtpChannelConfigSchema), + onboarding: xmtpOnboardingAdapter, + + config: { + listAccountIds: (cfg) => listXmtpAccountIds(cfg), + resolveAccount: (cfg, accountId) => { + const account = resolveXmtpAccount({ cfg, accountId }); + // Return null-safe account (plugin SDK expects non-null or throw) + if (!account) { + // Return a default unconfigured account + return { + accountId: accountId ?? DEFAULT_ACCOUNT_ID, + name: null, + enabled: false, + configured: false, + walletKey: null, + walletAddress: null, + env: "dev" as XmtpEnv, + dbPath: ".xmtp/db", + encryptionKey: null, + config: { + dmPolicy: "pairing" as XmtpDmPolicy, + allowFrom: [], + }, + }; + } + return account; + }, + defaultAccountId: (cfg) => resolveDefaultXmtpAccountId(cfg) ?? DEFAULT_ACCOUNT_ID, + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + walletAddress: account.walletAddress, + env: account.env, + }), + resolveAllowFrom: ({ cfg, accountId }) => { + const account = resolveXmtpAccount({ cfg, accountId }); + return (account?.config.allowFrom ?? []).map((entry) => String(entry)); + }, + formatAllowFrom: ({ allowFrom }) => + (allowFrom ?? []) + .map((entry) => String(entry).trim().toLowerCase()) + .filter(Boolean), + }, + + pairing: { + idLabel: "xmtpAddress", + normalizeAllowEntry: (entry) => entry.toLowerCase().replace(/^xmtp:/i, ""), + notifyApproval: async (params) => { + const { id } = params; + // Use first available running agent for notifications + // (pairing notifications don't have account context in current SDK) + let agent: Agent | undefined; + for (const [, runningAgent] of activeAgents) { + agent = runningAgent; + break; + } + + if (agent) { + const dm = await agent.createDmWithAddress(id as `0x${string}`); + await dm.send("Your pairing request has been approved!"); + } + }, + }, + + security: { + resolveDmPolicy: ({ account }) => ({ + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: "channels.xmtp.dmPolicy", + allowFromPath: "channels.xmtp.allowFrom", + approveHint: formatPairingApproveHint("xmtp"), + normalizeEntry: (raw) => raw.toLowerCase().replace(/^xmtp:/i, "").trim(), + }), + }, + + messaging: { + normalizeTarget: (target) => target.toLowerCase().replace(/^xmtp:/i, ""), + targetResolver: { + looksLikeId: (input) => { + // Strip xmtp: prefix if present before checking + const trimmed = input.trim().replace(/^xmtp:/i, ""); + // Accept ethereum addresses (0x + 40 hex) or conversation IDs (32+ hex chars) + return /^0x[a-fA-F0-9]{40}$/i.test(trimmed) || /^[a-fA-F0-9]{32,}$/i.test(trimmed); + }, + hint: "<0x... ethereum address or conversation id>", + }, + }, + + outbound: { + deliveryMode: "direct", + textChunkLimit: 4000, + sendText: async (params: { to: string; text: string; accountId?: string; replyToId?: string }) => { + const { to, text, accountId, replyToId } = params; + const runtime = getXmtpRuntime(); + const aid = accountId ?? DEFAULT_XMTP_ACCOUNT_ID; + const agent = activeAgents.get(aid); + if (!agent) { + throw new Error(`XMTP agent not running for account ${aid}`); + } + const tableMode = runtime.channel.text.resolveMarkdownTableMode({ + cfg: runtime.config.loadConfig(), + channel: "xmtp", + accountId: aid, + }); + const message = runtime.channel.text.convertMarkdownTables( + text ?? "", + tableMode + ); + + // Resolve ENS names to addresses + let targetAddress = to.toLowerCase(); + + // Import ENS utilities dynamically + const { isEnsName, resolveEnsName } = await import("./ens.js"); + + if (isEnsName(targetAddress)) { + const resolved = await resolveEnsName(targetAddress); + if (!resolved) { + throw new XmtpSendError({ + message: `Failed to resolve ENS name: ${targetAddress}. Check that the ENS name is registered and has an address set. Try using the raw Ethereum address instead.`, + code: "PROTOCOL_SEND_FAILED", + retryable: false, + context: { + ensName: targetAddress, + } + }); + } + targetAddress = resolved.toLowerCase(); + } + + const normalizedTo = targetAddress as `0x${string}`; + const dm = await agent.createDmWithAddress(normalizedTo); + + // Send as reply if replyToId is provided + if (replyToId) { + const replyContent: Reply = { + reference: replyToId, + content: message, + contentType: ContentTypeText, + }; + await dm.send(replyContent, ContentTypeReply); + } else { + await dm.send(message); + } + + return { channel: "xmtp", to: normalizedTo }; + }, + }, + + // Actions implementation - reactions and ENS resolution support + actions: { + /** List available actions for XMTP channel */ + listActions: (): string[] => ["react", "ens"], + + /** Handle an action request */ + handleAction: async (args: { + action: string; + params: Record; + accountId?: string; + }): Promise => { + const { action, params, accountId } = args; + const aid = accountId ?? DEFAULT_XMTP_ACCOUNT_ID; + const agent = activeAgents.get(aid); + if (!agent) { + throw new Error(`XMTP agent not running for account ${aid}`); + } + + /** + * Format tool results in the expected clawdbot format. + */ + const jsonResult = (payload: Record): XmtpActionResult => ({ + content: [ + { + type: "text", + text: JSON.stringify(payload, null, 2), + }, + ], + details: payload, + }); + + if (action === "react") { + // Log all incoming params for debugging + console.log(`[XMTP:REACT] handleAction 'react' called with params: ${JSON.stringify(params, null, 2)}`); + console.log(`[XMTP:REACT] Full args object: action=${action}, accountId=${accountId}, aid=${aid}`); + + // Parse params with type safety + const reactParams = params as XmtpReactParams; + + // Validate required params - normalize by stripping xmtp: prefix if present + const rawConversationId = reactParams.conversationId || reactParams.to; + const conversationId = rawConversationId?.replace(/^xmtp:/i, ""); + const messageId = reactParams.messageId; + const emoji = reactParams.emoji || "๐Ÿ‘"; + const remove = reactParams.remove === true; + // senderInboxId is required for group messages to identify whose message is being reacted to + const senderInboxId = reactParams.senderInboxId; + + console.log(`[XMTP:REACT] Resolved params: rawConversationId=${rawConversationId}, conversationId=${conversationId}, messageId=${messageId}, emoji=${emoji}, remove=${remove}, senderInboxId=${senderInboxId}`); + + if (!conversationId) { + console.error(`[XMTP:REACT] Missing conversationId. params.conversationId=${params?.conversationId}, params.to=${params?.to}`); + throw new Error("Missing required param: conversationId or to"); + } + if (!messageId) { + console.error(`[XMTP:REACT] Missing messageId. params.messageId=${params?.messageId}`); + throw new Error("Missing required param: messageId"); + } + + // Get conversation context + console.log(`[XMTP:REACT] Looking up conversation: ${conversationId}`); + const conversationCtx = await agent.getConversationContext(conversationId); + console.log(`[XMTP:REACT] Conversation lookup result: ${conversationCtx ? 'found' : 'NOT FOUND'}`); + if (conversationCtx) { + console.log(`[XMTP:REACT] Conversation details: id=${conversationCtx.conversation?.id}, type=${typeof conversationCtx.conversation}`); + } + if (!conversationCtx) { + console.error(`[XMTP:REACT] Conversation not found: ${conversationId}`); + throw new Error(`Conversation not found: ${conversationId}`); + } + + // Build reaction payload + const reaction: Reaction = { + reference: messageId, + action: remove ? "removed" : "added", + content: emoji, + schema: "unicode", + // Required for group messages - identifies the sender of the message being reacted to + ...(senderInboxId && { referenceInboxId: senderInboxId }), + }; + + console.log(`[XMTP:REACT] Sending reaction payload: ${JSON.stringify(reaction)}`); + console.log(`[XMTP:REACT] ContentTypeReaction: ${JSON.stringify(ContentTypeReaction)}`); + + // Send reaction through the conversation + try { + await conversationCtx.conversation.send(reaction, ContentTypeReaction); + console.log(`[XMTP:REACT] Reaction sent successfully: ${emoji} on message ${messageId}`); + } catch (sendError) { + console.error(`[XMTP:REACT] Failed to send reaction: ${String(sendError)}`); + console.error(`[XMTP:REACT] Send error stack: ${(sendError as Error)?.stack}`); + throw sendError; + } + + // Return result in the expected clawdbot jsonResult format + return jsonResult({ + ok: true, + action: reaction.action, + emoji, + messageId, + }); + } + + if (action === "ens") { + // Import ENS utilities + const { resolveEnsName, isEnsName } = await import("./ens.js"); + + const ensName = params.name as string; + if (!ensName) { + throw new Error("Missing required param: name"); + } + + if (!isEnsName(ensName)) { + return jsonResult({ + ok: false, + error: "invalid_ens_name", + message: `Not a valid ENS name: ${ensName}`, + ensName, + }); + } + + const address = await resolveEnsName(ensName); + + if (!address) { + return jsonResult({ + ok: false, + error: "resolution_failed", + message: `Failed to resolve ENS name: ${ensName}`, + ensName, + }); + } + + return jsonResult({ + ok: true, + ensName, + address, + }); + } + + throw new Error(`Unknown action: ${action}`); + }, + }, + + // Directory implementation - list peers and groups + directory: { + self: async (args: { cfg: unknown; accountId?: string }) => { + const { cfg, accountId } = args; + const account = resolveXmtpAccount({ cfg: cfg as Record, accountId }); + return { + kind: "user" as const, + id: account?.walletAddress ?? "", + name: account?.name || account?.walletAddress || "Unknown", + }; + }, + + listPeers: async (args: { accountId?: string; query?: string; limit?: number }) => { + const { accountId, query, limit } = args; + const aid = accountId ?? DEFAULT_XMTP_ACCOUNT_ID; + const agent = activeAgents.get(aid); + if (!agent) { + return []; + } + + try { + // Sync conversations first to get the latest + await agent.client.conversations.sync(); + + // List all DM conversations + const dms = agent.client.conversations.listDms(); + + // Map DMs to peer entries + const peers = await Promise.all( + dms.map(async (dm) => { + // Get peer inbox ID and try to get their address + const peerInboxId = dm.peerInboxId; + // Try to get members to find the peer address + let peerAddress = peerInboxId; // fallback to inbox ID + try { + const members = await dm.members(); + const peerMember = members.find( + (m) => m.inboxId !== agent.client.inboxId + ); + // GroupMember has identifiers array, not addresses + if (peerMember && (peerMember as any).identifiers?.[0]?.identifier) { + peerAddress = (peerMember as any).identifiers[0].identifier; + } + } catch { + // Ignore member lookup errors + } + + return { + kind: "user" as const, + id: peerAddress, + name: peerAddress, + }; + }) + ); + + // Apply query filter if provided + let result = peers; + if (query) { + const q = query.toLowerCase(); + result = peers.filter( + (p) => p.id.toLowerCase().includes(q) || p.name.toLowerCase().includes(q) + ); + } + + // Apply limit + return result.slice(0, limit || 50); + } catch { + // Ignore errors, return empty list + return []; + } + }, + + listGroups: async (args: { accountId?: string; query?: string; limit?: number }) => { + const { accountId, query, limit } = args; + const aid = accountId ?? DEFAULT_XMTP_ACCOUNT_ID; + const agent = activeAgents.get(aid); + if (!agent) { + return []; + } + + try { + // Sync conversations first to get the latest + await agent.client.conversations.sync(); + + // List all group conversations + const groups = agent.client.conversations.listGroups(); + + // Map groups to directory entries + const groupEntries = groups.map((group) => ({ + kind: "group" as const, + id: group.id, + name: group.name || group.id, + })); + + // Apply query filter if provided + let result = groupEntries; + if (query) { + const q = query.toLowerCase(); + result = groupEntries.filter( + (g) => g.id.toLowerCase().includes(q) || g.name.toLowerCase().includes(q) + ); + } + + // Apply limit + return result.slice(0, limit || 50); + } catch { + // Ignore errors, return empty list + return []; + } + }, + }, + + status: { + /** Default runtime state for new accounts */ + defaultRuntime: { + accountId: DEFAULT_XMTP_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + + /** Collect status issues from accounts for alerting */ + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + const lastError = + typeof account.lastError === "string" ? account.lastError.trim() : ""; + if (!lastError) return []; + return [ + { + channel: "xmtp" as const, + accountId: String(account.accountId), + kind: "runtime" as const, + message: `Channel error: ${lastError}`, + }, + ]; + }), + + /** Build channel-level status summary */ + buildChannelSummary: ({ snapshot }) => ({ + configured: Boolean(snapshot.configured), + walletAddress: (snapshot.walletAddress as string) ?? null, + env: ((snapshot.env as string) ?? "dev") as XmtpEnv, + running: Boolean(snapshot.running), + lastStartAt: (snapshot.lastStartAt as number) ?? null, + lastStopAt: (snapshot.lastStopAt as number) ?? null, + lastError: (snapshot.lastError as string) ?? null, + }), + + /** Build per-account status snapshot */ + buildAccountSnapshot: ({ account, runtime }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + walletAddress: account.walletAddress, + env: account.env, + running: Boolean(runtime?.running), + lastStartAt: (runtime?.lastStartAt as number) ?? null, + lastStopAt: (runtime?.lastStopAt as number) ?? null, + lastError: (runtime?.lastError as string) ?? null, + lastInboundAt: (runtime?.lastInboundAt as number) ?? null, + lastOutboundAt: (runtime?.lastOutboundAt as number) ?? null, + }), + }, + + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + walletAddress: account.walletAddress, + env: account.env, + }); + ctx.log?.info( + `[${account.accountId}] starting XMTP provider (address: ${account.walletAddress}, env: ${account.env})` + ); + + if (!account.configured || !account.walletKey) { + throw new Error( + "XMTP wallet key not configured. Set XMTP_WALLET_KEY or configure in clawdbot.json" + ); + } + + const runtime = getXmtpRuntime(); + + // Create signer from wallet key (must be 0x-prefixed hex string) + const walletKey = account.walletKey as `0x${string}`; + const user = createUser(walletKey); + const signer = createSigner(user); + + // Ensure db directory exists (separate per network to avoid conflicts) + const dbDirectory = path.join(account.dbPath, account.env); + fs.mkdirSync(dbDirectory, { recursive: true, mode: 0o700 }); + ctx.log?.info(`[${account.accountId}] Database directory: ${dbDirectory}`); + + // Initialize agent with proper dbPath function + // The dbPath should be a function that returns the full path including the db filename + const agent = await Agent.create(signer, { + env: account.env, + dbPath: (inboxId: string) => { + const fullPath = path.join(dbDirectory, `xmtp-${inboxId}.db3`); + ctx.log?.info(`[${account.accountId}] Using database: ${fullPath}`); + return fullPath; + }, + dbEncryptionKey: account.encryptionKey + ? (account.encryptionKey as `0x${string}`) + : undefined, + }); + + // Check and ensure registration + const client = agent.client; + ctx.log?.info( + `[${account.accountId}] XMTP client created, inbox ID: ${client.inboxId}, isRegistered: ${client.isRegistered}` + ); + + // Explicitly register if not already registered + if (!client.isRegistered) { + ctx.log?.info(`[${account.accountId}] Registering XMTP identity...`); + await client.register(); + ctx.log?.info(`[${account.accountId}] XMTP identity registered successfully`); + } else { + ctx.log?.info(`[${account.accountId}] XMTP identity already registered`); + } + + /** + * Check if a sender is authorized to message this bot. + * Returns authorization status and command permission. + */ + const checkSenderAuthorization = async ( + senderAddress: string + ): Promise => { + const normalizedSender = senderAddress.toLowerCase(); + const dmPolicy = account.config.dmPolicy; + const allowFrom = (account.config.allowFrom ?? []).map(a => a.toLowerCase()); + + // Check if sender is explicitly in config allowlist + const isInConfigAllowlist = allowFrom.length > 0 && + allowFrom.some(allowed => + allowed === normalizedSender || + allowed === `xmtp:${normalizedSender}` + ); + + // Check if sender is in the pairing store (approved pairings) + // @ts-ignore + const clawdbotPath = "/opt/homebrew/lib/node_modules/clawdbot/dist"; + const { readChannelAllowFromStore } = await import(`${clawdbotPath}/pairing/pairing-store.js`); + const storeAllowFrom = await readChannelAllowFromStore("xmtp") ?? []; + const isInPairingStore = storeAllowFrom.some((entry: string) => + entry.toLowerCase() === normalizedSender + ); + + const isAuthorized = isInConfigAllowlist || isInPairingStore; + + if (dmPolicy === "open") { + // Open policy: anyone can message, but only authorized users can run commands + return { + allowed: true, + commandAuthorized: isAuthorized + }; + } + + if (dmPolicy === "allowlist") { + // Allowlist policy: only config allowlist users can message (no pairing) + if (!isInConfigAllowlist) { + return { + allowed: false, + commandAuthorized: false, + reason: "not_in_allowlist" + }; + } + return { allowed: true, commandAuthorized: true }; + } + + if (dmPolicy === "pairing") { + // Pairing policy: config allowlist OR approved pairings can message + if (isAuthorized) { + return { allowed: true, commandAuthorized: true }; + } + + // Not authorized - will need to handle pairing + return { + allowed: false, + commandAuthorized: false, + reason: "needs_pairing" + }; + } + + // Default: deny + return { allowed: false, commandAuthorized: false, reason: "unknown_policy" }; + }; + + // Helper to handle pairing request + const handlePairingRequest = async ( + senderAddress: string, + msgCtx: any + ): Promise => { + // @ts-ignore + const clawdbotPath = "/opt/homebrew/lib/node_modules/clawdbot/dist"; + const { upsertChannelPairingRequest } = await import(`${clawdbotPath}/pairing/pairing-store.js`); + + const normalizedAddress = senderAddress.toLowerCase(); + const { code, created } = await upsertChannelPairingRequest({ + channel: "xmtp", + id: normalizedAddress, + meta: { + address: normalizedAddress, + label: senderAddress, + }, + }); + + if (created) { + ctx.log?.info( + `[${account.accountId}] New pairing request from ${senderAddress}, code: ${code}` + ); + } + + // Send pairing instructions to the user + await msgCtx.sendText([ + "๐Ÿ” Clawdbot: Access not configured.", + "", + `Your wallet address: ${senderAddress}`, + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + `clawdbot pairing approve xmtp ${code}`, + ].join("\n")); + }; + + // Set up message handlers + agent.on("text", async (msgCtx) => { + try { + const senderAddress = await msgCtx.getSenderAddress(); + if (!senderAddress) { + ctx.log?.warn(`[${account.accountId}] Message with unknown sender, ignoring`); + return; + } + const senderInboxId = msgCtx.message.senderInboxId; + const conversationId = msgCtx.conversation.id; + const isGroup = !msgCtx.isDm(); + const messageText = String(msgCtx.message.content); + + ctx.log?.debug( + `[${account.accountId}] Message from ${senderAddress}: ${messageText.slice(0, 50)}...` + ); + + // Check authorization + const authResult = await checkSenderAuthorization(senderAddress); + + if (!authResult.allowed) { + if (authResult.reason === "needs_pairing") { + await handlePairingRequest(senderAddress, msgCtx); + return; + } + if (authResult.reason === "not_in_allowlist") { + ctx.log?.info( + `[${account.accountId}] Blocked message from unauthorized sender: ${senderAddress}` + ); + await msgCtx.sendText("โ›” Access denied. You are not authorized to use this bot."); + return; + } + // Unknown reason, just ignore + ctx.log?.warn( + `[${account.accountId}] Blocked message from ${senderAddress}: ${authResult.reason}` + ); + return; + } + + // Dynamically import clawdbot's reply system using absolute paths + // @ts-ignore - accessing internal clawdbot modules + const clawdbotPath = "/opt/homebrew/lib/node_modules/clawdbot/dist"; + const { getReplyFromConfig } = await import(`${clawdbotPath}/auto-reply/reply/get-reply.js`); + // @ts-ignore + const { loadConfig } = await import(`${clawdbotPath}/config/config.js`); + + const cfg = loadConfig(); + + // Build inbound context for clawdbot + ctx.log?.info(`[${account.accountId}] [TEXT] Building inboundCtx for message processing`); + ctx.log?.debug(`[${account.accountId}] [TEXT] Raw values before inboundCtx:`); + ctx.log?.debug(`[${account.accountId}] - messageText: ${messageText?.slice(0, 100)}...`); + ctx.log?.debug(`[${account.accountId}] - senderAddress: ${senderAddress}`); + ctx.log?.debug(`[${account.accountId}] - conversationId: ${conversationId}`); + ctx.log?.debug(`[${account.accountId}] - senderInboxId: ${senderInboxId}`); + ctx.log?.debug(`[${account.accountId}] - isGroup: ${isGroup}`); + ctx.log?.debug(`[${account.accountId}] - msgCtx.message.id: ${msgCtx.message.id}`); + + const inboundCtx: XmtpInboundContext = { + Body: messageText, + RawBody: messageText, + From: `xmtp:${senderAddress}`, + To: `xmtp:${conversationId}`, + SessionKey: `xmtp:${conversationId}`, + AccountId: account.accountId, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: senderAddress, + SenderName: senderAddress, + SenderId: senderAddress, + Provider: "xmtp", + Surface: "xmtp", + MessageSid: msgCtx.message.id, + ConversationId: conversationId, + SenderInboxId: senderInboxId, + Timestamp: Date.now(), + CommandAuthorized: authResult.commandAuthorized, + OriginatingChannel: "xmtp", + OriginatingTo: `xmtp:${conversationId}`, + }; + + ctx.log?.debug(`[${account.accountId}] [TEXT] inboundCtx constructed: ${JSON.stringify(inboundCtx)}`); + ctx.log?.info(`[${account.accountId}] [TEXT] Calling getReplyFromConfig for session ${inboundCtx.SessionKey}`); + + // Get reply from clawdbot agent with corruption recovery + let replyResult; + try { + replyResult = await getReplyFromConfig(inboundCtx, { + onBlockReply: async (payload: { text?: string }) => { + ctx.log?.debug(`[${account.accountId}] [TEXT] onBlockReply called with payload: ${JSON.stringify(payload)}`); + if (payload.text) { + ctx.log?.info(`[${account.accountId}] [TEXT] Sending block reply via onBlockReply (${payload.text.length} chars)`); + await msgCtx.conversation.send(payload.text); + ctx.log?.debug(`[${account.accountId}] [TEXT] Block reply sent successfully`); + } + }, + }, cfg); + } catch (replyError) { + // Check if this is a corrupted session error + if (isCorruptedSessionError(replyError)) { + ctx.log?.warn( + `[${account.accountId}] Detected corrupted session, attempting recovery for ${inboundCtx.SessionKey}` + ); + + const cleared = await clearCorruptedSession({ + sessionKey: inboundCtx.SessionKey, + log: ctx.log, + }); + + if (cleared) { + // Retry with fresh session + ctx.log?.info(`[${account.accountId}] Retrying message with fresh session`); + await msgCtx.sendText("โš ๏ธ Session recovered from corruption. Please resend your message."); + } else { + await msgCtx.sendText("โš ๏ธ Session error occurred. Please try /new to start a fresh conversation."); + } + return; + } + // Re-throw other errors + throw replyError; + } + + ctx.log?.info(`[${account.accountId}] [TEXT] getReplyFromConfig completed, processing replies`); + ctx.log?.debug(`[${account.accountId}] [TEXT] replyResult type: ${typeof replyResult}, isArray: ${Array.isArray(replyResult)}`); + ctx.log?.debug(`[${account.accountId}] [TEXT] replyResult value: ${JSON.stringify(replyResult)}`); + + // Send final reply + const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : []; + ctx.log?.info(`[${account.accountId}] [TEXT] Sending ${replies.length} reply message(s)`); + for (let i = 0; i < replies.length; i++) { + const reply = replies[i]; + ctx.log?.debug(`[${account.accountId}] [TEXT] Reply ${i + 1}: has text=${!!reply.text}, length=${reply.text?.length || 0}`); + if (reply.text) { + ctx.log?.info(`[${account.accountId}] [TEXT] Sending reply ${i + 1} via msgCtx.sendText (${reply.text.length} chars)`); + await msgCtx.sendText(reply.text); + ctx.log?.debug(`[${account.accountId}] [TEXT] Reply ${i + 1} sent successfully`); + } + } + ctx.log?.info(`[${account.accountId}] [TEXT] All replies sent, message handling complete`); + } catch (error) { + ctx.log?.error( + `[${account.accountId}] [TEXT] Error handling message: ${String(error)}` + ); + ctx.log?.error( + `[${account.accountId}] [TEXT] Error stack: ${(error as Error)?.stack}` + ); + } + }); + + agent.on("dm", async (dmCtx) => { + ctx.log?.debug( + `[${account.accountId}] New DM conversation: ${dmCtx.conversation.id}` + ); + }); + + agent.on("group", async (groupCtx) => { + ctx.log?.debug( + `[${account.accountId}] New group conversation: ${groupCtx.conversation.id}` + ); + }); + + // Handle incoming reactions (logging only - reactions are typically UI-only) + agent.on("reaction", async (msgCtx) => { + try { + ctx.log?.info(`[${account.accountId}] [REACTION EVENT] Received reaction event`); + + // Log raw msgCtx structure + ctx.log?.debug(`[${account.accountId}] [REACTION EVENT] msgCtx keys: ${Object.keys(msgCtx || {}).join(', ')}`); + ctx.log?.debug(`[${account.accountId}] [REACTION EVENT] msgCtx.message exists: ${!!msgCtx?.message}`); + ctx.log?.debug(`[${account.accountId}] [REACTION EVENT] msgCtx.message keys: ${Object.keys(msgCtx?.message || {}).join(', ')}`); + + // Log message content structure in detail + const rawContent = msgCtx?.message?.content; + ctx.log?.debug(`[${account.accountId}] [REACTION EVENT] rawContent type: ${typeof rawContent}`); + ctx.log?.debug(`[${account.accountId}] [REACTION EVENT] rawContent value: ${JSON.stringify(rawContent)}`); + + // Check for undefined values that might cause errors + ctx.log?.debug(`[${account.accountId}] [REACTION EVENT] Checking for undefined values:`); + ctx.log?.debug(`[${account.accountId}] - msgCtx: ${msgCtx === undefined ? 'UNDEFINED' : 'defined'}`); + ctx.log?.debug(`[${account.accountId}] - msgCtx.message: ${msgCtx?.message === undefined ? 'UNDEFINED' : 'defined'}`); + ctx.log?.debug(`[${account.accountId}] - msgCtx.message.content: ${msgCtx?.message?.content === undefined ? 'UNDEFINED' : 'defined'}`); + ctx.log?.debug(`[${account.accountId}] - msgCtx.message.id: ${msgCtx?.message?.id === undefined ? 'UNDEFINED' : msgCtx?.message?.id}`); + ctx.log?.debug(`[${account.accountId}] - msgCtx.message.senderInboxId: ${msgCtx?.message?.senderInboxId === undefined ? 'UNDEFINED' : msgCtx?.message?.senderInboxId}`); + ctx.log?.debug(`[${account.accountId}] - msgCtx.conversation: ${msgCtx?.conversation === undefined ? 'UNDEFINED' : 'defined'}`); + ctx.log?.debug(`[${account.accountId}] - msgCtx.conversation.id: ${msgCtx?.conversation?.id === undefined ? 'UNDEFINED' : msgCtx?.conversation?.id}`); + + const reaction = msgCtx.message.content; + ctx.log?.debug(`[${account.accountId}] [REACTION EVENT] reaction.action: ${reaction?.action === undefined ? 'UNDEFINED' : reaction?.action}`); + ctx.log?.debug(`[${account.accountId}] [REACTION EVENT] reaction.content: ${reaction?.content === undefined ? 'UNDEFINED' : reaction?.content}`); + ctx.log?.debug(`[${account.accountId}] [REACTION EVENT] reaction.reference: ${reaction?.reference === undefined ? 'UNDEFINED' : reaction?.reference}`); + ctx.log?.debug(`[${account.accountId}] [REACTION EVENT] reaction.schema: ${reaction?.schema === undefined ? 'UNDEFINED' : reaction?.schema}`); + ctx.log?.debug(`[${account.accountId}] [REACTION EVENT] reaction.referenceInboxId: ${(reaction as any)?.referenceInboxId === undefined ? 'UNDEFINED' : (reaction as any)?.referenceInboxId}`); + + const senderAddress = await msgCtx.getSenderAddress(); + ctx.log?.info( + `[${account.accountId}] [REACTION EVENT] Reaction from ${senderAddress}: ${reaction?.action} "${reaction?.content}" on message ${reaction?.reference}` + ); + } catch (error) { + ctx.log?.error( + `[${account.accountId}] [REACTION EVENT] Error handling reaction: ${String(error)}` + ); + ctx.log?.error( + `[${account.accountId}] [REACTION EVENT] Error stack: ${(error as Error)?.stack}` + ); + ctx.log?.error( + `[${account.accountId}] [REACTION EVENT] Error name: ${(error as Error)?.name}, message: ${(error as Error)?.message}` + ); + } + }); + + // Handle incoming replies - process them like regular messages but include reply context + agent.on("reply", async (msgCtx) => { + try { + const reply = msgCtx.message.content as Reply; + const senderAddress = await msgCtx.getSenderAddress(); + if (!senderAddress) { + ctx.log?.warn(`[${account.accountId}] Reply with unknown sender, ignoring`); + return; + } + const senderInboxId = msgCtx.message.senderInboxId; + const conversationId = msgCtx.conversation.id; + const isGroup = !msgCtx.isDm(); + const messageText = String(reply?.content ?? ""); + const replyToId = reply?.reference; + + ctx.log?.debug( + `[${account.accountId}] Reply from ${senderAddress} to message ${replyToId}: ${messageText.slice(0, 50)}...` + ); + + // Check authorization (same as text handler) + const authResult = await checkSenderAuthorization(senderAddress); + + if (!authResult.allowed) { + if (authResult.reason === "needs_pairing") { + await handlePairingRequest(senderAddress, msgCtx); + return; + } + if (authResult.reason === "not_in_allowlist") { + ctx.log?.info( + `[${account.accountId}] Blocked reply from unauthorized sender: ${senderAddress}` + ); + await msgCtx.sendText("โ›” Access denied. You are not authorized to use this bot."); + return; + } + ctx.log?.warn( + `[${account.accountId}] Blocked reply from ${senderAddress}: ${authResult.reason}` + ); + return; + } + + // Dynamically import clawdbot's reply system + // @ts-ignore + const clawdbotPath = "/opt/homebrew/lib/node_modules/clawdbot/dist"; + const { getReplyFromConfig } = await import(`${clawdbotPath}/auto-reply/reply/get-reply.js`); + // @ts-ignore + const { loadConfig } = await import(`${clawdbotPath}/config/config.js`); + + const cfg = loadConfig(); + + // Build inbound context for clawdbot (same as text but with ReplyToId) + ctx.log?.info(`[${account.accountId}] [REPLY] Building inboundCtx for reply message processing`); + ctx.log?.debug(`[${account.accountId}] [REPLY] Raw values before inboundCtx:`); + ctx.log?.debug(`[${account.accountId}] - messageText: ${messageText?.slice(0, 100)}...`); + ctx.log?.debug(`[${account.accountId}] - senderAddress: ${senderAddress}`); + ctx.log?.debug(`[${account.accountId}] - conversationId: ${conversationId}`); + ctx.log?.debug(`[${account.accountId}] - senderInboxId: ${senderInboxId}`); + ctx.log?.debug(`[${account.accountId}] - replyToId: ${replyToId}`); + ctx.log?.debug(`[${account.accountId}] - isGroup: ${isGroup}`); + ctx.log?.debug(`[${account.accountId}] - msgCtx.message.id: ${msgCtx.message.id}`); + + const inboundCtx: XmtpInboundContext = { + Body: messageText, + RawBody: messageText, + From: `xmtp:${senderAddress}`, + To: `xmtp:${conversationId}`, + SessionKey: `xmtp:${conversationId}`, + AccountId: account.accountId, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: senderAddress, + SenderName: senderAddress, + SenderId: senderAddress, + Provider: "xmtp", + Surface: "xmtp", + MessageSid: msgCtx.message.id, + ReplyToId: replyToId, // Include reply reference + ConversationId: conversationId, + SenderInboxId: senderInboxId, + Timestamp: Date.now(), + CommandAuthorized: authResult.commandAuthorized, + OriginatingChannel: "xmtp", + OriginatingTo: `xmtp:${conversationId}`, + }; + + ctx.log?.debug(`[${account.accountId}] [REPLY] inboundCtx constructed: ${JSON.stringify(inboundCtx)}`); + ctx.log?.info(`[${account.accountId}] [REPLY] Calling getReplyFromConfig for session ${inboundCtx.SessionKey}`); + + // Get reply from clawdbot agent with corruption recovery + let replyResult; + try { + replyResult = await getReplyFromConfig(inboundCtx, { + onBlockReply: async (payload: { text?: string }) => { + ctx.log?.debug(`[${account.accountId}] [REPLY] onBlockReply called with payload: ${JSON.stringify(payload)}`); + if (payload.text) { + ctx.log?.info(`[${account.accountId}] [REPLY] Sending block reply via onBlockReply (${payload.text.length} chars)`); + await msgCtx.conversation.send(payload.text); + ctx.log?.debug(`[${account.accountId}] [REPLY] Block reply sent successfully`); + } + }, + }, cfg); + } catch (replyError) { + // Check if this is a corrupted session error + if (isCorruptedSessionError(replyError)) { + ctx.log?.warn( + `[${account.accountId}] Detected corrupted session in reply handler, attempting recovery for ${inboundCtx.SessionKey}` + ); + + const cleared = await clearCorruptedSession({ + sessionKey: inboundCtx.SessionKey, + log: ctx.log, + }); + + if (cleared) { + ctx.log?.info(`[${account.accountId}] Retrying reply with fresh session`); + await msgCtx.sendText("โš ๏ธ Session recovered from corruption. Please resend your message."); + } else { + await msgCtx.sendText("โš ๏ธ Session error occurred. Please try /new to start a fresh conversation."); + } + return; + } + // Re-throw other errors + throw replyError; + } + + ctx.log?.info(`[${account.accountId}] [REPLY] getReplyFromConfig completed, processing replies`); + ctx.log?.debug(`[${account.accountId}] [REPLY] replyResult type: ${typeof replyResult}, isArray: ${Array.isArray(replyResult)}`); + ctx.log?.debug(`[${account.accountId}] [REPLY] replyResult value: ${JSON.stringify(replyResult)}`); + + // Send final reply + const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : []; + ctx.log?.info(`[${account.accountId}] [REPLY] Sending ${replies.length} reply message(s)`); + for (let i = 0; i < replies.length; i++) { + const r = replies[i]; + ctx.log?.debug(`[${account.accountId}] [REPLY] Reply ${i + 1}: has text=${!!r.text}, length=${r.text?.length || 0}`); + if (r.text) { + ctx.log?.info(`[${account.accountId}] [REPLY] Sending reply ${i + 1} via msgCtx.sendText (${r.text.length} chars)`); + await msgCtx.sendText(r.text); + ctx.log?.debug(`[${account.accountId}] [REPLY] Reply ${i + 1} sent successfully`); + } + } + ctx.log?.info(`[${account.accountId}] [REPLY] All replies sent, reply handling complete`); + } catch (error) { + ctx.log?.error( + `[${account.accountId}] [REPLY] Error handling reply: ${String(error)}` + ); + ctx.log?.error( + `[${account.accountId}] [REPLY] Error stack: ${(error as Error)?.stack}` + ); + } + }); + + agent.on("unhandledError", (error) => { + ctx.log?.error(`[${account.accountId}] Unhandled error: ${String(error)}`); + }); + + // Start the agent + await agent.start(); + + // Store the agent handle + activeAgents.set(account.accountId, agent); + + ctx.log?.info( + `[${account.accountId}] XMTP provider started (address: ${account.walletAddress})` + ); + + // Return cleanup function + return { + stop: async () => { + await agent.stop(); + activeAgents.delete(account.accountId); + ctx.log?.info(`[${account.accountId}] XMTP provider stopped`); + }, + }; + }, + }, +}; + +/** + * Get active XMTP agent for an account. + * Useful for debugging and status reporting. + */ +export function getActiveXmtpAgent( + accountId: string = DEFAULT_ACCOUNT_ID +): Agent | undefined { + return activeAgents.get(accountId); +} diff --git a/extensions/xmtp/src/onboarding.ts b/extensions/xmtp/src/onboarding.ts new file mode 100644 index 000000000..2d0784c35 --- /dev/null +++ b/extensions/xmtp/src/onboarding.ts @@ -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; + confirm: (options: { + message: string; + initialValue?: boolean; + }) => Promise; + select: (options: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; + initialValue?: T; + }) => Promise; + note: (message: string, title?: string) => Promise; +} + +/** + * Status result from getStatus. + */ +interface OnboardingStatus { + provider: string; + configured: boolean; + statusLines: string[]; + selectionHint: string; + quickstartScore: number; +} + +/** + * Configure result. + */ +interface ConfigureResult { + cfg: Record; + accountId: string; +} + +/** + * DM policy adapter interface. + */ +interface DmPolicyAdapter { + label: string; + provider: string; + policyKey: string; + allowFromKey: string; + getCurrent: (cfg: Record) => XmtpDmPolicy; + setPolicy: (cfg: Record, policy: XmtpDmPolicy) => Record; +} + +/** + * Onboarding adapter interface. + */ +export interface XmtpOnboardingAdapter { + provider: string; + getStatus: (params: { + cfg: Record; + options?: Record; + accountOverrides?: Record; + }) => Promise; + configure: (params: { + cfg: Record; + prompter: Prompter; + accountOverrides: Record; + shouldPromptAccountIds?: boolean; + forceAllowFrom?: boolean; + options?: Record; + }) => Promise; + dmPolicy: DmPolicyAdapter; + disable: (cfg: Record) => Record; +} + +// ============================================================================ +// 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): XmtpConfig | undefined { + const channels = cfg.channels as Record | 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; + prompter: Prompter; + label: string; + currentId: string; + listAccountIds: (cfg: Record) => string[]; + defaultAccountId: string | null; +}): Promise { + 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 { + 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; + prompter: Prompter; + accountId: string; +}): Promise> { + 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), + xmtp: { + ...getXmtpConfig(cfg), + enabled: true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }; + } + + const xmtpCfg = getXmtpConfig(cfg); + return { + ...cfg, + channels: { + ...(cfg.channels as Record), + 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, + dmPolicy: XmtpDmPolicy +): Record { + const xmtpCfg = getXmtpConfig(cfg); + const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(xmtpCfg?.allowFrom) : undefined; + + return { + ...cfg, + channels: { + ...(cfg.channels as Record), + 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({ + 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({ + 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), + xmtp: { + ...xmtpCfg, + enabled: true, + ...(walletKey ? { walletKey } : {}), + env, + dmPolicy, + }, + }, + }; + } else { + next = { + ...next, + channels: { + ...(next.channels as Record), + 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), + xmtp: { + ...xmtpCfg, + enabled: false, + }, + }, + }; + }, +}; diff --git a/extensions/xmtp/src/types.xmtp.ts b/extensions/xmtp/src/types.xmtp.ts new file mode 100644 index 000000000..943d8ee10 --- /dev/null +++ b/extensions/xmtp/src/types.xmtp.ts @@ -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; +} & 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 for clawdbot SDK compatibility. + */ +export interface XmtpRuntimeState extends Record { + /** 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 for clawdbot SDK compatibility. + */ +export interface XmtpAccountSnapshot extends Record { + /** 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 for clawdbot SDK compatibility. + */ +export interface XmtpChannelSummary extends Record { + /** 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; +} + +/** + * 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; +}