@coffeexdev/xmtp 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +12 -0
- package/package.json +70 -0
- package/src/channel.ts +520 -0
- package/src/config-schema.ts +23 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +180 -0
- package/src/xmtp-bus.ts +319 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 coffeebot-agent
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @coffeexdev/xmtp
|
|
2
|
+
|
|
3
|
+
XMTP channel plugin for [OpenClaw](https://github.com/open-claw/openclaw) — adds E2E encrypted wallet-to-wallet messaging via the [XMTP protocol](https://xmtp.org/).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Direct Messages** — Wallet-to-wallet E2E encrypted DMs over XMTP
|
|
8
|
+
- **Pairing Flow** — Policy-aware consent with pairing codes for new senders
|
|
9
|
+
- **Reply Threading** — Native XMTP reply threading support (reference IDs)
|
|
10
|
+
- **DM Policies** — Configurable policies: `open`, `pairing`, `allowlist`, `disabled`
|
|
11
|
+
- **Allowlist** — Restrict access to specific Ethereum addresses
|
|
12
|
+
- **Activity Tracking** — Inbound/outbound message activity recording
|
|
13
|
+
- **Markdown Tables** — Automatic markdown table conversion for XMTP clients
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
openclaw plugins install @coffeexdev/xmtp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install manually via npm:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @coffeexdev/xmtp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
Add XMTP channel configuration to your OpenClaw config (`openclaw.yaml` or equivalent):
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
channels:
|
|
33
|
+
xmtp:
|
|
34
|
+
# Required: XMTP wallet private key (0x-prefixed hex)
|
|
35
|
+
walletKey: "0x..."
|
|
36
|
+
# Or use a file:
|
|
37
|
+
# walletKeyFile: "/path/to/wallet-key"
|
|
38
|
+
|
|
39
|
+
# Required: Database encryption key (hex string)
|
|
40
|
+
dbEncryptionKey: "0x..."
|
|
41
|
+
# Or use a file:
|
|
42
|
+
# dbEncryptionKeyFile: "/path/to/db-encryption-key"
|
|
43
|
+
|
|
44
|
+
# XMTP network environment (default: production)
|
|
45
|
+
env: production # or: dev, local
|
|
46
|
+
|
|
47
|
+
# DM policy (default: pairing)
|
|
48
|
+
dmPolicy: pairing # or: open, allowlist, disabled
|
|
49
|
+
|
|
50
|
+
# Allowlist of Ethereum addresses (used with allowlist/pairing policies)
|
|
51
|
+
allowFrom:
|
|
52
|
+
- "0x1234567890abcdef1234567890abcdef12345678"
|
|
53
|
+
|
|
54
|
+
# Custom database path (optional)
|
|
55
|
+
# dbPath: "~/.openclaw/state/channels/xmtp/production"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Environment Variables
|
|
59
|
+
|
|
60
|
+
As an alternative to config file values, you can set:
|
|
61
|
+
|
|
62
|
+
- `XMTP_WALLET_KEY` — Wallet private key
|
|
63
|
+
- `XMTP_DB_ENCRYPTION_KEY` — Database encryption key
|
|
64
|
+
|
|
65
|
+
## DM Policies
|
|
66
|
+
|
|
67
|
+
| Policy | Behavior |
|
|
68
|
+
| ----------- | ----------------------------------------------------------------------------------- |
|
|
69
|
+
| `open` | Accept DMs from anyone |
|
|
70
|
+
| `pairing` | New senders receive a pairing code; approve via `openclaw pair approve xmtp <code>` |
|
|
71
|
+
| `allowlist` | Only accept DMs from addresses in `allowFrom` or approved via pairing |
|
|
72
|
+
| `disabled` | Drop all inbound DMs |
|
|
73
|
+
|
|
74
|
+
## Links
|
|
75
|
+
|
|
76
|
+
- [XMTP Documentation](https://docs.xmtp.org/)
|
|
77
|
+
- [XMTP Agent SDK](https://github.com/nicobianchetti/xmtp-agent-sdk)
|
|
78
|
+
- [OpenClaw](https://github.com/open-claw/openclaw)
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
import { xmtpPlugin } from "./src/channel.js";
|
|
4
|
+
import { setXmtpRuntime } from "./src/runtime.js";
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
id: "xmtp",
|
|
8
|
+
name: "XMTP",
|
|
9
|
+
description: "XMTP E2E encrypted messaging channel plugin",
|
|
10
|
+
configSchema: emptyPluginConfigSchema(),
|
|
11
|
+
register(api: OpenClawPluginApi) {
|
|
12
|
+
setXmtpRuntime(api.runtime);
|
|
13
|
+
api.registerChannel({ plugin: xmtpPlugin });
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coffeexdev/xmtp",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "OpenClaw XMTP channel plugin for E2E encrypted wallet-to-wallet messaging",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"channel",
|
|
7
|
+
"e2e",
|
|
8
|
+
"encrypted",
|
|
9
|
+
"ethereum",
|
|
10
|
+
"messaging",
|
|
11
|
+
"openclaw",
|
|
12
|
+
"plugin",
|
|
13
|
+
"wallet",
|
|
14
|
+
"xmtp"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/coffeexcoin/openclaw-xmtp.git"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"index.ts",
|
|
23
|
+
"src/*.ts",
|
|
24
|
+
"!src/*.test.ts",
|
|
25
|
+
"openclaw.plugin.json"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"format": "oxfmt --write",
|
|
31
|
+
"format:check": "oxfmt --check",
|
|
32
|
+
"lint": "oxlint",
|
|
33
|
+
"check": "npm run format:check && npm run lint",
|
|
34
|
+
"prepare": "git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@xmtp/agent-sdk": "^2.2.0",
|
|
38
|
+
"viem": "^2.23.2",
|
|
39
|
+
"zod": "^4.3.6"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"openclaw": "latest",
|
|
43
|
+
"oxfmt": "0.32.0",
|
|
44
|
+
"oxlint": "^1.47.0",
|
|
45
|
+
"vitest": "^3.1.1"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"openclaw": ">=2026.2.15"
|
|
49
|
+
},
|
|
50
|
+
"openclaw": {
|
|
51
|
+
"extensions": [
|
|
52
|
+
"./index.ts"
|
|
53
|
+
],
|
|
54
|
+
"channel": {
|
|
55
|
+
"id": "xmtp",
|
|
56
|
+
"label": "XMTP",
|
|
57
|
+
"selectionLabel": "XMTP (E2E Encrypted DMs)",
|
|
58
|
+
"docsPath": "/channels/xmtp",
|
|
59
|
+
"docsLabel": "xmtp",
|
|
60
|
+
"blurb": "E2E encrypted messaging via XMTP protocol; wallet-to-wallet.",
|
|
61
|
+
"order": 101,
|
|
62
|
+
"quickstartAllowFrom": true
|
|
63
|
+
},
|
|
64
|
+
"install": {
|
|
65
|
+
"npmSpec": "@coffeexdev/xmtp",
|
|
66
|
+
"localPath": "extensions/xmtp",
|
|
67
|
+
"defaultChoice": "npm"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildChannelConfigSchema,
|
|
3
|
+
collectStatusIssuesFromLastError,
|
|
4
|
+
createDefaultChannelRuntimeState,
|
|
5
|
+
createReplyPrefixOptions,
|
|
6
|
+
DEFAULT_ACCOUNT_ID,
|
|
7
|
+
formatAllowlistMatchMeta,
|
|
8
|
+
formatPairingApproveHint,
|
|
9
|
+
PAIRING_APPROVED_MESSAGE,
|
|
10
|
+
resolveAllowlistMatchSimple,
|
|
11
|
+
resolveControlCommandGate,
|
|
12
|
+
type ChannelPlugin,
|
|
13
|
+
type OpenClawConfig,
|
|
14
|
+
type ReplyPayload,
|
|
15
|
+
} from "openclaw/plugin-sdk";
|
|
16
|
+
import { XmtpConfigSchema } from "./config-schema.js";
|
|
17
|
+
import { getXmtpRuntime } from "./runtime.js";
|
|
18
|
+
import {
|
|
19
|
+
listXmtpAccountIds,
|
|
20
|
+
resolveDefaultXmtpAccountId,
|
|
21
|
+
resolveXmtpAccount,
|
|
22
|
+
type ResolvedXmtpAccount,
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
import { normalizeEthAddress, startXmtpBus, type XmtpBusHandle } from "./xmtp-bus.js";
|
|
25
|
+
|
|
26
|
+
const activeBuses = new Map<string, XmtpBusHandle>();
|
|
27
|
+
|
|
28
|
+
function normalizeAllowEntry(entry: string): string {
|
|
29
|
+
const trimmed = entry.trim();
|
|
30
|
+
if (!trimmed) {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
if (trimmed === "*") {
|
|
34
|
+
return "*";
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return normalizeEthAddress(trimmed);
|
|
38
|
+
} catch {
|
|
39
|
+
return trimmed.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeAllowEntries(entries: Array<string | number>): string[] {
|
|
44
|
+
return entries
|
|
45
|
+
.map((entry) => normalizeAllowEntry(String(entry)))
|
|
46
|
+
.filter((entry) => entry.length > 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function previewText(text: string, limit = 200): string {
|
|
50
|
+
return text.slice(0, limit).replace(/\n/g, "\\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const xmtpPlugin: ChannelPlugin<ResolvedXmtpAccount> = {
|
|
54
|
+
id: "xmtp",
|
|
55
|
+
meta: {
|
|
56
|
+
id: "xmtp",
|
|
57
|
+
label: "XMTP",
|
|
58
|
+
selectionLabel: "XMTP",
|
|
59
|
+
docsPath: "/channels/xmtp",
|
|
60
|
+
docsLabel: "xmtp",
|
|
61
|
+
blurb: "E2E encrypted messaging via XMTP (wallet-to-wallet)",
|
|
62
|
+
order: 101,
|
|
63
|
+
},
|
|
64
|
+
capabilities: {
|
|
65
|
+
chatTypes: ["direct"],
|
|
66
|
+
media: false,
|
|
67
|
+
reply: true,
|
|
68
|
+
},
|
|
69
|
+
reload: { configPrefixes: ["channels.xmtp"] },
|
|
70
|
+
configSchema: buildChannelConfigSchema(XmtpConfigSchema),
|
|
71
|
+
|
|
72
|
+
config: {
|
|
73
|
+
listAccountIds: (cfg) => listXmtpAccountIds(cfg),
|
|
74
|
+
resolveAccount: (cfg, accountId) => resolveXmtpAccount({ cfg, accountId }),
|
|
75
|
+
defaultAccountId: (cfg) => resolveDefaultXmtpAccountId(cfg),
|
|
76
|
+
isConfigured: (account) => account.configured,
|
|
77
|
+
describeAccount: (account) => ({
|
|
78
|
+
accountId: account.accountId,
|
|
79
|
+
name: account.name,
|
|
80
|
+
enabled: account.enabled,
|
|
81
|
+
configured: account.configured,
|
|
82
|
+
credentialSource: account.walletKeySource,
|
|
83
|
+
secretSource: account.dbEncryptionKeySource,
|
|
84
|
+
dmPolicy: account.config.dmPolicy,
|
|
85
|
+
allowFrom: account.config.allowFrom,
|
|
86
|
+
dbPath: account.config.dbPath ?? null,
|
|
87
|
+
}),
|
|
88
|
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
89
|
+
resolveXmtpAccount({ cfg, accountId }).config.allowFrom ?? [],
|
|
90
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
91
|
+
allowFrom
|
|
92
|
+
.map((entry) => String(entry).trim())
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.map((entry) => {
|
|
95
|
+
if (entry === "*") return "*";
|
|
96
|
+
try {
|
|
97
|
+
return normalizeEthAddress(entry);
|
|
98
|
+
} catch {
|
|
99
|
+
return entry;
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
.filter(Boolean),
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
pairing: {
|
|
106
|
+
idLabel: "ethAddress",
|
|
107
|
+
normalizeAllowEntry: (entry) => {
|
|
108
|
+
try {
|
|
109
|
+
return normalizeEthAddress(entry);
|
|
110
|
+
} catch {
|
|
111
|
+
return entry;
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
notifyApproval: async ({ id, cfg }) => {
|
|
115
|
+
const effectiveAccountId = resolveDefaultXmtpAccountId(cfg);
|
|
116
|
+
const bus = activeBuses.get(effectiveAccountId);
|
|
117
|
+
if (!bus) {
|
|
118
|
+
throw new Error(`XMTP bus not running for account ${effectiveAccountId}`);
|
|
119
|
+
}
|
|
120
|
+
await bus.sendText(id, PAIRING_APPROVED_MESSAGE);
|
|
121
|
+
getXmtpRuntime().channel.activity.record({
|
|
122
|
+
channel: "xmtp",
|
|
123
|
+
accountId: effectiveAccountId,
|
|
124
|
+
direction: "outbound",
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
security: {
|
|
130
|
+
resolveDmPolicy: ({ account }) => ({
|
|
131
|
+
policy: account.config.dmPolicy ?? "pairing",
|
|
132
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
133
|
+
policyPath: "channels.xmtp.dmPolicy",
|
|
134
|
+
allowFromPath: "channels.xmtp.allowFrom",
|
|
135
|
+
approveHint: formatPairingApproveHint("xmtp"),
|
|
136
|
+
normalizeEntry: (raw) => {
|
|
137
|
+
try {
|
|
138
|
+
return normalizeEthAddress(raw.trim());
|
|
139
|
+
} catch {
|
|
140
|
+
return raw.trim();
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
messaging: {
|
|
147
|
+
normalizeTarget: (target) => {
|
|
148
|
+
const cleaned = target.trim().toLowerCase();
|
|
149
|
+
try {
|
|
150
|
+
return normalizeEthAddress(cleaned);
|
|
151
|
+
} catch {
|
|
152
|
+
return cleaned;
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
targetResolver: {
|
|
156
|
+
looksLikeId: (input) => {
|
|
157
|
+
const trimmed = input.trim();
|
|
158
|
+
return /^0x[0-9a-fA-F]{40}$/.test(trimmed);
|
|
159
|
+
},
|
|
160
|
+
hint: "<0x... Ethereum address>",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
outbound: {
|
|
165
|
+
deliveryMode: "direct",
|
|
166
|
+
textChunkLimit: 4000,
|
|
167
|
+
sendText: async ({ to, text, accountId, replyToId }) => {
|
|
168
|
+
const core = getXmtpRuntime();
|
|
169
|
+
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
170
|
+
const bus = activeBuses.get(aid);
|
|
171
|
+
if (!bus) {
|
|
172
|
+
throw new Error(`XMTP bus not running for account ${aid}`);
|
|
173
|
+
}
|
|
174
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
175
|
+
cfg: core.config.loadConfig(),
|
|
176
|
+
channel: "xmtp",
|
|
177
|
+
accountId: aid,
|
|
178
|
+
});
|
|
179
|
+
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
|
180
|
+
const replyTarget = typeof replyToId === "string" ? replyToId.trim() : "";
|
|
181
|
+
if (replyTarget) {
|
|
182
|
+
await bus.sendReply(to, message, replyTarget);
|
|
183
|
+
} else {
|
|
184
|
+
await bus.sendText(to, message);
|
|
185
|
+
}
|
|
186
|
+
core.channel.activity.record({
|
|
187
|
+
channel: "xmtp",
|
|
188
|
+
accountId: aid,
|
|
189
|
+
direction: "outbound",
|
|
190
|
+
});
|
|
191
|
+
core.logging
|
|
192
|
+
.getChildLogger?.({ channel: "xmtp", accountId: aid })
|
|
193
|
+
?.debug?.(
|
|
194
|
+
`xmtp outbound: to=${to} len=${message.length} preview="${previewText(message, 160)}"`,
|
|
195
|
+
);
|
|
196
|
+
return {
|
|
197
|
+
channel: "xmtp" as const,
|
|
198
|
+
to,
|
|
199
|
+
messageId: `xmtp-${Date.now()}`,
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
status: {
|
|
205
|
+
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
206
|
+
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("xmtp", accounts),
|
|
207
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
208
|
+
configured: snapshot.configured ?? false,
|
|
209
|
+
credentialSource: snapshot.credentialSource ?? "none",
|
|
210
|
+
secretSource: snapshot.secretSource ?? "none",
|
|
211
|
+
running: snapshot.running ?? false,
|
|
212
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
213
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
214
|
+
lastError: snapshot.lastError ?? null,
|
|
215
|
+
}),
|
|
216
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
217
|
+
accountId: account.accountId,
|
|
218
|
+
name: account.name,
|
|
219
|
+
enabled: account.enabled,
|
|
220
|
+
configured: account.configured,
|
|
221
|
+
credentialSource: account.walletKeySource,
|
|
222
|
+
secretSource: account.dbEncryptionKeySource,
|
|
223
|
+
dmPolicy: account.config.dmPolicy,
|
|
224
|
+
allowFrom: account.config.allowFrom,
|
|
225
|
+
dbPath: account.config.dbPath ?? null,
|
|
226
|
+
running: runtime?.running ?? false,
|
|
227
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
228
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
229
|
+
lastError: runtime?.lastError ?? null,
|
|
230
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
231
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
232
|
+
}),
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
gateway: {
|
|
236
|
+
startAccount: async (ctx) => {
|
|
237
|
+
const account = ctx.account;
|
|
238
|
+
ctx.setStatus({
|
|
239
|
+
accountId: account.accountId,
|
|
240
|
+
credentialSource: account.walletKeySource,
|
|
241
|
+
secretSource: account.dbEncryptionKeySource,
|
|
242
|
+
dmPolicy: account.config.dmPolicy,
|
|
243
|
+
allowFrom: account.config.allowFrom,
|
|
244
|
+
dbPath: account.config.dbPath ?? null,
|
|
245
|
+
});
|
|
246
|
+
ctx.log?.info(
|
|
247
|
+
`[${account.accountId}] starting XMTP provider (address: ${account.address}, env: ${account.env})`,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
if (!account.configured) {
|
|
251
|
+
throw new Error("XMTP walletKey and dbEncryptionKey not configured");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const runtime = getXmtpRuntime();
|
|
255
|
+
|
|
256
|
+
const bus = await startXmtpBus({
|
|
257
|
+
accountId: account.accountId,
|
|
258
|
+
walletKey: account.walletKey,
|
|
259
|
+
dbEncryptionKey: account.dbEncryptionKey,
|
|
260
|
+
env: account.env,
|
|
261
|
+
dbPath: account.config.dbPath,
|
|
262
|
+
shouldConsentDm: (senderAddress: string) => {
|
|
263
|
+
const cfg = runtime.config.loadConfig() as OpenClawConfig;
|
|
264
|
+
const freshAccount = resolveXmtpAccount({
|
|
265
|
+
cfg,
|
|
266
|
+
accountId: account.accountId,
|
|
267
|
+
});
|
|
268
|
+
const policy = freshAccount.config.dmPolicy ?? "pairing";
|
|
269
|
+
if (policy === "disabled") return false;
|
|
270
|
+
if (policy === "open" || policy === "pairing") return true;
|
|
271
|
+
// allowlist: only consent if sender is in the effective allowlist
|
|
272
|
+
const configuredAllow = normalizeAllowEntries(freshAccount.config.allowFrom ?? []);
|
|
273
|
+
return configuredAllow.includes(senderAddress) || configuredAllow.includes("*");
|
|
274
|
+
},
|
|
275
|
+
onMessage: async ({
|
|
276
|
+
senderAddress,
|
|
277
|
+
senderInboxId,
|
|
278
|
+
conversationId,
|
|
279
|
+
text,
|
|
280
|
+
messageId,
|
|
281
|
+
replyContext,
|
|
282
|
+
}) => {
|
|
283
|
+
const cfg = runtime.config.loadConfig() as OpenClawConfig;
|
|
284
|
+
|
|
285
|
+
const rawBody = text.trim();
|
|
286
|
+
if (!rawBody) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
runtime.channel.activity.record({
|
|
291
|
+
channel: "xmtp",
|
|
292
|
+
accountId: account.accountId,
|
|
293
|
+
direction: "inbound",
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const freshAccount = resolveXmtpAccount({
|
|
297
|
+
cfg,
|
|
298
|
+
accountId: account.accountId,
|
|
299
|
+
});
|
|
300
|
+
const dmPolicy = freshAccount.config.dmPolicy ?? "pairing";
|
|
301
|
+
const configuredAllowFrom = normalizeAllowEntries(freshAccount.config.allowFrom ?? []);
|
|
302
|
+
const storeAllowFrom = normalizeAllowEntries(
|
|
303
|
+
await runtime.channel.pairing
|
|
304
|
+
.readAllowFromStore("xmtp", process.env, account.accountId)
|
|
305
|
+
.catch((error) => {
|
|
306
|
+
ctx.log?.warn?.(
|
|
307
|
+
`[${account.accountId}] Failed to read pairing store: ${String(error)}`,
|
|
308
|
+
);
|
|
309
|
+
return [];
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
const effectiveAllowFrom = [...configuredAllowFrom, ...storeAllowFrom];
|
|
313
|
+
const allowMatch = resolveAllowlistMatchSimple({
|
|
314
|
+
allowFrom: effectiveAllowFrom,
|
|
315
|
+
senderId: senderAddress,
|
|
316
|
+
});
|
|
317
|
+
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
|
318
|
+
|
|
319
|
+
if (dmPolicy === "disabled") {
|
|
320
|
+
ctx.log?.debug?.(`[${account.accountId}] blocked xmtp DM ${senderAddress} (disabled)`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (dmPolicy !== "open" && !allowMatch.allowed) {
|
|
325
|
+
if (dmPolicy === "pairing") {
|
|
326
|
+
try {
|
|
327
|
+
const { code, created } = await runtime.channel.pairing.upsertPairingRequest({
|
|
328
|
+
channel: "xmtp",
|
|
329
|
+
id: senderAddress,
|
|
330
|
+
accountId: account.accountId,
|
|
331
|
+
meta: {
|
|
332
|
+
inboxId: senderInboxId,
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
if (created) {
|
|
336
|
+
ctx.log?.info?.(
|
|
337
|
+
`[${account.accountId}] xmtp pairing request from ${senderAddress} (${allowMatchMeta})`,
|
|
338
|
+
);
|
|
339
|
+
const reply = runtime.channel.pairing.buildPairingReply({
|
|
340
|
+
channel: "xmtp",
|
|
341
|
+
idLine: `Your XMTP address: ${senderAddress}`,
|
|
342
|
+
code,
|
|
343
|
+
});
|
|
344
|
+
await bus.sendText(conversationId, reply);
|
|
345
|
+
runtime.channel.activity.record({
|
|
346
|
+
channel: "xmtp",
|
|
347
|
+
accountId: account.accountId,
|
|
348
|
+
direction: "outbound",
|
|
349
|
+
});
|
|
350
|
+
ctx.log?.debug?.(
|
|
351
|
+
`[${account.accountId}] xmtp pairing reply to=${conversationId} len=${reply.length}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
ctx.log?.error?.(
|
|
356
|
+
`[${account.accountId}] xmtp pairing reply failed for ${senderAddress}: ${String(err)}`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
ctx.log?.debug?.(
|
|
361
|
+
`[${account.accountId}] blocked unauthorized xmtp sender ${senderAddress} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const allowTextCommands = runtime.channel.commands.shouldHandleTextCommands({
|
|
368
|
+
cfg,
|
|
369
|
+
surface: "xmtp",
|
|
370
|
+
});
|
|
371
|
+
const hasControlCommand = runtime.channel.commands.isControlCommandMessage(rawBody, cfg);
|
|
372
|
+
const commandGate = resolveControlCommandGate({
|
|
373
|
+
useAccessGroups: cfg.commands?.useAccessGroups !== false,
|
|
374
|
+
authorizers: [
|
|
375
|
+
{
|
|
376
|
+
configured: effectiveAllowFrom.length > 0,
|
|
377
|
+
allowed: allowMatch.allowed,
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
allowTextCommands,
|
|
381
|
+
hasControlCommand,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
if (commandGate.shouldBlock) {
|
|
385
|
+
ctx.log?.debug?.(
|
|
386
|
+
`[${account.accountId}] blocked xmtp control command from ${senderAddress} (${allowMatchMeta})`,
|
|
387
|
+
);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
392
|
+
cfg,
|
|
393
|
+
channel: "xmtp",
|
|
394
|
+
accountId: account.accountId,
|
|
395
|
+
peer: { kind: "direct", id: senderAddress },
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
399
|
+
const body = runtime.channel.reply.formatAgentEnvelope({
|
|
400
|
+
channel: "XMTP",
|
|
401
|
+
from: senderAddress,
|
|
402
|
+
envelope: envelopeOptions,
|
|
403
|
+
body: rawBody,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
407
|
+
Body: body,
|
|
408
|
+
BodyForAgent: rawBody,
|
|
409
|
+
RawBody: rawBody,
|
|
410
|
+
CommandBody: rawBody,
|
|
411
|
+
From: `xmtp:${senderAddress}`,
|
|
412
|
+
To: `xmtp:${account.address}`,
|
|
413
|
+
SessionKey: route.sessionKey,
|
|
414
|
+
AccountId: route.accountId,
|
|
415
|
+
ChatType: "direct" as const,
|
|
416
|
+
ConversationLabel: senderAddress,
|
|
417
|
+
SenderName: senderAddress,
|
|
418
|
+
SenderId: senderAddress,
|
|
419
|
+
Provider: "xmtp",
|
|
420
|
+
Surface: "xmtp",
|
|
421
|
+
MessageSid: messageId,
|
|
422
|
+
MessageSidFull: messageId,
|
|
423
|
+
ReplyToId: replyContext?.referenceId,
|
|
424
|
+
ReplyToIdFull: replyContext?.referenceId,
|
|
425
|
+
ReplyToBody: replyContext?.referencedText,
|
|
426
|
+
OriginatingChannel: "xmtp",
|
|
427
|
+
OriginatingTo: `xmtp:${account.address}`,
|
|
428
|
+
CommandAuthorized: commandGate.commandAuthorized,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
ctx.log?.debug?.(
|
|
432
|
+
`[${account.accountId}] xmtp inbound: sender=${senderAddress} sid=${messageId} len=${rawBody.length} ${allowMatchMeta} preview="${previewText(rawBody)}"`,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
|
436
|
+
agentId: route.agentId,
|
|
437
|
+
});
|
|
438
|
+
await runtime.channel.session.recordInboundSession({
|
|
439
|
+
storePath,
|
|
440
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
441
|
+
ctx: ctxPayload,
|
|
442
|
+
onRecordError: (err) => {
|
|
443
|
+
ctx.log?.error?.(`[${account.accountId}] session record failed: ${String(err)}`);
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
448
|
+
cfg,
|
|
449
|
+
channel: "xmtp",
|
|
450
|
+
accountId: account.accountId,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
454
|
+
cfg,
|
|
455
|
+
agentId: route.agentId,
|
|
456
|
+
channel: "xmtp",
|
|
457
|
+
accountId: account.accountId,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
461
|
+
ctx: ctxPayload,
|
|
462
|
+
cfg,
|
|
463
|
+
dispatcherOptions: {
|
|
464
|
+
...prefixOptions,
|
|
465
|
+
deliver: async (payload: ReplyPayload) => {
|
|
466
|
+
const message = runtime.channel.text.convertMarkdownTables(
|
|
467
|
+
payload.text ?? "",
|
|
468
|
+
tableMode,
|
|
469
|
+
);
|
|
470
|
+
if (message) {
|
|
471
|
+
const replyTarget =
|
|
472
|
+
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
|
473
|
+
if (replyTarget) {
|
|
474
|
+
await bus.sendReply(conversationId, message, replyTarget);
|
|
475
|
+
} else {
|
|
476
|
+
await bus.sendText(conversationId, message);
|
|
477
|
+
}
|
|
478
|
+
runtime.channel.activity.record({
|
|
479
|
+
channel: "xmtp",
|
|
480
|
+
accountId: account.accountId,
|
|
481
|
+
direction: "outbound",
|
|
482
|
+
});
|
|
483
|
+
ctx.log?.debug?.(
|
|
484
|
+
`[${account.accountId}] xmtp outbound: to=${conversationId} len=${message.length} preview="${previewText(message, 160)}"`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
onError: (err, info) => {
|
|
489
|
+
ctx.log?.error?.(
|
|
490
|
+
`[${account.accountId}] xmtp ${info.kind} reply failed: ${String(err)}`,
|
|
491
|
+
);
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
replyOptions: {
|
|
495
|
+
onModelSelected,
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
},
|
|
499
|
+
onError: (error, context) => {
|
|
500
|
+
ctx.log?.error?.(`[${account.accountId}] XMTP error (${context}): ${error.message}`);
|
|
501
|
+
},
|
|
502
|
+
onConnect: () => {
|
|
503
|
+
ctx.log?.info?.(`[${account.accountId}] XMTP agent connected (env: ${account.env})`);
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
activeBuses.set(account.accountId, bus);
|
|
508
|
+
|
|
509
|
+
ctx.log?.info(`[${account.accountId}] XMTP provider started (address: ${bus.getAddress()})`);
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
stop: async () => {
|
|
513
|
+
await bus.close();
|
|
514
|
+
activeBuses.delete(account.accountId);
|
|
515
|
+
ctx.log?.info(`[${account.accountId}] XMTP provider stopped`);
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const XmtpConfigSchema = z.object({
|
|
5
|
+
name: z.string().optional(),
|
|
6
|
+
enabled: z.boolean().optional(),
|
|
7
|
+
markdown: MarkdownConfigSchema,
|
|
8
|
+
|
|
9
|
+
walletKey: z.string().optional(),
|
|
10
|
+
walletKeyFile: z.string().optional(),
|
|
11
|
+
dbEncryptionKey: z.string().optional(),
|
|
12
|
+
dbEncryptionKeyFile: z.string().optional(),
|
|
13
|
+
|
|
14
|
+
env: z.enum(["local", "dev", "production"]).optional(),
|
|
15
|
+
dbPath: z.string().optional(),
|
|
16
|
+
|
|
17
|
+
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
|
18
|
+
allowFrom: z.array(z.string()).optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type XmtpConfig = z.infer<typeof XmtpConfigSchema>;
|
|
22
|
+
|
|
23
|
+
export const xmtpChannelConfigSchema = buildChannelConfigSchema(XmtpConfigSchema);
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setXmtpRuntime(next: PluginRuntime): void {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getXmtpRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("XMTP runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
4
|
+
|
|
5
|
+
export interface XmtpAccountConfig {
|
|
6
|
+
enabled?: boolean;
|
|
7
|
+
name?: string;
|
|
8
|
+
walletKey?: string;
|
|
9
|
+
walletKeyFile?: string;
|
|
10
|
+
dbEncryptionKey?: string;
|
|
11
|
+
dbEncryptionKeyFile?: string;
|
|
12
|
+
env?: "local" | "dev" | "production";
|
|
13
|
+
dbPath?: string;
|
|
14
|
+
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
15
|
+
allowFrom?: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type XmtpSecretSource = "env" | "secretFile" | "config" | "none";
|
|
19
|
+
|
|
20
|
+
export interface ResolvedXmtpAccount {
|
|
21
|
+
accountId: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
configured: boolean;
|
|
25
|
+
walletKey: string;
|
|
26
|
+
walletKeySource: XmtpSecretSource;
|
|
27
|
+
dbEncryptionKey: string;
|
|
28
|
+
dbEncryptionKeySource: XmtpSecretSource;
|
|
29
|
+
address: string;
|
|
30
|
+
env: "local" | "dev" | "production";
|
|
31
|
+
config: XmtpAccountConfig;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
35
|
+
const DEFAULT_ENV = "production" as const;
|
|
36
|
+
|
|
37
|
+
function deriveAddressFromKey(walletKey: string): string {
|
|
38
|
+
try {
|
|
39
|
+
const account = privateKeyToAccount(walletKey as `0x${string}`);
|
|
40
|
+
return account.address.toLowerCase();
|
|
41
|
+
} catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function listXmtpAccountIds(cfg: OpenClawConfig): string[] {
|
|
47
|
+
const xmtpCfg = (cfg.channels as Record<string, unknown> | undefined)?.xmtp as
|
|
48
|
+
| XmtpAccountConfig
|
|
49
|
+
| undefined;
|
|
50
|
+
|
|
51
|
+
const hasConfig =
|
|
52
|
+
Boolean(xmtpCfg?.walletKey?.trim()) ||
|
|
53
|
+
Boolean(xmtpCfg?.walletKeyFile?.trim()) ||
|
|
54
|
+
Boolean(xmtpCfg?.dbEncryptionKey?.trim()) ||
|
|
55
|
+
Boolean(xmtpCfg?.dbEncryptionKeyFile?.trim());
|
|
56
|
+
const hasEnv =
|
|
57
|
+
Boolean(process.env.XMTP_WALLET_KEY?.trim()) ||
|
|
58
|
+
Boolean(process.env.XMTP_DB_ENCRYPTION_KEY?.trim());
|
|
59
|
+
|
|
60
|
+
if (hasConfig || hasEnv) {
|
|
61
|
+
return [DEFAULT_ACCOUNT_ID];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolveDefaultXmtpAccountId(cfg: OpenClawConfig): string {
|
|
68
|
+
const ids = listXmtpAccountIds(cfg);
|
|
69
|
+
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
|
70
|
+
return DEFAULT_ACCOUNT_ID;
|
|
71
|
+
}
|
|
72
|
+
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveXmtpAccount(opts: {
|
|
76
|
+
cfg: OpenClawConfig;
|
|
77
|
+
accountId?: string | null;
|
|
78
|
+
}): ResolvedXmtpAccount {
|
|
79
|
+
const accountId = opts.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
80
|
+
const xmtpCfg = (opts.cfg.channels as Record<string, unknown> | undefined)?.xmtp as
|
|
81
|
+
| XmtpAccountConfig
|
|
82
|
+
| undefined;
|
|
83
|
+
|
|
84
|
+
const baseEnabled = xmtpCfg?.enabled !== false;
|
|
85
|
+
const walletKeyResolution = resolveWalletKey(accountId, xmtpCfg);
|
|
86
|
+
const dbEncryptionKeyResolution = resolveDbEncryptionKey(accountId, xmtpCfg);
|
|
87
|
+
const configured = Boolean(walletKeyResolution.secret && dbEncryptionKeyResolution.secret);
|
|
88
|
+
|
|
89
|
+
let address = "";
|
|
90
|
+
if (configured) {
|
|
91
|
+
address = deriveAddressFromKey(walletKeyResolution.secret);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
accountId,
|
|
96
|
+
name: xmtpCfg?.name?.trim() || undefined,
|
|
97
|
+
enabled: baseEnabled,
|
|
98
|
+
configured,
|
|
99
|
+
walletKey: walletKeyResolution.secret,
|
|
100
|
+
walletKeySource: walletKeyResolution.source,
|
|
101
|
+
dbEncryptionKey: dbEncryptionKeyResolution.secret,
|
|
102
|
+
dbEncryptionKeySource: dbEncryptionKeyResolution.source,
|
|
103
|
+
address,
|
|
104
|
+
env: xmtpCfg?.env ?? DEFAULT_ENV,
|
|
105
|
+
config: {
|
|
106
|
+
enabled: xmtpCfg?.enabled,
|
|
107
|
+
name: xmtpCfg?.name,
|
|
108
|
+
walletKey: xmtpCfg?.walletKey,
|
|
109
|
+
walletKeyFile: xmtpCfg?.walletKeyFile,
|
|
110
|
+
dbEncryptionKey: xmtpCfg?.dbEncryptionKey,
|
|
111
|
+
dbEncryptionKeyFile: xmtpCfg?.dbEncryptionKeyFile,
|
|
112
|
+
env: xmtpCfg?.env,
|
|
113
|
+
dbPath: xmtpCfg?.dbPath,
|
|
114
|
+
dmPolicy: xmtpCfg?.dmPolicy,
|
|
115
|
+
allowFrom: xmtpCfg?.allowFrom,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveWalletKey(
|
|
121
|
+
accountId: string,
|
|
122
|
+
cfg?: XmtpAccountConfig,
|
|
123
|
+
): { secret: string; source: XmtpSecretSource } {
|
|
124
|
+
const walletKeyFile = cfg?.walletKeyFile?.trim();
|
|
125
|
+
if (walletKeyFile) {
|
|
126
|
+
try {
|
|
127
|
+
const secret = readFileSync(walletKeyFile, "utf-8").trim();
|
|
128
|
+
if (secret) {
|
|
129
|
+
return { secret, source: "secretFile" };
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
return { secret: "", source: "none" };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const configKey = cfg?.walletKey?.trim();
|
|
137
|
+
if (configKey) {
|
|
138
|
+
return { secret: configKey, source: "config" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
142
|
+
const envKey = process.env.XMTP_WALLET_KEY?.trim();
|
|
143
|
+
if (envKey) {
|
|
144
|
+
return { secret: envKey, source: "env" };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { secret: "", source: "none" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function resolveDbEncryptionKey(
|
|
152
|
+
accountId: string,
|
|
153
|
+
cfg?: XmtpAccountConfig,
|
|
154
|
+
): { secret: string; source: XmtpSecretSource } {
|
|
155
|
+
const secretFile = cfg?.dbEncryptionKeyFile?.trim();
|
|
156
|
+
if (secretFile) {
|
|
157
|
+
try {
|
|
158
|
+
const secret = readFileSync(secretFile, "utf-8").trim();
|
|
159
|
+
if (secret) {
|
|
160
|
+
return { secret, source: "secretFile" };
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
return { secret: "", source: "none" };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const configKey = cfg?.dbEncryptionKey?.trim();
|
|
168
|
+
if (configKey) {
|
|
169
|
+
return { secret: configKey, source: "config" };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
173
|
+
const envKey = process.env.XMTP_DB_ENCRYPTION_KEY?.trim();
|
|
174
|
+
if (envKey) {
|
|
175
|
+
return { secret: envKey, source: "env" };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { secret: "", source: "none" };
|
|
180
|
+
}
|
package/src/xmtp-bus.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Agent, createSigner, createUser } from "@xmtp/agent-sdk";
|
|
5
|
+
import { getXmtpRuntime } from "./runtime.js";
|
|
6
|
+
|
|
7
|
+
export type XmtpReplyContext = {
|
|
8
|
+
referenceId: string;
|
|
9
|
+
referencedText?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface XmtpBusOptions {
|
|
13
|
+
accountId?: string;
|
|
14
|
+
walletKey: string;
|
|
15
|
+
dbEncryptionKey: string;
|
|
16
|
+
env: "local" | "dev" | "production";
|
|
17
|
+
dbPath?: string;
|
|
18
|
+
// Default to auto-consenting DMs so pairing flows can proceed.
|
|
19
|
+
shouldConsentDm: (senderAddress: string) => boolean;
|
|
20
|
+
onMessage: (params: {
|
|
21
|
+
senderAddress: string;
|
|
22
|
+
senderInboxId: string;
|
|
23
|
+
conversationId: string;
|
|
24
|
+
isDm: boolean;
|
|
25
|
+
text: string;
|
|
26
|
+
messageId: string;
|
|
27
|
+
replyContext?: XmtpReplyContext;
|
|
28
|
+
}) => Promise<void>;
|
|
29
|
+
onError?: (error: Error, context: string) => void;
|
|
30
|
+
onConnect?: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface XmtpBusHandle {
|
|
34
|
+
sendText(target: string, text: string): Promise<void>;
|
|
35
|
+
sendReply(target: string, text: string, referenceMessageId: string): Promise<void>;
|
|
36
|
+
getAddress(): string;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type XmtpConversationHandle = {
|
|
41
|
+
sendText: (text: string) => Promise<unknown>;
|
|
42
|
+
sendReply?: (reply: unknown) => Promise<unknown>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type XmtpAgentHandle = Awaited<ReturnType<typeof Agent.create>>;
|
|
46
|
+
|
|
47
|
+
type XmtpInboundMessageContext = {
|
|
48
|
+
getSenderAddress: () => Promise<string | null>;
|
|
49
|
+
message: {
|
|
50
|
+
senderInboxId: string;
|
|
51
|
+
content: unknown;
|
|
52
|
+
id: string;
|
|
53
|
+
};
|
|
54
|
+
conversation: {
|
|
55
|
+
id: string;
|
|
56
|
+
};
|
|
57
|
+
isDm: () => boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function looksLikeEthAddress(value: string): boolean {
|
|
61
|
+
return /^0x[0-9a-fA-F]{40}$/.test(value.trim());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function resolveConversationForTarget(
|
|
65
|
+
agent: XmtpAgentHandle,
|
|
66
|
+
target: string,
|
|
67
|
+
): Promise<XmtpConversationHandle> {
|
|
68
|
+
const trimmedTarget = target.trim();
|
|
69
|
+
if (!trimmedTarget) {
|
|
70
|
+
throw new Error("Target is required");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (looksLikeEthAddress(trimmedTarget)) {
|
|
74
|
+
const normalizedAddress = normalizeEthAddress(trimmedTarget);
|
|
75
|
+
const agentWithAddressDm = agent as XmtpAgentHandle & {
|
|
76
|
+
createDmWithAddress?: (address: string) => Promise<XmtpConversationHandle | null>;
|
|
77
|
+
};
|
|
78
|
+
if (typeof agentWithAddressDm.createDmWithAddress === "function") {
|
|
79
|
+
const dmConversation = await agentWithAddressDm.createDmWithAddress(normalizedAddress);
|
|
80
|
+
if (dmConversation) {
|
|
81
|
+
return dmConversation;
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`Conversation not found for address: ${normalizedAddress}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const conversationsWithAddressDm = agent.client.conversations as {
|
|
87
|
+
createDmWithAddress?: (address: string) => Promise<XmtpConversationHandle | null>;
|
|
88
|
+
};
|
|
89
|
+
if (typeof conversationsWithAddressDm.createDmWithAddress === "function") {
|
|
90
|
+
const dmConversation =
|
|
91
|
+
await conversationsWithAddressDm.createDmWithAddress(normalizedAddress);
|
|
92
|
+
if (dmConversation) {
|
|
93
|
+
return dmConversation;
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`Conversation not found for address: ${normalizedAddress}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
throw new Error("XMTP SDK does not support address-based DM creation");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const conversation = await agent.client.conversations.getConversationById(trimmedTarget);
|
|
102
|
+
if (!conversation) {
|
|
103
|
+
throw new Error(`Conversation not found: ${trimmedTarget}`);
|
|
104
|
+
}
|
|
105
|
+
return conversation as XmtpConversationHandle;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveDbDirectory(env: string, configDbPath?: string): string {
|
|
109
|
+
if (configDbPath) {
|
|
110
|
+
const resolved = configDbPath.replace(/^~/, os.homedir());
|
|
111
|
+
fs.mkdirSync(resolved, { recursive: true, mode: 0o700 });
|
|
112
|
+
return resolved;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const runtime = getXmtpRuntime();
|
|
116
|
+
const stateDir = runtime.state.resolveStateDir(process.env, os.homedir);
|
|
117
|
+
const dbDir = path.join(stateDir, "channels", "xmtp", env);
|
|
118
|
+
fs.mkdirSync(dbDir, { recursive: true, mode: 0o700 });
|
|
119
|
+
return dbDir;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function extractTextFromMessageContent(content: unknown): string | undefined {
|
|
123
|
+
if (typeof content === "string") {
|
|
124
|
+
return content;
|
|
125
|
+
}
|
|
126
|
+
if (content && typeof content === "object") {
|
|
127
|
+
const nestedContent = (content as { content?: unknown }).content;
|
|
128
|
+
if (typeof nestedContent === "string") {
|
|
129
|
+
return nestedContent;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function extractReplyContext(content: unknown): XmtpReplyContext | undefined {
|
|
136
|
+
if (!content || typeof content !== "object") {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const referenceIdRaw = (content as { referenceId?: unknown }).referenceId;
|
|
141
|
+
if (typeof referenceIdRaw !== "string" || !referenceIdRaw.trim()) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const inReplyTo = (content as { inReplyTo?: unknown }).inReplyTo;
|
|
146
|
+
const referencedText = extractTextFromMessageContent(inReplyTo);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
referenceId: referenceIdRaw,
|
|
150
|
+
...(referencedText ? { referencedText } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function startXmtpBus(options: XmtpBusOptions): Promise<XmtpBusHandle> {
|
|
155
|
+
const {
|
|
156
|
+
walletKey,
|
|
157
|
+
dbEncryptionKey,
|
|
158
|
+
env,
|
|
159
|
+
dbPath: configDbPath,
|
|
160
|
+
shouldConsentDm = () => true,
|
|
161
|
+
onMessage,
|
|
162
|
+
onError,
|
|
163
|
+
onConnect,
|
|
164
|
+
} = options;
|
|
165
|
+
|
|
166
|
+
const dbDir = resolveDbDirectory(env, configDbPath);
|
|
167
|
+
const user = createUser(walletKey as `0x${string}`);
|
|
168
|
+
const signer = createSigner(user);
|
|
169
|
+
|
|
170
|
+
const normalizedEncryptionKey = dbEncryptionKey.startsWith("0x")
|
|
171
|
+
? dbEncryptionKey
|
|
172
|
+
: `0x${dbEncryptionKey}`;
|
|
173
|
+
|
|
174
|
+
const agent = await Agent.create(signer, {
|
|
175
|
+
env,
|
|
176
|
+
dbEncryptionKey: normalizedEncryptionKey as `0x${string}`,
|
|
177
|
+
dbPath: (inboxId: string) => path.join(dbDir, `xmtp-${inboxId}.db3`),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const agentAddress = agent.address ?? user.account.address.toLowerCase();
|
|
181
|
+
|
|
182
|
+
agent.on("conversation", async (ctx) => {
|
|
183
|
+
try {
|
|
184
|
+
if (ctx.isDm()) {
|
|
185
|
+
const senderAddress = await (
|
|
186
|
+
ctx as { getSenderAddress?: () => Promise<string | null> }
|
|
187
|
+
).getSenderAddress?.();
|
|
188
|
+
type ConsentState = Parameters<NonNullable<typeof ctx.conversation.updateConsentState>>[0];
|
|
189
|
+
const conversation = ctx.conversation as unknown as {
|
|
190
|
+
updateConsentState: (state: ConsentState) => void;
|
|
191
|
+
};
|
|
192
|
+
if (!senderAddress) {
|
|
193
|
+
conversation.updateConsentState("allowed" as unknown as ConsentState);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (shouldConsentDm(senderAddress.toLowerCase())) {
|
|
197
|
+
conversation.updateConsentState("allowed" as unknown as ConsentState);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
onError?.(err as Error, "auto-consent conversation");
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
agent.on("text", async (ctx) => {
|
|
206
|
+
try {
|
|
207
|
+
const typedCtx = ctx as XmtpInboundMessageContext;
|
|
208
|
+
const senderAddressRaw = await typedCtx.getSenderAddress();
|
|
209
|
+
if (!senderAddressRaw) {
|
|
210
|
+
throw new Error("XMTP message missing sender address");
|
|
211
|
+
}
|
|
212
|
+
const senderAddress = senderAddressRaw.toLowerCase();
|
|
213
|
+
const senderInboxId = typedCtx.message.senderInboxId;
|
|
214
|
+
const conversationId = typedCtx.conversation.id;
|
|
215
|
+
const isDm = typedCtx.isDm();
|
|
216
|
+
const text = typedCtx.message.content as string;
|
|
217
|
+
const messageId = typedCtx.message.id;
|
|
218
|
+
|
|
219
|
+
if (!isDm) return;
|
|
220
|
+
|
|
221
|
+
await onMessage({
|
|
222
|
+
senderAddress,
|
|
223
|
+
senderInboxId,
|
|
224
|
+
conversationId,
|
|
225
|
+
isDm,
|
|
226
|
+
text,
|
|
227
|
+
messageId,
|
|
228
|
+
});
|
|
229
|
+
} catch (err) {
|
|
230
|
+
onError?.(err as Error, "handle text message");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
(
|
|
235
|
+
agent as {
|
|
236
|
+
on: (event: string, handler: (ctx: unknown) => Promise<void>) => void;
|
|
237
|
+
}
|
|
238
|
+
).on("reply", async (ctx) => {
|
|
239
|
+
try {
|
|
240
|
+
const typedCtx = ctx as XmtpInboundMessageContext;
|
|
241
|
+
const senderAddressRaw = await typedCtx.getSenderAddress();
|
|
242
|
+
if (!senderAddressRaw) {
|
|
243
|
+
throw new Error("XMTP message missing sender address");
|
|
244
|
+
}
|
|
245
|
+
const senderAddress = senderAddressRaw.toLowerCase();
|
|
246
|
+
const senderInboxId = typedCtx.message.senderInboxId;
|
|
247
|
+
const conversationId = typedCtx.conversation.id;
|
|
248
|
+
const isDm = typedCtx.isDm();
|
|
249
|
+
const messageId = typedCtx.message.id;
|
|
250
|
+
const replyContent = typedCtx.message.content;
|
|
251
|
+
const text = extractTextFromMessageContent(replyContent);
|
|
252
|
+
|
|
253
|
+
if (!isDm) return;
|
|
254
|
+
if (!text?.trim()) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await onMessage({
|
|
259
|
+
senderAddress,
|
|
260
|
+
senderInboxId,
|
|
261
|
+
conversationId,
|
|
262
|
+
isDm,
|
|
263
|
+
text,
|
|
264
|
+
messageId,
|
|
265
|
+
replyContext: extractReplyContext(replyContent),
|
|
266
|
+
});
|
|
267
|
+
} catch (err) {
|
|
268
|
+
onError?.(err as Error, "handle reply message");
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
agent.on("unhandledError", (error) => {
|
|
273
|
+
onError?.(error, "unhandled agent error");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await agent.start();
|
|
277
|
+
onConnect?.();
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
async sendText(target: string, text: string): Promise<void> {
|
|
281
|
+
const conversation = await resolveConversationForTarget(agent, target);
|
|
282
|
+
await conversation.sendText(text);
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
async sendReply(target: string, text: string, referenceMessageId: string): Promise<void> {
|
|
286
|
+
const trimmedReferenceMessageId = referenceMessageId.trim();
|
|
287
|
+
if (!trimmedReferenceMessageId) {
|
|
288
|
+
throw new Error("referenceMessageId is required");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const conversation = await resolveConversationForTarget(agent, target);
|
|
292
|
+
if (typeof conversation.sendReply === "function") {
|
|
293
|
+
await conversation.sendReply({
|
|
294
|
+
content: text,
|
|
295
|
+
referenceId: trimmedReferenceMessageId,
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await conversation.sendText(text);
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
getAddress(): string {
|
|
304
|
+
return agentAddress;
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
async close(): Promise<void> {
|
|
308
|
+
await agent.stop();
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function normalizeEthAddress(input: string): string {
|
|
314
|
+
const trimmed = input.trim().toLowerCase();
|
|
315
|
+
if (!/^0x[0-9a-f]{40}$/.test(trimmed)) {
|
|
316
|
+
throw new Error("Invalid Ethereum address: must be 0x-prefixed 40 hex chars");
|
|
317
|
+
}
|
|
318
|
+
return trimmed;
|
|
319
|
+
}
|