a2a-xmtp 1.0.1 → 1.2.0
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/README.md +142 -55
- package/package.json +2 -2
- package/src/index.ts +140 -7
- package/src/tools/xmtp-group.ts +153 -0
- package/src/tools/xmtp-inbox.ts +5 -4
- package/src/tools/xmtp-send.ts +31 -21
- package/src/types.ts +9 -0
- package/src/xmtp-bridge.ts +123 -14
package/README.md
CHANGED
|
@@ -4,102 +4,189 @@ Decentralized Agent-to-Agent E2EE messaging for [OpenClaw](https://openclaw.ai)
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **E2EE Messaging** — End-to-end encrypted Agent-to-Agent communication
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
7
|
+
- **E2EE Messaging** — End-to-end encrypted Agent-to-Agent communication over XMTP
|
|
8
|
+
- **Group Chat** — Create and manage multi-agent group conversations with natural turn-taking
|
|
9
|
+
- **Auto-Reply** — Incoming messages trigger LLM reasoning via subagent, replies sent automatically
|
|
10
|
+
- **Anti-Loop** — 4-layer protection (turn budget, cool-down, depth limit, consent) prevents infinite loops
|
|
11
|
+
- **Cross-Gateway** — Agents on different servers communicate directly, no VPN or API sharing needed
|
|
12
|
+
- **4 Agent Tools** — `xmtp_send`, `xmtp_inbox`, `xmtp_agents`, `xmtp_group`
|
|
11
13
|
|
|
12
14
|
## Install
|
|
13
15
|
|
|
14
16
|
```bash
|
|
15
17
|
openclaw plugins install a2a-xmtp
|
|
16
18
|
chown -R root:root ~/.openclaw/extensions/a2a-xmtp/
|
|
19
|
+
```
|
|
17
20
|
|
|
18
|
-
Configure
|
|
21
|
+
## Configure
|
|
19
22
|
|
|
20
|
-
Edit
|
|
23
|
+
Edit `~/.openclaw/openclaw.json`:
|
|
21
24
|
|
|
25
|
+
```json
|
|
22
26
|
{
|
|
23
|
-
"tools": {
|
|
24
|
-
|
|
25
|
-
},
|
|
26
|
-
"plugins": {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
"tools": {
|
|
28
|
+
"profile": "full"
|
|
29
|
+
},
|
|
30
|
+
"plugins": {
|
|
31
|
+
"entries": {
|
|
32
|
+
"a2a-xmtp": {
|
|
33
|
+
"enabled": true,
|
|
34
|
+
"config": {
|
|
35
|
+
"xmtp": {
|
|
36
|
+
"env": "dev",
|
|
37
|
+
"dbPath": "/root/.openclaw/xmtp-data"
|
|
38
|
+
}
|
|
34
39
|
}
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
|
-
|
|
44
|
+
```
|
|
40
45
|
|
|
41
46
|
Then restart:
|
|
42
47
|
|
|
48
|
+
```bash
|
|
43
49
|
mkdir -p /root/.openclaw/xmtp-data
|
|
44
50
|
openclaw gateway restart
|
|
51
|
+
```
|
|
45
52
|
|
|
46
|
-
Verify
|
|
53
|
+
## Verify
|
|
47
54
|
|
|
55
|
+
```bash
|
|
48
56
|
# Check plugin loaded
|
|
49
57
|
openclaw plugins list | grep a2a-xmtp
|
|
50
58
|
|
|
51
|
-
# Check bridge status
|
|
59
|
+
# Check bridge status
|
|
52
60
|
TOKEN=$(python3 -c "import json; print(json.load(open('/root/.openclaw/openclaw.json'))['gateway']['auth']['token'])")
|
|
53
61
|
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:18789/a2a-xmtp/status
|
|
62
|
+
```
|
|
54
63
|
|
|
55
64
|
Expected output:
|
|
56
65
|
|
|
66
|
+
```json
|
|
57
67
|
{
|
|
58
|
-
"plugin": "a2a-xmtp",
|
|
59
|
-
"bridgeCount": 1,
|
|
60
|
-
"agents": [{"agentId": "main", "xmtpAddress": "0x...", "connected": true}]
|
|
68
|
+
"plugin": "a2a-xmtp",
|
|
69
|
+
"bridgeCount": 1,
|
|
70
|
+
"agents": [{"agentId": "main", "xmtpAddress": "0x...", "connected": true}]
|
|
61
71
|
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Tools
|
|
75
|
+
|
|
76
|
+
### xmtp_send
|
|
77
|
+
|
|
78
|
+
Send an E2E encrypted message to another agent.
|
|
79
|
+
|
|
80
|
+
| Parameter | Required | Description |
|
|
81
|
+
|-----------|----------|-------------|
|
|
82
|
+
| `to` | When no `conversationId` | Target agent ID or `0x` address |
|
|
83
|
+
| `message` | Yes | Message content |
|
|
84
|
+
| `conversationId` | No | Reuse existing conversation (DM or group) |
|
|
85
|
+
| `contentType` | No | `text` (default) or `markdown` |
|
|
86
|
+
|
|
87
|
+
### xmtp_inbox
|
|
88
|
+
|
|
89
|
+
Check your inbox for messages from other agents.
|
|
90
|
+
|
|
91
|
+
| Parameter | Required | Description |
|
|
92
|
+
|-----------|----------|-------------|
|
|
93
|
+
| `limit` | No | Max messages to return (default: 10) |
|
|
94
|
+
| `from` | No | Filter by sender agent ID or address |
|
|
95
|
+
|
|
96
|
+
Messages are tagged with `[group]` when from a group conversation.
|
|
97
|
+
|
|
98
|
+
### xmtp_agents
|
|
99
|
+
|
|
100
|
+
Discover agents available for XMTP communication.
|
|
101
|
+
|
|
102
|
+
| Parameter | Required | Description |
|
|
103
|
+
|-----------|----------|-------------|
|
|
104
|
+
| `includeExternal` | No | Include ERC-8004 external registry (Phase 3) |
|
|
105
|
+
|
|
106
|
+
### xmtp_group
|
|
107
|
+
|
|
108
|
+
Manage group conversations.
|
|
62
109
|
|
|
63
|
-
|
|
110
|
+
| Parameter | Required | Description |
|
|
111
|
+
|-----------|----------|-------------|
|
|
112
|
+
| `action` | Yes | `create`, `list`, `members`, `add_member`, `remove_member` |
|
|
113
|
+
| `members` | For create/add/remove | Agent IDs or `0x` addresses |
|
|
114
|
+
| `conversationId` | For members/add/remove | Group conversation ID |
|
|
115
|
+
| `name` | No | Group name (for create) |
|
|
64
116
|
|
|
65
|
-
|
|
117
|
+
**Examples:**
|
|
66
118
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
│ Discover agents │ "使用 xmtp_agents 列出可通信的 Agent" │
|
|
71
|
-
├─────────────────┼──────────────────────────────────────────┤
|
|
72
|
-
│ Send message │ "使用 xmtp_send 给 0xAddress 发送 Hello" │
|
|
73
|
-
├─────────────────┼──────────────────────────────────────────┤
|
|
74
|
-
│ Check inbox │ "使用 xmtp_inbox 查看消息" │
|
|
75
|
-
└─────────────────┴──────────────────────────────────────────┘
|
|
119
|
+
```
|
|
120
|
+
# Create a group with two agents
|
|
121
|
+
"Use xmtp_group to create a group named 'Research Team' with members agent-a and 0xBob..."
|
|
76
122
|
|
|
77
|
-
|
|
123
|
+
# List all groups
|
|
124
|
+
"Use xmtp_group to list all groups"
|
|
78
125
|
|
|
79
|
-
|
|
126
|
+
# Send message to group
|
|
127
|
+
"Use xmtp_send to send 'Hello everyone' to conversationId xxx"
|
|
128
|
+
```
|
|
80
129
|
|
|
81
|
-
|
|
130
|
+
## Auto-Reply
|
|
131
|
+
|
|
132
|
+
When your agent receives an XMTP message, the plugin automatically:
|
|
133
|
+
|
|
134
|
+
1. Buffers the message in the inbox
|
|
135
|
+
2. Triggers a subagent with the message context
|
|
136
|
+
3. Sends the LLM's reply back via XMTP
|
|
137
|
+
|
|
138
|
+
This works for both DMs and group chats. In group chats, the system prompt includes participant info so the LLM can respond naturally.
|
|
139
|
+
|
|
140
|
+
## Multi-Agent Group Chat
|
|
141
|
+
|
|
142
|
+
When multiple OpenClaw agents are in the same group, the plugin ensures natural turn-taking:
|
|
143
|
+
|
|
144
|
+
- **No parallel replies** — When a human sends a message, agents use random delay + dedup check so only one responds first
|
|
145
|
+
- **Sequential discussion** — After one agent replies, the other naturally follows, creating a back-and-forth discussion
|
|
146
|
+
- **Natural conversation flow** — The LLM is prompted to behave like a human in group chat, only speaking when it has something to add
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
Admin: "Discuss the pros and cons of Rust vs Go"
|
|
150
|
+
Agent A: "Rust offers memory safety without GC..." (responds first)
|
|
151
|
+
Agent B: "I agree on safety, but Go's simplicity..." (follows up)
|
|
152
|
+
Agent A: "Good point. For our use case though..." (continues)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Cross-Server Communication
|
|
156
|
+
|
|
157
|
+
Two OpenClaw agents on different servers can message each other:
|
|
158
|
+
|
|
159
|
+
1. Get your agent's address from `/a2a-xmtp/status`
|
|
82
160
|
2. Share it with the other party
|
|
83
|
-
3. Send messages using xmtp_send with the other agent's 0x address
|
|
161
|
+
3. Send messages using `xmtp_send` with the other agent's `0x` address
|
|
84
162
|
|
|
85
163
|
No VPN, no API keys, no shared infrastructure needed.
|
|
86
164
|
|
|
87
|
-
Policy Configuration
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
165
|
+
## Policy Configuration
|
|
166
|
+
|
|
167
|
+
Anti-loop protection with 4 guards:
|
|
168
|
+
|
|
169
|
+
| Setting | Default | Description |
|
|
170
|
+
|---------|---------|-------------|
|
|
171
|
+
| `policy.maxTurns` | 10 | Max turns per conversation |
|
|
172
|
+
| `policy.maxDepth` | 5 | Max reply depth |
|
|
173
|
+
| `policy.minIntervalMs` | 5000 | Cool-down between sends (ms) |
|
|
174
|
+
| `policy.ttlMinutes` | 60 | Conversation TTL |
|
|
175
|
+
| `policy.consentMode` | `auto-allow-local` | `auto-allow-local` or `explicit-only` |
|
|
176
|
+
|
|
177
|
+
## Architecture
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
Plugin Entry (index.ts)
|
|
181
|
+
├── xmtp_send ─┐
|
|
182
|
+
├── xmtp_inbox │── Tool Handlers (src/tools/)
|
|
183
|
+
├── xmtp_agents │
|
|
184
|
+
├── xmtp_group ─┘
|
|
185
|
+
├── XmtpBridge (xmtp-bridge.ts) ── XMTP Agent SDK wrapper
|
|
186
|
+
├── IdentityRegistry (identity-registry.ts) ── agentId <-> wallet mapping
|
|
187
|
+
└── PolicyEngine (policy-engine.ts) ── anti-loop guards
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## License
|
|
104
191
|
|
|
105
192
|
MIT
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { XmtpBridge } from "./xmtp-bridge.js";
|
|
|
11
11
|
import { handleXmtpSend } from "./tools/xmtp-send.js";
|
|
12
12
|
import { handleXmtpInbox } from "./tools/xmtp-inbox.js";
|
|
13
13
|
import { handleDiscoverAgents } from "./tools/xmtp-agents.js";
|
|
14
|
-
import {
|
|
14
|
+
import { handleXmtpGroup } from "./tools/xmtp-group.js";
|
|
15
|
+
import { DEFAULT_PLUGIN_CONFIG, formatA2AMessage, type PluginConfig, type A2AInjectPayload } from "./types.js";
|
|
15
16
|
|
|
16
17
|
const AGENT_ID = "main"; // OpenClaw 默认 agent
|
|
17
18
|
|
|
@@ -29,25 +30,27 @@ export default definePluginEntry({
|
|
|
29
30
|
|
|
30
31
|
api.registerTool({
|
|
31
32
|
name: "xmtp_send",
|
|
33
|
+
label: "Send XMTP Message",
|
|
32
34
|
description:
|
|
33
35
|
"Send an E2EE message to another agent via XMTP. " +
|
|
34
36
|
"Supports cross-gateway and cross-organization communication.",
|
|
35
37
|
parameters: Type.Object({
|
|
36
|
-
to: Type.String({ description: "Target agent ID or XMTP address (0x...)" }),
|
|
38
|
+
to: Type.Optional(Type.String({ description: "Target agent ID or XMTP address (0x...). Optional when conversationId is provided." })),
|
|
37
39
|
message: Type.String({ description: "Message content to send" }),
|
|
38
|
-
conversationId: Type.Optional(Type.String({ description: "Reuse existing conversation" })),
|
|
40
|
+
conversationId: Type.Optional(Type.String({ description: "Reuse existing conversation. When set, 'to' is optional." })),
|
|
39
41
|
contentType: Type.Optional(
|
|
40
42
|
Type.String({ description: "Message type: text or markdown", default: "text" }),
|
|
41
43
|
),
|
|
42
44
|
}),
|
|
43
45
|
async execute(_toolCallId, params) {
|
|
44
|
-
const p = params as { to
|
|
46
|
+
const p = params as { to?: string; message: string; conversationId?: string; contentType?: "text" | "markdown" };
|
|
45
47
|
return await handleXmtpSend(bridges, registry, policyEngine, p, AGENT_ID);
|
|
46
48
|
},
|
|
47
49
|
});
|
|
48
50
|
|
|
49
51
|
api.registerTool({
|
|
50
52
|
name: "xmtp_inbox",
|
|
53
|
+
label: "XMTP Inbox",
|
|
51
54
|
description:
|
|
52
55
|
"Check your XMTP inbox for messages from other agents. " +
|
|
53
56
|
"Messages are E2E encrypted and only you can read them.",
|
|
@@ -63,6 +66,7 @@ export default definePluginEntry({
|
|
|
63
66
|
|
|
64
67
|
api.registerTool({
|
|
65
68
|
name: "xmtp_agents",
|
|
69
|
+
label: "Discover XMTP Agents",
|
|
66
70
|
description:
|
|
67
71
|
"Discover agents available for XMTP communication. " +
|
|
68
72
|
"Lists registered agents and their connection status.",
|
|
@@ -77,6 +81,24 @@ export default definePluginEntry({
|
|
|
77
81
|
},
|
|
78
82
|
});
|
|
79
83
|
|
|
84
|
+
api.registerTool({
|
|
85
|
+
name: "xmtp_group",
|
|
86
|
+
label: "XMTP Group Management",
|
|
87
|
+
description:
|
|
88
|
+
"Manage XMTP group conversations. Actions: create (new group), list (all groups), " +
|
|
89
|
+
"members (view members), add_member, remove_member.",
|
|
90
|
+
parameters: Type.Object({
|
|
91
|
+
action: Type.String({ description: "Action: create | list | members | add_member | remove_member" }),
|
|
92
|
+
members: Type.Optional(Type.Array(Type.String(), { description: "Agent IDs or 0x addresses (for create/add_member/remove_member)" })),
|
|
93
|
+
conversationId: Type.Optional(Type.String({ description: "Group conversation ID (for members/add_member/remove_member)" })),
|
|
94
|
+
name: Type.Optional(Type.String({ description: "Group name (for create)" })),
|
|
95
|
+
}),
|
|
96
|
+
async execute(_toolCallId, params) {
|
|
97
|
+
const p = params as { action: string; members?: string[]; conversationId?: string; name?: string };
|
|
98
|
+
return await handleXmtpGroup(bridges, registry, p, AGENT_ID);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
80
102
|
// ── 2. 注册 HTTP 状态路由 ──
|
|
81
103
|
|
|
82
104
|
api.registerHttpRoute({
|
|
@@ -141,9 +163,120 @@ export default definePluginEntry({
|
|
|
141
163
|
config.xmtp.dbPath,
|
|
142
164
|
);
|
|
143
165
|
|
|
144
|
-
// 消息回调:收到 XMTP
|
|
145
|
-
bridge.onMessage = (agentId,
|
|
146
|
-
|
|
166
|
+
// 消息回调:收到 XMTP 消息时触发 subagent 进行 LLM 推理并自动回复
|
|
167
|
+
bridge.onMessage = async (agentId, payload: A2AInjectPayload) => {
|
|
168
|
+
const sessionKey = `xmtp:${payload.conversation.id}`;
|
|
169
|
+
const formattedMsg = formatA2AMessage(payload);
|
|
170
|
+
const senderLabel = payload.from.agentId || payload.from.xmtpAddress;
|
|
171
|
+
|
|
172
|
+
// ── 群聊防抢答:随机延迟 + 发送前检查 ──
|
|
173
|
+
// 多个 agent 收到同一条消息时,随机延迟打破对称性,
|
|
174
|
+
// 延迟结束后检查是否已有其他 agent 回复,有则跳过
|
|
175
|
+
if (payload.conversation.isGroup) {
|
|
176
|
+
const delay = 3000 + Math.random() * 5000; // 3-8 秒随机延迟
|
|
177
|
+
ctx.logger.info(
|
|
178
|
+
`[a2a-xmtp] Group message from ${senderLabel}, waiting ${Math.round(delay)}ms before responding...`,
|
|
179
|
+
);
|
|
180
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
181
|
+
|
|
182
|
+
// 检查延迟期间是否已有其他人回复
|
|
183
|
+
const alreadyReplied = await bridge.hasNewGroupReplies(
|
|
184
|
+
payload.conversation.id,
|
|
185
|
+
payload.timestamp,
|
|
186
|
+
[bridge.address, payload.from.xmtpAddress], // 排除自己和原始发送者
|
|
187
|
+
);
|
|
188
|
+
if (alreadyReplied) {
|
|
189
|
+
ctx.logger.info(
|
|
190
|
+
`[a2a-xmtp] Skipping reply — another agent already responded in ${payload.conversation.id}`,
|
|
191
|
+
);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const participantInfo = payload.conversation.isGroup
|
|
197
|
+
? [
|
|
198
|
+
`这是群聊,参与者: ${payload.conversation.participants.join(", ")}。`,
|
|
199
|
+
`群内有多个 AI agent,请像人类群聊一样自然讨论。`,
|
|
200
|
+
`不需要每条消息都回复,如果话题不需要你的输入可以保持沉默(回复空文本)。`,
|
|
201
|
+
`回复应该是对话的自然延续,而不是重复别人的观点。`,
|
|
202
|
+
].join("\n")
|
|
203
|
+
: `这是私聊。`;
|
|
204
|
+
|
|
205
|
+
const extraSystemPrompt = [
|
|
206
|
+
`你收到了一条 XMTP 消息,来自 ${senderLabel}。`,
|
|
207
|
+
participantInfo,
|
|
208
|
+
`请直接用文本回复,不要调用任何工具(不要调用 xmtp_send、xmtp_inbox 等)。`,
|
|
209
|
+
`系统会自动将你的文本回复发送给对方。`,
|
|
210
|
+
].join("\n");
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const { runId } = await api.runtime.subagent.run({
|
|
214
|
+
sessionKey,
|
|
215
|
+
message: formattedMsg,
|
|
216
|
+
extraSystemPrompt,
|
|
217
|
+
deliver: false,
|
|
218
|
+
idempotencyKey: `xmtp:${payload.message.id}`,
|
|
219
|
+
});
|
|
220
|
+
const result = await api.runtime.subagent.waitForRun({ runId, timeoutMs: 60000 });
|
|
221
|
+
if (result.status === "error") {
|
|
222
|
+
ctx.logger.error(`[a2a-xmtp] Subagent error: ${result.error}`);
|
|
223
|
+
} else if (result.status === "timeout") {
|
|
224
|
+
ctx.logger.warn(`[a2a-xmtp] Subagent timeout for ${sessionKey}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 取回 LLM 的文本回复,手动通过 XMTP 发送
|
|
228
|
+
if (result.status === "ok") {
|
|
229
|
+
const { messages } = await api.runtime.subagent.getSessionMessages({
|
|
230
|
+
sessionKey,
|
|
231
|
+
limit: 5,
|
|
232
|
+
});
|
|
233
|
+
// 找到最后一条 assistant 回复
|
|
234
|
+
const lastReply = [...messages].reverse().find(
|
|
235
|
+
(m: any) => m.role === "assistant" && m.content,
|
|
236
|
+
);
|
|
237
|
+
if (lastReply) {
|
|
238
|
+
const rawContent = (lastReply as any).content;
|
|
239
|
+
// content 可能是 string 或 content blocks 数组(含 thinking/text)
|
|
240
|
+
let replyText: string;
|
|
241
|
+
if (typeof rawContent === "string") {
|
|
242
|
+
replyText = rawContent;
|
|
243
|
+
} else if (Array.isArray(rawContent)) {
|
|
244
|
+
// 只提取 type=text 的部分,跳过 thinking
|
|
245
|
+
replyText = rawContent
|
|
246
|
+
.filter((block: any) => block.type === "text" && block.text)
|
|
247
|
+
.map((block: any) => block.text)
|
|
248
|
+
.join("\n");
|
|
249
|
+
} else {
|
|
250
|
+
replyText = String(rawContent);
|
|
251
|
+
}
|
|
252
|
+
if (!replyText.trim()) return; // 没有有效文本则不回复
|
|
253
|
+
|
|
254
|
+
// 群聊:发送前再次检查,防止 LLM 推理期间其他 agent 已经回复
|
|
255
|
+
if (payload.conversation.isGroup) {
|
|
256
|
+
const raceCheck = await bridge.hasNewGroupReplies(
|
|
257
|
+
payload.conversation.id,
|
|
258
|
+
payload.timestamp,
|
|
259
|
+
[bridge.address, payload.from.xmtpAddress],
|
|
260
|
+
);
|
|
261
|
+
if (raceCheck) {
|
|
262
|
+
ctx.logger.info(
|
|
263
|
+
`[a2a-xmtp] Skipping reply (post-LLM check) — another agent responded in ${payload.conversation.id}`,
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
await bridge.sendMessage(payload.from.xmtpAddress, replyText, {
|
|
270
|
+
conversationId: payload.conversation.id,
|
|
271
|
+
});
|
|
272
|
+
ctx.logger.info(`[a2a-xmtp] Replied to ${senderLabel} in ${payload.conversation.id}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
ctx.logger.error(
|
|
277
|
+
`[a2a-xmtp] Failed to trigger subagent: ${err instanceof Error ? err.message : String(err)}`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
147
280
|
};
|
|
148
281
|
|
|
149
282
|
await bridge.start();
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Tool: xmtp_group — 群聊管理(创建、列表、成员查看/添加/移除)
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import type { XmtpBridge } from "../xmtp-bridge.js";
|
|
6
|
+
import type { IdentityRegistry } from "../identity-registry.js";
|
|
7
|
+
|
|
8
|
+
type GroupAction = "create" | "list" | "members" | "add_member" | "remove_member";
|
|
9
|
+
|
|
10
|
+
export async function handleXmtpGroup(
|
|
11
|
+
bridges: Map<string, XmtpBridge>,
|
|
12
|
+
registry: IdentityRegistry,
|
|
13
|
+
params: {
|
|
14
|
+
action: string;
|
|
15
|
+
members?: string[];
|
|
16
|
+
conversationId?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
},
|
|
19
|
+
agentId: string,
|
|
20
|
+
) {
|
|
21
|
+
const bridge = bridges.get(agentId) ?? bridges.values().next().value;
|
|
22
|
+
if (!bridge) {
|
|
23
|
+
return { content: [{ type: "text" as const, text: "No XMTP bridge available. Plugin not initialized." }], details: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const action = params.action as GroupAction;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
switch (action) {
|
|
30
|
+
case "create": {
|
|
31
|
+
if (!params.members?.length) {
|
|
32
|
+
return { content: [{ type: "text" as const, text: "Parameter 'members' is required for create action. Provide agent IDs or 0x addresses." }], details: null };
|
|
33
|
+
}
|
|
34
|
+
// 解析成员地址
|
|
35
|
+
const addresses: string[] = [];
|
|
36
|
+
for (const member of params.members) {
|
|
37
|
+
if (member.startsWith("0x")) {
|
|
38
|
+
addresses.push(member);
|
|
39
|
+
} else {
|
|
40
|
+
const addr = registry.getAddress(member);
|
|
41
|
+
if (!addr) {
|
|
42
|
+
return { content: [{ type: "text" as const, text: `Agent "${member}" not found. Use xmtp_agents to discover available agents.` }], details: null };
|
|
43
|
+
}
|
|
44
|
+
addresses.push(addr);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = await bridge.createGroup(addresses, params.name);
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text" as const, text: `Group created${result.name ? ` "${result.name}"` : ""} with ${addresses.length} members.` }],
|
|
51
|
+
details: {
|
|
52
|
+
action: "create",
|
|
53
|
+
conversationId: result.conversationId,
|
|
54
|
+
name: result.name,
|
|
55
|
+
memberCount: addresses.length,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
case "list": {
|
|
61
|
+
const groups = await bridge.listGroups();
|
|
62
|
+
if (groups.length === 0) {
|
|
63
|
+
return { content: [{ type: "text" as const, text: "No group conversations found." }], details: null };
|
|
64
|
+
}
|
|
65
|
+
const lines = groups.map((g) => {
|
|
66
|
+
const nameLabel = g.name ? ` "${g.name}"` : "";
|
|
67
|
+
return `- ${g.conversationId}${nameLabel} (${g.memberAddresses.length} members, created ${g.createdAt})`;
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text" as const, text: `Groups:\n${lines.join("\n")}` }],
|
|
71
|
+
details: { action: "list", count: groups.length, groups },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case "members": {
|
|
76
|
+
if (!params.conversationId) {
|
|
77
|
+
return { content: [{ type: "text" as const, text: "Parameter 'conversationId' is required for members action." }], details: null };
|
|
78
|
+
}
|
|
79
|
+
const members = await bridge.getGroupMembers(params.conversationId);
|
|
80
|
+
// 尝试解析 agentId
|
|
81
|
+
const resolved = members.map((addr) => {
|
|
82
|
+
const aid = registry.getAgentId(addr);
|
|
83
|
+
return aid ? `${aid} (${addr})` : addr;
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: "text" as const, text: `Members (${members.length}):\n${resolved.map((m) => `- ${m}`).join("\n")}` }],
|
|
87
|
+
details: { action: "members", conversationId: params.conversationId, count: members.length, members },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case "add_member": {
|
|
92
|
+
if (!params.conversationId) {
|
|
93
|
+
return { content: [{ type: "text" as const, text: "Parameter 'conversationId' is required for add_member action." }], details: null };
|
|
94
|
+
}
|
|
95
|
+
if (!params.members?.length) {
|
|
96
|
+
return { content: [{ type: "text" as const, text: "Parameter 'members' is required for add_member action." }], details: null };
|
|
97
|
+
}
|
|
98
|
+
const addresses: string[] = [];
|
|
99
|
+
for (const member of params.members) {
|
|
100
|
+
if (member.startsWith("0x")) {
|
|
101
|
+
addresses.push(member);
|
|
102
|
+
} else {
|
|
103
|
+
const addr = registry.getAddress(member);
|
|
104
|
+
if (!addr) {
|
|
105
|
+
return { content: [{ type: "text" as const, text: `Agent "${member}" not found.` }], details: null };
|
|
106
|
+
}
|
|
107
|
+
addresses.push(addr);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
await bridge.addGroupMembers(params.conversationId, addresses);
|
|
111
|
+
return {
|
|
112
|
+
content: [{ type: "text" as const, text: `Added ${addresses.length} member(s) to group.` }],
|
|
113
|
+
details: { action: "add_member", conversationId: params.conversationId, added: addresses },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case "remove_member": {
|
|
118
|
+
if (!params.conversationId) {
|
|
119
|
+
return { content: [{ type: "text" as const, text: "Parameter 'conversationId' is required for remove_member action." }], details: null };
|
|
120
|
+
}
|
|
121
|
+
if (!params.members?.length) {
|
|
122
|
+
return { content: [{ type: "text" as const, text: "Parameter 'members' is required for remove_member action." }], details: null };
|
|
123
|
+
}
|
|
124
|
+
const addresses: string[] = [];
|
|
125
|
+
for (const member of params.members) {
|
|
126
|
+
if (member.startsWith("0x")) {
|
|
127
|
+
addresses.push(member);
|
|
128
|
+
} else {
|
|
129
|
+
const addr = registry.getAddress(member);
|
|
130
|
+
if (!addr) {
|
|
131
|
+
return { content: [{ type: "text" as const, text: `Agent "${member}" not found.` }], details: null };
|
|
132
|
+
}
|
|
133
|
+
addresses.push(addr);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
await bridge.removeGroupMembers(params.conversationId, addresses);
|
|
137
|
+
return {
|
|
138
|
+
content: [{ type: "text" as const, text: `Removed ${addresses.length} member(s) from group.` }],
|
|
139
|
+
details: { action: "remove_member", conversationId: params.conversationId, removed: addresses },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
default:
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: "text" as const, text: `Unknown action "${action}". Supported: create, list, members, add_member, remove_member.` }],
|
|
146
|
+
details: null,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
} catch (err: unknown) {
|
|
150
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
151
|
+
return { content: [{ type: "text" as const, text: `Group operation failed: ${msg}` }], details: null };
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/tools/xmtp-inbox.ts
CHANGED
|
@@ -13,7 +13,7 @@ export async function handleXmtpInbox(
|
|
|
13
13
|
) {
|
|
14
14
|
const bridge = bridges.get(agentId) ?? bridges.values().next().value;
|
|
15
15
|
if (!bridge) {
|
|
16
|
-
return { content: [{ type: "text" as const, text: "No XMTP bridge available." }] };
|
|
16
|
+
return { content: [{ type: "text" as const, text: "No XMTP bridge available." }], details: null };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// 解析 from
|
|
@@ -27,12 +27,13 @@ export async function handleXmtpInbox(
|
|
|
27
27
|
const messages = await bridge.getInbox({ limit: params.limit ?? 10, from: fromFilter });
|
|
28
28
|
|
|
29
29
|
if (messages.length === 0) {
|
|
30
|
-
return { content: [{ type: "text" as const, text: "No messages in inbox." }] };
|
|
30
|
+
return { content: [{ type: "text" as const, text: "No messages in inbox." }], details: null };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const lines = messages.map((m, i) => {
|
|
34
34
|
const sender = m.from.agentId ?? m.from.xmtpAddress;
|
|
35
|
-
|
|
35
|
+
const groupTag = m.isGroup ? " [group]" : "";
|
|
36
|
+
return `${i + 1}. [${m.timestamp}]${groupTag} from ${sender}: ${m.content}`;
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
return {
|
|
@@ -41,6 +42,6 @@ export async function handleXmtpInbox(
|
|
|
41
42
|
};
|
|
42
43
|
} catch (err: unknown) {
|
|
43
44
|
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
-
return { content: [{ type: "text" as const, text: `Failed to fetch inbox: ${msg}` }] };
|
|
45
|
+
return { content: [{ type: "text" as const, text: `Failed to fetch inbox: ${msg}` }], details: null };
|
|
45
46
|
}
|
|
46
47
|
}
|
package/src/tools/xmtp-send.ts
CHANGED
|
@@ -10,48 +10,58 @@ export async function handleXmtpSend(
|
|
|
10
10
|
bridges: Map<string, XmtpBridge>,
|
|
11
11
|
registry: IdentityRegistry,
|
|
12
12
|
policyEngine: PolicyEngine,
|
|
13
|
-
params: { to
|
|
13
|
+
params: { to?: string; message: string; conversationId?: string; contentType?: "text" | "markdown" },
|
|
14
14
|
senderAgentId: string,
|
|
15
15
|
) {
|
|
16
16
|
// 找到发送者 bridge(使用第一个可用的 bridge)
|
|
17
17
|
const bridge = bridges.get(senderAgentId) ?? bridges.values().next().value;
|
|
18
18
|
if (!bridge) {
|
|
19
|
-
return { content: [{ type: "text" as const, text: "No XMTP bridge available. Plugin not initialized." }] };
|
|
19
|
+
return { content: [{ type: "text" as const, text: "No XMTP bridge available. Plugin not initialized." }], details: null };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
targetAddress = params.to;
|
|
26
|
-
} else {
|
|
27
|
-
const addr = registry.getAddress(params.to);
|
|
28
|
-
if (!addr) {
|
|
29
|
-
return {
|
|
30
|
-
content: [{ type: "text" as const, text: `Agent "${params.to}" not found. Use xmtp_agents to discover available agents.` }],
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
targetAddress = addr;
|
|
22
|
+
// 当提供 conversationId 时,to 可省略(直接向已有会话发送)
|
|
23
|
+
if (!params.to && !params.conversationId) {
|
|
24
|
+
return { content: [{ type: "text" as const, text: "Either 'to' or 'conversationId' must be provided." }], details: null };
|
|
34
25
|
}
|
|
35
26
|
|
|
36
|
-
|
|
37
|
-
|
|
27
|
+
// 解析目标地址(仅在提供 to 时需要)
|
|
28
|
+
let targetAddress: string | undefined;
|
|
29
|
+
if (params.to) {
|
|
30
|
+
if (params.to.startsWith("0x")) {
|
|
31
|
+
targetAddress = params.to;
|
|
32
|
+
} else {
|
|
33
|
+
const addr = registry.getAddress(params.to);
|
|
34
|
+
if (!addr) {
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: "text" as const, text: `Agent "${params.to}" not found. Use xmtp_agents to discover available agents.` }],
|
|
37
|
+
details: null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
targetAddress = addr;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (targetAddress.toLowerCase() === bridge.address.toLowerCase()) {
|
|
44
|
+
return { content: [{ type: "text" as const, text: "Cannot send message to yourself." }], details: null };
|
|
45
|
+
}
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
// Policy 检查
|
|
49
|
+
const toLabel = params.to ?? params.conversationId!;
|
|
41
50
|
const convId = params.conversationId ?? `dm:${senderAgentId}:${params.to}`;
|
|
42
|
-
const check = policyEngine.checkOutgoing({ from: senderAgentId, to:
|
|
51
|
+
const check = policyEngine.checkOutgoing({ from: senderAgentId, to: toLabel, conversationId: convId });
|
|
43
52
|
if (!check.allowed) {
|
|
44
|
-
return { content: [{ type: "text" as const, text: `Blocked: ${check.reason}` }] };
|
|
53
|
+
return { content: [{ type: "text" as const, text: `Blocked: ${check.reason}` }], details: null };
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
try {
|
|
48
|
-
const result = await bridge.sendMessage(targetAddress, params.message, {
|
|
57
|
+
const result = await bridge.sendMessage(targetAddress ?? "", params.message, {
|
|
49
58
|
contentType: params.contentType,
|
|
50
59
|
conversationId: params.conversationId,
|
|
51
60
|
});
|
|
52
61
|
|
|
62
|
+
const recipientLabel = params.to ?? `conversation ${result.conversationId}`;
|
|
53
63
|
return {
|
|
54
|
-
content: [{ type: "text" as const, text: `Message sent to ${
|
|
64
|
+
content: [{ type: "text" as const, text: `Message sent to ${recipientLabel}.` }],
|
|
55
65
|
details: {
|
|
56
66
|
status: "sent",
|
|
57
67
|
conversationId: result.conversationId,
|
|
@@ -62,6 +72,6 @@ export async function handleXmtpSend(
|
|
|
62
72
|
};
|
|
63
73
|
} catch (err: unknown) {
|
|
64
74
|
const msg = err instanceof Error ? err.message : String(err);
|
|
65
|
-
return { content: [{ type: "text" as const, text: `Failed to send: ${msg}` }] };
|
|
75
|
+
return { content: [{ type: "text" as const, text: `Failed to send: ${msg}` }], details: null };
|
|
66
76
|
}
|
|
67
77
|
}
|
package/src/types.ts
CHANGED
|
@@ -76,11 +76,20 @@ export interface InboxMessage {
|
|
|
76
76
|
xmtpAddress: string;
|
|
77
77
|
};
|
|
78
78
|
conversationId: string;
|
|
79
|
+
isGroup: boolean;
|
|
79
80
|
content: string;
|
|
80
81
|
contentType: A2AContentType;
|
|
81
82
|
timestamp: string;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/** 群聊信息 */
|
|
86
|
+
export interface GroupInfo {
|
|
87
|
+
conversationId: string;
|
|
88
|
+
name: string;
|
|
89
|
+
memberAddresses: string[];
|
|
90
|
+
createdAt: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
84
93
|
/** 默认策略配置 */
|
|
85
94
|
export const DEFAULT_POLICY: ConversationPolicy = {
|
|
86
95
|
maxTurns: 10,
|
package/src/xmtp-bridge.ts
CHANGED
|
@@ -3,22 +3,26 @@
|
|
|
3
3
|
// 每个 Agent 对应一个 XMTP Client,消息 stream + 收件箱缓存
|
|
4
4
|
// ============================================================
|
|
5
5
|
|
|
6
|
-
import { Agent, createUser, createSigner
|
|
6
|
+
import { Agent, createUser, createSigner } from "@xmtp/agent-sdk";
|
|
7
7
|
import type { IdentityRegistry } from "./identity-registry.js";
|
|
8
8
|
import { PolicyEngine } from "./policy-engine.js";
|
|
9
|
-
import type { XmtpWalletConfig, InboxMessage, A2AContentType } from "./types.js";
|
|
10
|
-
import { createA2APayload
|
|
9
|
+
import type { XmtpWalletConfig, InboxMessage, A2AContentType, A2AInjectPayload, GroupInfo } from "./types.js";
|
|
10
|
+
import { createA2APayload } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/** IdentifierKind.Ethereum = 0 (const enum, 不能在 isolatedModules 下直接访问) */
|
|
13
|
+
const IDENTIFIER_KIND_ETHEREUM = 0;
|
|
11
14
|
|
|
12
15
|
export class XmtpBridge {
|
|
13
16
|
private agent: InstanceType<typeof Agent> | null = null;
|
|
14
17
|
private running = false;
|
|
18
|
+
private syncTimer: ReturnType<typeof setInterval> | null = null;
|
|
15
19
|
|
|
16
20
|
/** 收件箱缓存 */
|
|
17
21
|
private inboxBuffer: InboxMessage[] = [];
|
|
18
22
|
private readonly maxInboxBuffer = 100;
|
|
19
23
|
|
|
20
|
-
/** 消息回调(由 plugin
|
|
21
|
-
onMessage?: (agentId: string,
|
|
24
|
+
/** 消息回调(由 plugin 入口设置,用于触发 subagent 处理) */
|
|
25
|
+
onMessage?: (agentId: string, payload: A2AInjectPayload) => void;
|
|
22
26
|
|
|
23
27
|
constructor(
|
|
24
28
|
readonly agentId: string,
|
|
@@ -40,7 +44,7 @@ export class XmtpBridge {
|
|
|
40
44
|
dbPath: `${this.dbPath}/${this.agentId}`,
|
|
41
45
|
});
|
|
42
46
|
|
|
43
|
-
await this.registry.updateInboxId(this.agentId, this.agent.address);
|
|
47
|
+
await this.registry.updateInboxId(this.agentId, this.agent.address!);
|
|
44
48
|
|
|
45
49
|
this.agent.on("text", async (ctx: any) => {
|
|
46
50
|
await this.handleIncoming(ctx, "text");
|
|
@@ -52,10 +56,21 @@ export class XmtpBridge {
|
|
|
52
56
|
|
|
53
57
|
await this.agent.start();
|
|
54
58
|
this.running = true;
|
|
59
|
+
|
|
60
|
+
// 定期同步会话列表,发现被外部添加到的新群聊(每 30 秒)
|
|
61
|
+
this.syncTimer = setInterval(async () => {
|
|
62
|
+
try {
|
|
63
|
+
await this.agent?.client.conversations.sync();
|
|
64
|
+
} catch { /* 同步失败不影响正常运行 */ }
|
|
65
|
+
}, 30_000);
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
async stop(): Promise<void> {
|
|
58
69
|
if (!this.running || !this.agent) return;
|
|
70
|
+
if (this.syncTimer) {
|
|
71
|
+
clearInterval(this.syncTimer);
|
|
72
|
+
this.syncTimer = null;
|
|
73
|
+
}
|
|
59
74
|
await this.agent.stop();
|
|
60
75
|
this.running = false;
|
|
61
76
|
}
|
|
@@ -96,16 +111,97 @@ export class XmtpBridge {
|
|
|
96
111
|
return messages.slice(0, limit);
|
|
97
112
|
}
|
|
98
113
|
|
|
99
|
-
async createGroup(memberAddresses: string[]): Promise<string> {
|
|
114
|
+
async createGroup(memberAddresses: string[], name?: string): Promise<{ conversationId: string; name: string }> {
|
|
115
|
+
if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
|
|
116
|
+
const opts = name ? { groupName: name } : undefined;
|
|
117
|
+
const group = await (this.agent as any).createGroupWithAddresses(memberAddresses, opts);
|
|
118
|
+
if (name) {
|
|
119
|
+
try { await group.updateName(name); } catch { /* 部分 SDK 版本 createGroupWithAddresses 不支持 opts */ }
|
|
120
|
+
}
|
|
121
|
+
return { conversationId: group.id, name: group.name ?? name ?? "" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async listGroups(): Promise<GroupInfo[]> {
|
|
125
|
+
if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
|
|
126
|
+
const conversations = await this.agent.client.conversations.list();
|
|
127
|
+
const groups: GroupInfo[] = [];
|
|
128
|
+
for (const conv of conversations) {
|
|
129
|
+
const meta = await conv.metadata();
|
|
130
|
+
if (meta.conversationType !== 1) continue; // 1 = Group, 0 = Dm
|
|
131
|
+
const members = await conv.members();
|
|
132
|
+
groups.push({
|
|
133
|
+
conversationId: conv.id,
|
|
134
|
+
name: (conv as any).name ?? "",
|
|
135
|
+
memberAddresses: members.map((m: any) =>
|
|
136
|
+
m.accountAddresses?.[0]?.toLowerCase() ?? m.inboxId,
|
|
137
|
+
),
|
|
138
|
+
createdAt: conv.createdAt.toISOString(),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return groups;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async getGroupMembers(conversationId: string): Promise<string[]> {
|
|
100
145
|
if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
|
|
101
|
-
const
|
|
102
|
-
|
|
146
|
+
const conv = await this.agent.client.conversations.getConversationById(conversationId);
|
|
147
|
+
if (!conv) throw new Error(`Conversation ${conversationId} not found`);
|
|
148
|
+
const members = await conv.members();
|
|
149
|
+
return members.map((m: any) => m.accountAddresses?.[0]?.toLowerCase() ?? m.inboxId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async addGroupMembers(conversationId: string, addresses: string[]): Promise<void> {
|
|
153
|
+
if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
|
|
154
|
+
const conv = await this.agent.client.conversations.getConversationById(conversationId);
|
|
155
|
+
if (!conv) throw new Error(`Conversation ${conversationId} not found`);
|
|
156
|
+
const identifiers = addresses.map((addr) => ({
|
|
157
|
+
identifier: addr,
|
|
158
|
+
identifierKind: IDENTIFIER_KIND_ETHEREUM,
|
|
159
|
+
}));
|
|
160
|
+
await (conv as any).addMembersByIdentifiers(identifiers);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async removeGroupMembers(conversationId: string, addresses: string[]): Promise<void> {
|
|
164
|
+
if (!this.agent) throw new Error(`Bridge for ${this.agentId} not started`);
|
|
165
|
+
const conv = await this.agent.client.conversations.getConversationById(conversationId);
|
|
166
|
+
if (!conv) throw new Error(`Conversation ${conversationId} not found`);
|
|
167
|
+
const identifiers = addresses.map((addr) => ({
|
|
168
|
+
identifier: addr,
|
|
169
|
+
identifierKind: IDENTIFIER_KIND_ETHEREUM,
|
|
170
|
+
}));
|
|
171
|
+
await (conv as any).removeMembersByIdentifiers(identifiers);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 检查群聊中在 afterTimestamp 之后是否有新消息(排除自身和指定 sender)
|
|
176
|
+
* 用于防止多 agent 同时回复同一条消息
|
|
177
|
+
*/
|
|
178
|
+
async hasNewGroupReplies(
|
|
179
|
+
conversationId: string,
|
|
180
|
+
afterTimestamp: string,
|
|
181
|
+
excludeAddresses: string[],
|
|
182
|
+
): Promise<boolean> {
|
|
183
|
+
if (!this.agent) return false;
|
|
184
|
+
try {
|
|
185
|
+
const conv = await this.agent.client.conversations.getConversationById(conversationId);
|
|
186
|
+
if (!conv) return false;
|
|
187
|
+
await conv.sync();
|
|
188
|
+
const messages = await conv.messages({ limit: BigInt(10) as any });
|
|
189
|
+
const cutoffMs = new Date(afterTimestamp).getTime();
|
|
190
|
+
const excluded = new Set(excludeAddresses.map((a) => a.toLowerCase()));
|
|
191
|
+
return messages.some((m: any) => {
|
|
192
|
+
const sentMs = Number(BigInt(m.sentAtNs) / 1_000_000n);
|
|
193
|
+
const sender = (m.senderInboxId ?? m.senderAddress ?? "").toLowerCase();
|
|
194
|
+
return sentMs > cutoffMs && !excluded.has(sender);
|
|
195
|
+
});
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
103
199
|
}
|
|
104
200
|
|
|
105
201
|
async canMessage(address: string): Promise<boolean> {
|
|
106
202
|
if (!this.agent) return false;
|
|
107
203
|
const result = await this.agent.client.canMessage([
|
|
108
|
-
{ identifier: address, identifierKind:
|
|
204
|
+
{ identifier: address, identifierKind: IDENTIFIER_KIND_ETHEREUM },
|
|
109
205
|
]);
|
|
110
206
|
return result.get(address.toLowerCase()) ?? false;
|
|
111
207
|
}
|
|
@@ -130,12 +226,24 @@ export class XmtpBridge {
|
|
|
130
226
|
|
|
131
227
|
this.policyEngine.recordTurn(ctx.conversation.id, depth + 1);
|
|
132
228
|
|
|
229
|
+
// 群聊时获取参与者列表
|
|
230
|
+
let participants: string[] = [];
|
|
231
|
+
const isGroup = ctx.conversation.isGroup ?? false;
|
|
232
|
+
if (isGroup) {
|
|
233
|
+
try {
|
|
234
|
+
const members = await ctx.conversation.members();
|
|
235
|
+
participants = members.map((m: any) =>
|
|
236
|
+
m.accountAddresses?.[0]?.toLowerCase() ?? m.inboxId,
|
|
237
|
+
);
|
|
238
|
+
} catch { /* 获取成员失败时保持空数组 */ }
|
|
239
|
+
}
|
|
240
|
+
|
|
133
241
|
const payload = createA2APayload({
|
|
134
242
|
fromAgentId: senderAgentId,
|
|
135
243
|
fromAddress: senderAddress,
|
|
136
244
|
conversationId: ctx.conversation.id,
|
|
137
|
-
isGroup
|
|
138
|
-
participants
|
|
245
|
+
isGroup,
|
|
246
|
+
participants,
|
|
139
247
|
messageId: ctx.message.id ?? crypto.randomUUID(),
|
|
140
248
|
content: String(ctx.message.content),
|
|
141
249
|
contentType,
|
|
@@ -148,15 +256,16 @@ export class XmtpBridge {
|
|
|
148
256
|
id: payload.message.id,
|
|
149
257
|
from: { agentId: senderAgentId, xmtpAddress: senderAddress },
|
|
150
258
|
conversationId: ctx.conversation.id,
|
|
259
|
+
isGroup,
|
|
151
260
|
content: String(ctx.message.content),
|
|
152
261
|
contentType,
|
|
153
262
|
timestamp: payload.timestamp,
|
|
154
263
|
});
|
|
155
264
|
if (this.inboxBuffer.length > this.maxInboxBuffer) this.inboxBuffer.pop();
|
|
156
265
|
|
|
157
|
-
// 通知 plugin
|
|
266
|
+
// 通知 plugin 层(传递结构化 payload,由调用方决定如何处理)
|
|
158
267
|
if (this.onMessage) {
|
|
159
|
-
this.onMessage(this.agentId,
|
|
268
|
+
this.onMessage(this.agentId, payload);
|
|
160
269
|
}
|
|
161
270
|
}
|
|
162
271
|
|