@f2a/openclaw-f2a 0.2.24 → 0.3.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/dist/connector.js CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.F2AOpenClawAdapter = void 0;
13
+ exports.isValidPeerId = isValidPeerId;
13
14
  const path_1 = require("path");
14
15
  const os_1 = require("os");
15
16
  const node_manager_js_1 = require("./node-manager.js");
@@ -25,6 +26,166 @@ const task_guard_js_1 = require("./task-guard.js");
25
26
  const tool_handlers_js_1 = require("./tool-handlers.js");
26
27
  const claim_handlers_js_1 = require("./claim-handlers.js");
27
28
  const network_1 = require("@f2a/network");
29
+ // Issue #98 & #99: 通讯录和握手机制
30
+ const contact_manager_js_1 = require("./contact-manager.js");
31
+ const handshake_protocol_js_1 = require("./handshake-protocol.js");
32
+ const contact_types_js_1 = require("./contact-types.js");
33
+ // ============================================================================
34
+ // 安全常量和验证工具
35
+ // ============================================================================
36
+ /** P1-7: 消息内容最大长度限制 (1MB),防止内存耗尽 */
37
+ const MAX_MESSAGE_LENGTH = 1024 * 1024;
38
+ /** P1-6: PeerID 格式正则(libp2p 格式:12D3KooW...) */
39
+ const PEER_ID_REGEX = /^12D3KooW[A-Za-z1-9]{44}$/;
40
+ /** P1-4: URL 编码的路径遍历模式 */
41
+ const PATH_TRAVERSAL_PATTERNS = [
42
+ '%2e%2e', // URL 编码的 ..
43
+ '%2E%2E', // URL 编码的 .. (大写)
44
+ '%252e', // 双重 URL 编码
45
+ '%c0%ae', // UTF-8 overlong encoding
46
+ '%c1%9c', // UTF-8 overlong encoding
47
+ ];
48
+ /**
49
+ * P1-6: 验证 PeerID 格式
50
+ * @param peerId - 待验证的 Peer ID
51
+ * @returns 是否为有效的 libp2p Peer ID 格式
52
+ */
53
+ function isValidPeerId(peerId) {
54
+ return typeof peerId === 'string' && PEER_ID_REGEX.test(peerId);
55
+ }
56
+ /**
57
+ * P0-1, P1-4: 验证路径安全性,防止路径遍历攻击
58
+ *
59
+ * 增强版本,处理:
60
+ * - 符号链接(通过 realpath 验证)
61
+ * - URL 编码绕过
62
+ * - 双重编码绕过
63
+ * - UTF-8 overlong encoding
64
+ *
65
+ * @param path - 待验证的路径
66
+ * @param options - 可选的额外验证选项
67
+ * @returns 如果路径安全返回 true,否则返回 false
68
+ */
69
+ function isPathSafe(path, options) {
70
+ if (typeof path !== 'string' || path.length === 0) {
71
+ return false;
72
+ }
73
+ // 拒绝绝对路径
74
+ if ((0, path_1.isAbsolute)(path)) {
75
+ return false;
76
+ }
77
+ // 拒绝包含路径遍历字符
78
+ if (path.includes('..') || path.includes('\0')) {
79
+ return false;
80
+ }
81
+ // 拒绝以 ~ 开头的路径(用户目录展开)
82
+ if (path.startsWith('~')) {
83
+ return false;
84
+ }
85
+ // P1-4: 检查 URL 编码的路径遍历模式
86
+ const lowerPath = path.toLowerCase();
87
+ for (const pattern of PATH_TRAVERSAL_PATTERNS) {
88
+ if (lowerPath.includes(pattern.toLowerCase())) {
89
+ return false;
90
+ }
91
+ }
92
+ // P1-4: 解码 URL 编码后再次检查
93
+ try {
94
+ const decodedPath = decodeURIComponent(path);
95
+ // 解码后再次检查路径遍历
96
+ if (decodedPath.includes('..') || decodedPath.includes('\0')) {
97
+ return false;
98
+ }
99
+ // 检查解码后是否变成绝对路径
100
+ if ((0, path_1.isAbsolute)(decodedPath)) {
101
+ return false;
102
+ }
103
+ }
104
+ catch {
105
+ // 解码失败可能是恶意构造,拒绝
106
+ return false;
107
+ }
108
+ // P1-4: 如果指定了允许的根目录,验证路径不会逃逸
109
+ if (options?.allowedRoot) {
110
+ try {
111
+ const resolvedPath = (0, path_1.join)(options.allowedRoot, path);
112
+ // 检查解析后的路径是否仍在允许的根目录下
113
+ if (!resolvedPath.startsWith(options.allowedRoot)) {
114
+ return false;
115
+ }
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ return true;
122
+ }
123
+ /**
124
+ * P1-2: 统一错误提取工具函数
125
+ * 从各种错误格式中提取错误消息,添加循环引用保护
126
+ * @param error - 任意错误对象
127
+ * @returns 错误消息字符串
128
+ */
129
+ function extractErrorMessage(error) {
130
+ try {
131
+ if (error instanceof Error) {
132
+ return error.message;
133
+ }
134
+ if (typeof error === 'string') {
135
+ return error;
136
+ }
137
+ if (typeof error === 'object' && error !== null) {
138
+ // 尝试常见的错误属性
139
+ const err = error;
140
+ if (typeof err.message === 'string') {
141
+ return err.message;
142
+ }
143
+ if (typeof err.error === 'string') {
144
+ return err.error;
145
+ }
146
+ if (typeof err.msg === 'string') {
147
+ return err.msg;
148
+ }
149
+ }
150
+ // P1-2: 使用 try-catch 保护 String() 调用,防止循环引用异常
151
+ return String(error);
152
+ }
153
+ catch {
154
+ // 如果 String() 抛出异常(如循环引用),返回安全的默认消息
155
+ return '[Error: Unable to extract error message - possible circular reference]';
156
+ }
157
+ }
158
+ /**
159
+ * Issue #96: 从 IDENTITY.md 读取 agent 名字
160
+ * @param workspace - agent workspace 目录
161
+ * @returns agent 名字,如果读取失败返回 null
162
+ */
163
+ function readAgentNameFromIdentity(workspace) {
164
+ if (!workspace) {
165
+ return null;
166
+ }
167
+ try {
168
+ const identityPath = (0, path_1.join)(workspace, 'IDENTITY.md');
169
+ const fs = require('fs');
170
+ if (!fs.existsSync(identityPath)) {
171
+ return null;
172
+ }
173
+ const content = fs.readFileSync(identityPath, 'utf-8');
174
+ // 解析 IDENTITY.md 中的 Name 字段
175
+ // 格式: - **Name:** 猫咕噜 (Cat Guru)
176
+ const nameMatch = content.match(/-\s*\*\*Name:\*\*\s*(.+?)(?:\s*\([^)]*\))?$/m);
177
+ if (nameMatch && nameMatch[1]) {
178
+ const name = nameMatch[1].trim();
179
+ // 移除可能的英文别名(括号内的内容已在正则中处理)
180
+ return name;
181
+ }
182
+ return null;
183
+ }
184
+ catch (err) {
185
+ // 读取失败,返回 null
186
+ return null;
187
+ }
188
+ }
28
189
  class F2AOpenClawAdapter {
29
190
  name = 'f2a-openclaw-f2a';
30
191
  version = '0.3.0';
@@ -44,6 +205,16 @@ class F2AOpenClawAdapter {
44
205
  // 处理器实例(延迟初始化)
45
206
  _toolHandlers;
46
207
  _claimHandlers;
208
+ // Issue #98 & #99: 通讯录和握手机制
209
+ _contactManager;
210
+ _handshakeProtocol;
211
+ // P1-3: 消息哈希去重缓存,防止恶意节点绕过 echo 检测
212
+ // 存储最近处理过的消息哈希,避免重复处理
213
+ _processedMessageHashes = new Map();
214
+ /** P1-3: 消息去重缓存最大条目数 */
215
+ static MAX_MESSAGE_HASH_CACHE_SIZE = 10000;
216
+ /** P1-3: 消息去重缓存条目最大存活时间(毫秒) */
217
+ static MESSAGE_HASH_TTL_MS = 5 * 60 * 1000; // 5 分钟
47
218
  config;
48
219
  nodeConfig;
49
220
  capabilities = [];
@@ -62,18 +233,125 @@ class F2AOpenClawAdapter {
62
233
  }
63
234
  /**
64
235
  * 获取默认的 F2A 数据目录
65
- * 优先级:workspace/.f2a > ~/.f2a(兼容旧版本)
66
- * 注意:config.dataDir 的处理在 mergeConfig() 中,这里只处理未配置的情况
236
+ *
237
+ * 优先级:
238
+ * 1. config.dataDir(用户显式配置)
239
+ * 2. workspace/.f2a(agent workspace 目录)
240
+ * 3. ~/.f2a(兼容旧版本)
241
+ *
242
+ * 安全:
243
+ * - P0-1: 验证 workspace 路径,防止路径遍历攻击
244
+ * - P1-1: 优先检查 config.dataDir
67
245
  */
68
246
  getDefaultDataDir() {
247
+ // P1-1: 优先使用用户配置的 dataDir
248
+ if (this.config?.dataDir) {
249
+ return this.config.dataDir;
250
+ }
69
251
  // 默认:使用 agent workspace 目录
70
252
  const workspace = this.api?.config?.agents?.defaults?.workspace;
71
- if (workspace) {
253
+ // P0-1: 验证 workspace 路径安全性
254
+ if (isPathSafe(workspace)) {
72
255
  return (0, path_1.join)(workspace, '.f2a');
73
256
  }
74
257
  // 兼容旧版本
75
258
  return (0, path_1.join)((0, os_1.homedir)(), '.f2a');
76
259
  }
260
+ /**
261
+ * P1-2, P1-5, P1-3: 检测是否为回声消息(避免循环)
262
+ *
263
+ * 使用多层验证策略:
264
+ * 1. 检查 metadata 中的特定标记(不仅仅是 type === 'reply')
265
+ * 2. 检查消息来源可信度
266
+ * 3. 检查消息内容的特殊标记
267
+ * 4. P1-3: 基于消息内容哈希的去重机制(防止恶意绕过)
268
+ *
269
+ * @param msg - 接收到的消息
270
+ * @returns 是否为应该跳过的回声消息
271
+ */
272
+ isEchoMessage(msg) {
273
+ const { metadata, content, from } = msg;
274
+ // 层1: 检查 metadata 中的标记
275
+ // P1-5: 不能只依赖 metadata.type,恶意节点可以伪造
276
+ // 我们检查更具体的标记组合
277
+ if (metadata) {
278
+ // 检查是否是我们自己发出的回复标记
279
+ if (metadata.type === 'reply' && metadata.replyTo) {
280
+ // 进一步验证:检查是否来自可信源(我们自己发出的消息)
281
+ // 如果有 replyTo,说明这是一个回复消息
282
+ return true;
283
+ }
284
+ // 检查显式的跳过标记
285
+ if (metadata._f2a_skip_echo === true || metadata['x-openclaw-skip'] === true) {
286
+ return true;
287
+ }
288
+ }
289
+ // 层2: 检查消息内容中的特殊标记
290
+ // P1-2: 使用更严格的匹配,避免误判正常消息
291
+ if (content) {
292
+ // 使用特殊的标记格式 [[F2A:REPLY:...]]
293
+ // 而不是简单的 "NO_REPLY" 字符串
294
+ if (content.includes('[[F2A:REPLY:') || content.includes('[[reply_to_current]]')) {
295
+ return true;
296
+ }
297
+ // 检查是否以 NO_REPLY 标记开头(更严格)
298
+ if (content.startsWith('NO_REPLY:') || content.startsWith('[NO_REPLY]')) {
299
+ return true;
300
+ }
301
+ }
302
+ // 层3: 检查消息来源是否是我们自己的 peerId(防止自循环)
303
+ if (this._f2a && from === this._f2a.peerId) {
304
+ return true;
305
+ }
306
+ // 层4 (P1-3): 基于消息内容哈希的去重机制
307
+ // 防止恶意节点构造不含特定标记的消息绕过检测
308
+ if (content) {
309
+ const messageHash = this.computeMessageHash(from, content);
310
+ const now = Date.now();
311
+ // 检查是否已处理过相同的消息内容
312
+ if (this._processedMessageHashes.has(messageHash)) {
313
+ const processedTime = this._processedMessageHashes.get(messageHash);
314
+ // 如果在 TTL 内,认为是重复消息
315
+ if (now - processedTime < F2AOpenClawAdapter.MESSAGE_HASH_TTL_MS) {
316
+ this._logger?.debug?.(`[F2A Adapter] 检测到重复消息(哈希去重): ${messageHash.slice(0, 16)}...`);
317
+ return true;
318
+ }
319
+ }
320
+ // 记录此消息哈希
321
+ this._processedMessageHashes.set(messageHash, now);
322
+ // 清理过期的条目(防止内存泄漏)
323
+ if (this._processedMessageHashes.size > F2AOpenClawAdapter.MAX_MESSAGE_HASH_CACHE_SIZE) {
324
+ this.cleanupMessageHashCache(now);
325
+ }
326
+ }
327
+ return false;
328
+ }
329
+ /**
330
+ * P1-3: 计算消息内容哈希
331
+ * 用于基于内容的去重
332
+ */
333
+ computeMessageHash(from, content) {
334
+ // 使用简单的哈希算法,避免依赖 crypto 模块
335
+ const data = `${from}:${content}`;
336
+ let hash = 0;
337
+ for (let i = 0; i < data.length; i++) {
338
+ const char = data.charCodeAt(i);
339
+ hash = ((hash << 5) - hash) + char;
340
+ hash = hash & hash; // Convert to 32bit integer
341
+ }
342
+ return `msg-${hash.toString(16)}-${data.length}`;
343
+ }
344
+ /**
345
+ * P1-3: 清理过期的消息哈希缓存
346
+ */
347
+ cleanupMessageHashCache(now) {
348
+ const ttl = F2AOpenClawAdapter.MESSAGE_HASH_TTL_MS;
349
+ for (const [hash, timestamp] of this._processedMessageHashes.entries()) {
350
+ if (now - timestamp > ttl) {
351
+ this._processedMessageHashes.delete(hash);
352
+ }
353
+ }
354
+ }
77
355
  /**
78
356
  * 获取网络客户端(懒加载)
79
357
  * 新架构:直接使用 F2A 实例的方法,不再通过 HTTP
@@ -171,6 +449,28 @@ class F2AOpenClawAdapter {
171
449
  }
172
450
  return this._claimHandlers;
173
451
  }
452
+ /**
453
+ * Issue #98: 获取联系人管理器(延迟初始化)
454
+ */
455
+ get contactManager() {
456
+ if (!this._contactManager) {
457
+ const dataDir = this.getDefaultDataDir();
458
+ this._contactManager = new contact_manager_js_1.ContactManager(dataDir, this._logger);
459
+ this._logger?.info('[F2A Adapter] ContactManager 已初始化');
460
+ }
461
+ return this._contactManager;
462
+ }
463
+ /**
464
+ * Issue #99: 获取握手协议处理器(延迟初始化)
465
+ * 依赖 F2A 实例和 ContactManager
466
+ */
467
+ get handshakeProtocol() {
468
+ if (!this._handshakeProtocol && this._f2a && this._contactManager) {
469
+ this._handshakeProtocol = new handshake_protocol_js_1.HandshakeProtocol(this._f2a, this._contactManager, this._logger);
470
+ this._logger?.info('[F2A Adapter] HandshakeProtocol 已初始化');
471
+ }
472
+ return this._handshakeProtocol;
473
+ }
174
474
  /**
175
475
  * 检查是否已初始化(用于判断是否需要启动服务)
176
476
  */
@@ -205,7 +505,7 @@ class F2AOpenClawAdapter {
205
505
  return { success: true, data: agents };
206
506
  }
207
507
  catch (err) {
208
- return { success: false, error: { message: err instanceof Error ? err.message : String(err) } };
508
+ return { success: false, error: { message: extractErrorMessage(err) } };
209
509
  }
210
510
  },
211
511
  getConnectedPeers: async () => {
@@ -218,7 +518,7 @@ class F2AOpenClawAdapter {
218
518
  return { success: true, data: peers };
219
519
  }
220
520
  catch (err) {
221
- return { success: false, error: { message: err instanceof Error ? err.message : String(err) } };
521
+ return { success: false, error: { message: extractErrorMessage(err) } };
222
522
  }
223
523
  }
224
524
  };
@@ -299,8 +599,16 @@ class F2AOpenClawAdapter {
299
599
  debugLog(`[F2A Adapter] 使用数据目录: ${dataDir}`);
300
600
  debugLog(`[F2A Adapter] workspace: ${this.api?.config?.agents?.defaults?.workspace}`);
301
601
  debugLog(`[F2A Adapter] config.dataDir: ${this.config.dataDir}`);
602
+ // Issue #96: 从 IDENTITY.md 读取 agent 名字
603
+ const workspace = this.api?.config?.agents?.defaults?.workspace;
604
+ const identityName = readAgentNameFromIdentity(workspace);
605
+ // 优先级:IDENTITY.md > config.agentName > 默认值
606
+ const displayName = identityName || this.config.agentName || 'OpenClaw Agent';
607
+ if (identityName) {
608
+ debugLog(`[F2A Adapter] 从 IDENTITY.md 读取 agent 名字: ${identityName}`);
609
+ }
302
610
  this._f2a = await network_1.F2A.create({
303
- displayName: this.config.agentName || 'OpenClaw Agent',
611
+ displayName,
304
612
  dataDir,
305
613
  network: {
306
614
  listenPort: this.config.p2pPort || 0,
@@ -311,7 +619,17 @@ class F2AOpenClawAdapter {
311
619
  });
312
620
  // 监听 P2P 消息,调用 OpenClaw Agent 生成回复
313
621
  this._f2a.on('message', async (msg) => {
314
- const logMsg = `[F2A Adapter] 收到 P2P 消息: from=${msg.from?.slice(0, 16)}, content=${msg.content?.slice(0, 50)}`;
622
+ // P1-6: 验证 PeerID 格式
623
+ if (!isValidPeerId(msg.from)) {
624
+ this._logger?.warn(`[F2A Adapter] 拒绝来自无效 PeerID 的消息: ${String(msg.from).slice(0, 20)}`);
625
+ return;
626
+ }
627
+ // P1-7: 检查消息长度限制
628
+ if (msg.content && msg.content.length > MAX_MESSAGE_LENGTH) {
629
+ this._logger?.warn(`[F2A Adapter] 消息过长 (${msg.content.length} bytes),拒绝处理`);
630
+ return;
631
+ }
632
+ const logMsg = `[F2A Adapter] 收到 P2P 消息: from=${msg.from.slice(0, 16)}, content=${msg.content?.slice(0, 50)}`;
315
633
  this._logger?.info(logMsg);
316
634
  // 写入文件日志
317
635
  try {
@@ -321,10 +639,9 @@ class F2AOpenClawAdapter {
321
639
  }
322
640
  catch { }
323
641
  try {
324
- // 检查是否是 Agent 回复(避免回声循环)
325
- const isReply = msg.metadata?.type === 'reply' ||
326
- msg.content?.includes('[[reply_to_current]]') ||
327
- msg.content?.includes('NO_REPLY');
642
+ // P1-2, P1-5: 改进的回声循环检测
643
+ // 使用更严格的检测逻辑,防止恶意绕过
644
+ const isReply = this.isEchoMessage(msg);
328
645
  if (isReply) {
329
646
  this._logger?.info('[F2A Adapter] 跳过 Agent 回复,避免回声循环');
330
647
  return;
@@ -338,7 +655,7 @@ class F2AOpenClawAdapter {
338
655
  }
339
656
  }
340
657
  catch (err) {
341
- this._logger?.error('[F2A Adapter] 处理消息失败', { error: err instanceof Error ? err.message : String(err) });
658
+ this._logger?.error('[F2A Adapter] 处理消息失败', { error: extractErrorMessage(err) });
342
659
  }
343
660
  });
344
661
  // 监听其他事件
@@ -367,7 +684,7 @@ class F2AOpenClawAdapter {
367
684
  });
368
685
  }
369
686
  catch (err) {
370
- const errorMsg = err instanceof Error ? err.message : String(err);
687
+ const errorMsg = extractErrorMessage(err);
371
688
  this._logger?.error(`[F2A Adapter] 创建 F2A 实例失败: ${errorMsg}`);
372
689
  this._logger?.warn('[F2A Adapter] F2A Adapter 将以降级模式运行,P2P 功能不可用');
373
690
  // 清理失败的实例
@@ -386,7 +703,7 @@ class F2AOpenClawAdapter {
386
703
  this._logger?.info(`[F2A Adapter] Webhook 服务器已启动: ${this._webhookServer.getUrl()}`);
387
704
  }
388
705
  catch (err) {
389
- const errorMsg = err instanceof Error ? err.message : String(err);
706
+ const errorMsg = extractErrorMessage(err);
390
707
  this._logger?.warn(`[F2A Adapter] Webhook 服务器启动失败: ${errorMsg}`);
391
708
  }
392
709
  // 启动兜底轮询
@@ -418,7 +735,7 @@ class F2AOpenClawAdapter {
418
735
  this._logger?.info('[F2A Adapter] 回复已发送', { to: fromPeerId.slice(0, 16) });
419
736
  }
420
737
  catch (err) {
421
- this._logger?.error('[F2A Adapter] 发送回复失败', { error: err instanceof Error ? err.message : String(err) });
738
+ this._logger?.error('[F2A Adapter] 发送回复失败', { error: extractErrorMessage(err) });
422
739
  }
423
740
  };
424
741
  // 返回 dispatcher 对象,格式与 OpenClaw 兼容
@@ -481,7 +798,7 @@ class F2AOpenClawAdapter {
481
798
  return undefined; // dispatcher 会自动发送回复
482
799
  }
483
800
  catch (err) {
484
- debugLog(`[F2A Adapter] Channel API 失败: ${err instanceof Error ? err.message : String(err)}`);
801
+ debugLog(`[F2A Adapter] Channel API 失败: ${extractErrorMessage(err)}`);
485
802
  }
486
803
  }
487
804
  // 降级:使用 subagent API
@@ -490,12 +807,13 @@ class F2AOpenClawAdapter {
490
807
  try {
491
808
  // 生成 idempotencyKey(必需参数)
492
809
  const idempotencyKey = `f2a-${fromPeerId.slice(0, 16)}-${Date.now()}`;
810
+ // P1-3: 使用正确的类型,移除 as any
493
811
  const runResult = await this.api.runtime.subagent.run({
494
812
  sessionKey,
495
813
  message,
496
814
  deliver: false,
497
815
  idempotencyKey,
498
- }); // 使用 as any 绕过类型检查
816
+ });
499
817
  const waitResult = await this.api.runtime.subagent.waitForRun({
500
818
  runId: runResult.runId,
501
819
  timeoutMs: 60000,
@@ -527,7 +845,7 @@ class F2AOpenClawAdapter {
527
845
  }
528
846
  }
529
847
  catch (err) {
530
- debugLog(`[F2A Adapter] Subagent 失败: ${err instanceof Error ? err.message : String(err)}`);
848
+ debugLog(`[F2A Adapter] Subagent 失败: ${extractErrorMessage(err)}`);
531
849
  }
532
850
  }
533
851
  // 最终降级
@@ -981,6 +1299,157 @@ class F2AOpenClawAdapter {
981
1299
  }
982
1300
  },
983
1301
  handler: this.toolHandlers.handleGetCapabilities.bind(this.toolHandlers)
1302
+ },
1303
+ // ========== Issue #98 & #99: 通讯录和握手机制工具 ==========
1304
+ {
1305
+ name: 'f2a_contacts',
1306
+ description: '管理通讯录联系人。Actions: list(列出联系人), get(获取详情), add(添加), remove(删除), update(更新), block(拉黑), unblock(解除拉黑)',
1307
+ parameters: {
1308
+ action: {
1309
+ type: 'string',
1310
+ description: '操作类型: list, get, add, remove, update, block, unblock',
1311
+ required: true,
1312
+ enum: ['list', 'get', 'add', 'remove', 'update', 'block', 'unblock']
1313
+ },
1314
+ contact_id: {
1315
+ type: 'string',
1316
+ description: '联系人 ID(get/remove/update/block/unblock 时需要)',
1317
+ required: false
1318
+ },
1319
+ peer_id: {
1320
+ type: 'string',
1321
+ description: 'Peer ID(add 时需要,get/remove 时可选)',
1322
+ required: false
1323
+ },
1324
+ name: {
1325
+ type: 'string',
1326
+ description: '联系人名称(add/update 时需要)',
1327
+ required: false
1328
+ },
1329
+ groups: {
1330
+ type: 'array',
1331
+ description: '分组列表',
1332
+ required: false
1333
+ },
1334
+ tags: {
1335
+ type: 'array',
1336
+ description: '标签列表',
1337
+ required: false
1338
+ },
1339
+ notes: {
1340
+ type: 'string',
1341
+ description: '备注信息',
1342
+ required: false
1343
+ },
1344
+ status: {
1345
+ type: 'string',
1346
+ description: '按状态过滤(list 时可选): stranger, pending, friend, blocked',
1347
+ required: false,
1348
+ enum: ['stranger', 'pending', 'friend', 'blocked']
1349
+ },
1350
+ group: {
1351
+ type: 'string',
1352
+ description: '按分组过滤(list 时可选)',
1353
+ required: false
1354
+ }
1355
+ },
1356
+ handler: this.handleContacts.bind(this)
1357
+ },
1358
+ {
1359
+ name: 'f2a_contact_groups',
1360
+ description: '管理联系人分组。Actions: list(列出分组), create(创建), update(更新), delete(删除)',
1361
+ parameters: {
1362
+ action: {
1363
+ type: 'string',
1364
+ description: '操作类型: list, create, update, delete',
1365
+ required: true,
1366
+ enum: ['list', 'create', 'update', 'delete']
1367
+ },
1368
+ group_id: {
1369
+ type: 'string',
1370
+ description: '分组 ID(update/delete 时需要)',
1371
+ required: false
1372
+ },
1373
+ name: {
1374
+ type: 'string',
1375
+ description: '分组名称(create/update 时需要)',
1376
+ required: false
1377
+ },
1378
+ description: {
1379
+ type: 'string',
1380
+ description: '分组描述',
1381
+ required: false
1382
+ },
1383
+ color: {
1384
+ type: 'string',
1385
+ description: '分组颜色(十六进制,如 #FF5733)',
1386
+ required: false
1387
+ }
1388
+ },
1389
+ handler: this.handleContactGroups.bind(this)
1390
+ },
1391
+ {
1392
+ name: 'f2a_friend_request',
1393
+ description: '发送好友请求给指定 Agent',
1394
+ parameters: {
1395
+ peer_id: {
1396
+ type: 'string',
1397
+ description: '目标 Agent 的 Peer ID',
1398
+ required: true
1399
+ },
1400
+ message: {
1401
+ type: 'string',
1402
+ description: '附加消息',
1403
+ required: false
1404
+ }
1405
+ },
1406
+ handler: this.handleFriendRequest.bind(this)
1407
+ },
1408
+ {
1409
+ name: 'f2a_pending_requests',
1410
+ description: '查看和处理待处理的好友请求。Actions: list(列出请求), accept(接受), reject(拒绝)',
1411
+ parameters: {
1412
+ action: {
1413
+ type: 'string',
1414
+ description: '操作类型: list, accept, reject',
1415
+ required: true,
1416
+ enum: ['list', 'accept', 'reject']
1417
+ },
1418
+ request_id: {
1419
+ type: 'string',
1420
+ description: '请求 ID(accept/reject 时需要)',
1421
+ required: false
1422
+ },
1423
+ reason: {
1424
+ type: 'string',
1425
+ description: '拒绝原因(reject 时可选)',
1426
+ required: false
1427
+ }
1428
+ },
1429
+ handler: this.handlePendingRequests.bind(this)
1430
+ },
1431
+ {
1432
+ name: 'f2a_contacts_export',
1433
+ description: '导出通讯录数据',
1434
+ parameters: {},
1435
+ handler: this.handleContactsExport.bind(this)
1436
+ },
1437
+ {
1438
+ name: 'f2a_contacts_import',
1439
+ description: '导入通讯录数据',
1440
+ parameters: {
1441
+ data: {
1442
+ type: 'object',
1443
+ description: '导入的通讯录数据(JSON 格式)',
1444
+ required: true
1445
+ },
1446
+ merge: {
1447
+ type: 'boolean',
1448
+ description: '是否合并(true)或覆盖(false),默认 true',
1449
+ required: false
1450
+ }
1451
+ },
1452
+ handler: this.handleContactsImport.bind(this)
984
1453
  }
985
1454
  ];
986
1455
  }
@@ -1101,6 +1570,16 @@ class F2AOpenClawAdapter {
1101
1570
  }
1102
1571
  },
1103
1572
  onMessage: async (payload) => {
1573
+ // P1-6: 验证 PeerID 格式
1574
+ if (!isValidPeerId(payload.from)) {
1575
+ this._logger?.warn(`[F2A Adapter] onMessage: 拒绝来自无效 PeerID 的消息: ${String(payload.from).slice(0, 20)}`);
1576
+ return { response: 'Invalid sender' };
1577
+ }
1578
+ // P1-7: 检查消息长度限制
1579
+ if (payload.content && payload.content.length > MAX_MESSAGE_LENGTH) {
1580
+ this._logger?.warn(`[F2A Adapter] onMessage: 消息过长 (${payload.content.length} bytes),拒绝处理`);
1581
+ return { response: 'Message too long' };
1582
+ }
1104
1583
  this._logger?.info('[F2A Adapter] 收到 P2P 消息', {
1105
1584
  from: payload.from.slice(0, 16),
1106
1585
  content: payload.content.slice(0, 50)
@@ -1116,7 +1595,7 @@ class F2AOpenClawAdapter {
1116
1595
  return { response: result || '收到消息,但我暂时无法生成回复。' };
1117
1596
  }
1118
1597
  catch (error) {
1119
- this._logger?.error('[F2A Adapter] 处理消息失败', { error: error instanceof Error ? error.message : String(error) });
1598
+ this._logger?.error('[F2A Adapter] 处理消息失败', { error: extractErrorMessage(error) });
1120
1599
  return { response: '抱歉,我遇到了一些问题,无法处理你的消息。' };
1121
1600
  }
1122
1601
  },
@@ -1306,6 +1785,313 @@ class F2AOpenClawAdapter {
1306
1785
  (a.displayName?.toLowerCase().includes(agentRef.toLowerCase()) ?? false));
1307
1786
  return fuzzy || null;
1308
1787
  }
1788
+ // ============================================================================
1789
+ // Issue #98 & #99: 通讯录和握手机制工具处理方法
1790
+ // ============================================================================
1791
+ /**
1792
+ * 处理通讯录工具
1793
+ */
1794
+ async handleContacts(params, _context) {
1795
+ try {
1796
+ const cm = this.contactManager;
1797
+ switch (params.action) {
1798
+ case 'list': {
1799
+ const filter = {};
1800
+ if (params.status) {
1801
+ filter.status = params.status;
1802
+ }
1803
+ if (params.group) {
1804
+ filter.group = params.group;
1805
+ }
1806
+ const contacts = cm.getContacts(filter, { field: 'name', order: 'asc' });
1807
+ const stats = cm.getStats();
1808
+ return {
1809
+ content: `📋 **通讯录** (${stats.total} 个联系人)\n\n` +
1810
+ contacts.map(c => {
1811
+ const statusIcon = {
1812
+ [contact_types_js_1.FriendStatus.FRIEND]: '💚',
1813
+ [contact_types_js_1.FriendStatus.STRANGER]: '⚪',
1814
+ [contact_types_js_1.FriendStatus.PENDING]: '🟡',
1815
+ [contact_types_js_1.FriendStatus.BLOCKED]: '🔴',
1816
+ }[c.status];
1817
+ return `${statusIcon} **${c.name}**\n Peer: ${c.peerId.slice(0, 16)}...\n 信誉: ${c.reputation} | 状态: ${c.status}`;
1818
+ }).join('\n\n') || '暂无联系人'
1819
+ };
1820
+ }
1821
+ case 'get': {
1822
+ let contact;
1823
+ if (params.contact_id) {
1824
+ contact = cm.getContact(params.contact_id);
1825
+ }
1826
+ else if (params.peer_id) {
1827
+ contact = cm.getContactByPeerId(params.peer_id);
1828
+ }
1829
+ else {
1830
+ return { content: '❌ 需要提供 contact_id 或 peer_id' };
1831
+ }
1832
+ if (!contact) {
1833
+ return { content: '❌ 联系人不存在' };
1834
+ }
1835
+ return {
1836
+ content: `👤 **${contact.name}**\n` +
1837
+ ` ID: ${contact.id}\n` +
1838
+ ` Peer ID: ${contact.peerId}\n` +
1839
+ ` 状态: ${contact.status}\n` +
1840
+ ` 信誉: ${contact.reputation}\n` +
1841
+ ` 分组: ${contact.groups.join(', ') || '无'}\n` +
1842
+ ` 标签: ${contact.tags.join(', ') || '无'}\n` +
1843
+ ` 最后通信: ${contact.lastCommunicationTime ? new Date(contact.lastCommunicationTime).toLocaleString() : '从未'}\n` +
1844
+ (contact.notes ? ` 备注: ${contact.notes}` : '')
1845
+ };
1846
+ }
1847
+ case 'add': {
1848
+ if (!params.peer_id || !params.name) {
1849
+ return { content: '❌ 需要提供 peer_id 和 name' };
1850
+ }
1851
+ const contact = cm.addContact({
1852
+ name: params.name,
1853
+ peerId: params.peer_id,
1854
+ groups: params.groups,
1855
+ tags: params.tags,
1856
+ notes: params.notes,
1857
+ });
1858
+ return { content: `✅ 已添加联系人: ${contact.name} (${contact.peerId.slice(0, 16)})` };
1859
+ }
1860
+ case 'remove': {
1861
+ let contactId = params.contact_id;
1862
+ if (!contactId && params.peer_id) {
1863
+ const contact = cm.getContactByPeerId(params.peer_id);
1864
+ contactId = contact?.id;
1865
+ }
1866
+ if (!contactId) {
1867
+ return { content: '❌ 需要提供 contact_id 或 peer_id' };
1868
+ }
1869
+ const success = cm.removeContact(contactId);
1870
+ return { content: success ? '✅ 已删除联系人' : '❌ 联系人不存在' };
1871
+ }
1872
+ case 'update': {
1873
+ let contactId = params.contact_id;
1874
+ if (!contactId && params.peer_id) {
1875
+ const contact = cm.getContactByPeerId(params.peer_id);
1876
+ contactId = contact?.id;
1877
+ }
1878
+ if (!contactId) {
1879
+ return { content: '❌ 需要提供 contact_id 或 peer_id' };
1880
+ }
1881
+ const contact = cm.updateContact(contactId, {
1882
+ name: params.name,
1883
+ groups: params.groups,
1884
+ tags: params.tags,
1885
+ notes: params.notes,
1886
+ });
1887
+ return { content: contact ? `✅ 已更新联系人: ${contact.name}` : '❌ 联系人不存在' };
1888
+ }
1889
+ case 'block': {
1890
+ let contactId = params.contact_id;
1891
+ if (!contactId && params.peer_id) {
1892
+ const contact = cm.getContactByPeerId(params.peer_id);
1893
+ contactId = contact?.id;
1894
+ }
1895
+ if (!contactId) {
1896
+ return { content: '❌ 需要提供 contact_id 或 peer_id' };
1897
+ }
1898
+ const success = cm.blockContact(contactId);
1899
+ return { content: success ? '✅ 已拉黑联系人' : '❌ 联系人不存在' };
1900
+ }
1901
+ case 'unblock': {
1902
+ let contactId = params.contact_id;
1903
+ if (!contactId && params.peer_id) {
1904
+ const contact = cm.getContactByPeerId(params.peer_id);
1905
+ contactId = contact?.id;
1906
+ }
1907
+ if (!contactId) {
1908
+ return { content: '❌ 需要提供 contact_id 或 peer_id' };
1909
+ }
1910
+ const success = cm.unblockContact(contactId);
1911
+ return { content: success ? '✅ 已解除拉黑' : '❌ 联系人不存在' };
1912
+ }
1913
+ default:
1914
+ return { content: '❌ 未知操作' };
1915
+ }
1916
+ }
1917
+ catch (err) {
1918
+ return { content: `❌ 操作失败: ${err instanceof Error ? err.message : String(err)}` };
1919
+ }
1920
+ }
1921
+ /**
1922
+ * 处理分组管理工具
1923
+ */
1924
+ async handleContactGroups(params, _context) {
1925
+ try {
1926
+ const cm = this.contactManager;
1927
+ switch (params.action) {
1928
+ case 'list': {
1929
+ const groups = cm.getGroups();
1930
+ return {
1931
+ content: `📁 **分组列表** (${groups.length} 个)\n\n` +
1932
+ groups.map(g => `• **${g.name}** (${g.id})\n ${g.description || '无描述'}`).join('\n\n')
1933
+ };
1934
+ }
1935
+ case 'create': {
1936
+ if (!params.name) {
1937
+ return { content: '❌ 需要提供分组名称' };
1938
+ }
1939
+ const group = cm.createGroup({
1940
+ name: params.name,
1941
+ description: params.description,
1942
+ color: params.color,
1943
+ });
1944
+ return { content: `✅ 已创建分组: ${group.name}` };
1945
+ }
1946
+ case 'update': {
1947
+ if (!params.group_id) {
1948
+ return { content: '❌ 需要提供 group_id' };
1949
+ }
1950
+ const group = cm.updateGroup(params.group_id, {
1951
+ name: params.name,
1952
+ description: params.description,
1953
+ color: params.color,
1954
+ });
1955
+ return { content: group ? `✅ 已更新分组: ${group.name}` : '❌ 分组不存在' };
1956
+ }
1957
+ case 'delete': {
1958
+ if (!params.group_id) {
1959
+ return { content: '❌ 需要提供 group_id' };
1960
+ }
1961
+ const success = cm.deleteGroup(params.group_id);
1962
+ return { content: success ? '✅ 已删除分组' : '❌ 无法删除(分组不存在或为默认分组)' };
1963
+ }
1964
+ default:
1965
+ return { content: '❌ 未知操作' };
1966
+ }
1967
+ }
1968
+ catch (err) {
1969
+ return { content: `❌ 操作失败: ${err instanceof Error ? err.message : String(err)}` };
1970
+ }
1971
+ }
1972
+ /**
1973
+ * 处理好友请求工具
1974
+ */
1975
+ async handleFriendRequest(params, _context) {
1976
+ try {
1977
+ if (!this._f2a) {
1978
+ return { content: '❌ F2A 实例未初始化' };
1979
+ }
1980
+ if (!this._contactManager) {
1981
+ // 确保联系人管理器已初始化
1982
+ this.contactManager;
1983
+ }
1984
+ if (!this._handshakeProtocol) {
1985
+ // 初始化握手协议
1986
+ this.handshakeProtocol;
1987
+ }
1988
+ if (!this._handshakeProtocol) {
1989
+ return { content: '❌ 握手协议未初始化' };
1990
+ }
1991
+ const requestId = await this._handshakeProtocol.sendFriendRequest(params.peer_id, params.message);
1992
+ if (requestId) {
1993
+ return { content: `✅ 好友请求已发送\n请求 ID: ${requestId}\n等待对方响应...` };
1994
+ }
1995
+ else {
1996
+ return { content: '❌ 发送好友请求失败' };
1997
+ }
1998
+ }
1999
+ catch (err) {
2000
+ return { content: `❌ 发送失败: ${err instanceof Error ? err.message : String(err)}` };
2001
+ }
2002
+ }
2003
+ /**
2004
+ * 处理待处理请求工具
2005
+ */
2006
+ async handlePendingRequests(params, _context) {
2007
+ try {
2008
+ const cm = this.contactManager;
2009
+ switch (params.action) {
2010
+ case 'list': {
2011
+ const pending = cm.getPendingHandshakes();
2012
+ if (pending.length === 0) {
2013
+ return { content: '📭 暂无待处理的好友请求' };
2014
+ }
2015
+ return {
2016
+ content: `📬 **待处理的好友请求** (${pending.length} 个)\n\n` +
2017
+ pending.map(p => `• **${p.fromName}**\n Peer: ${p.from.slice(0, 16)}...\n 请求 ID: ${p.requestId}\n 收到: ${new Date(p.receivedAt).toLocaleString()}` +
2018
+ (p.message ? `\n 消息: ${p.message}` : '')).join('\n\n')
2019
+ };
2020
+ }
2021
+ case 'accept': {
2022
+ if (!params.request_id) {
2023
+ return { content: '❌ 需要提供 request_id' };
2024
+ }
2025
+ if (!this._handshakeProtocol) {
2026
+ return { content: '❌ 握手协议未初始化' };
2027
+ }
2028
+ const success = await this._handshakeProtocol.acceptRequest(params.request_id);
2029
+ return { content: success ? '✅ 已接受好友请求,双方已成为好友' : '❌ 接受失败' };
2030
+ }
2031
+ case 'reject': {
2032
+ if (!params.request_id) {
2033
+ return { content: '❌ 需要提供 request_id' };
2034
+ }
2035
+ if (!this._handshakeProtocol) {
2036
+ return { content: '❌ 握手协议未初始化' };
2037
+ }
2038
+ const success = await this._handshakeProtocol.rejectRequest(params.request_id, params.reason);
2039
+ return { content: success ? '✅ 已拒绝好友请求' : '❌ 拒绝失败' };
2040
+ }
2041
+ default:
2042
+ return { content: '❌ 未知操作' };
2043
+ }
2044
+ }
2045
+ catch (err) {
2046
+ return { content: `❌ 操作失败: ${err instanceof Error ? err.message : String(err)}` };
2047
+ }
2048
+ }
2049
+ /**
2050
+ * 处理导出通讯录工具
2051
+ */
2052
+ async handleContactsExport(_params, _context) {
2053
+ try {
2054
+ const cm = this.contactManager;
2055
+ const data = cm.exportContacts(this._f2a?.peerId);
2056
+ return {
2057
+ content: `📤 **通讯录导出成功**\n\n` +
2058
+ `联系人: ${data.contacts.length} 个\n` +
2059
+ `分组: ${data.groups.length} 个\n` +
2060
+ `导出时间: ${new Date(data.exportedAt).toLocaleString()}\n\n` +
2061
+ '```json\n' + JSON.stringify(data, null, 2) + '\n```',
2062
+ data,
2063
+ };
2064
+ }
2065
+ catch (err) {
2066
+ return { content: `❌ 导出失败: ${err instanceof Error ? err.message : String(err)}` };
2067
+ }
2068
+ }
2069
+ /**
2070
+ * 处理导入通讯录工具
2071
+ */
2072
+ async handleContactsImport(params, _context) {
2073
+ try {
2074
+ const cm = this.contactManager;
2075
+ const result = cm.importContacts(params.data, params.merge ?? true);
2076
+ if (result.success) {
2077
+ return {
2078
+ content: `📥 **通讯录导入完成**\n\n` +
2079
+ `✅ 导入联系人: ${result.importedContacts} 个\n` +
2080
+ `✅ 导入分组: ${result.importedGroups} 个\n` +
2081
+ `⏭️ 跳过联系人: ${result.skippedContacts} 个` +
2082
+ (result.errors.length ? `\n\n⚠️ 错误:\n${result.errors.join('\n')}` : '')
2083
+ };
2084
+ }
2085
+ else {
2086
+ return {
2087
+ content: `❌ 导入失败\n\n错误:\n${result.errors.join('\n')}`
2088
+ };
2089
+ }
2090
+ }
2091
+ catch (err) {
2092
+ return { content: `❌ 导入失败: ${err instanceof Error ? err.message : String(err)}` };
2093
+ }
2094
+ }
1309
2095
  /**
1310
2096
  * 关闭插件,清理资源
1311
2097
  * 只清理已初始化的资源
@@ -1317,6 +2103,18 @@ class F2AOpenClawAdapter {
1317
2103
  clearInterval(this.pollTimer);
1318
2104
  this.pollTimer = undefined;
1319
2105
  }
2106
+ // Issue #99: 关闭握手协议
2107
+ if (this._handshakeProtocol) {
2108
+ this._handshakeProtocol.shutdown();
2109
+ this._logger?.info('[F2A Adapter] HandshakeProtocol 已关闭');
2110
+ this._handshakeProtocol = undefined;
2111
+ }
2112
+ // Issue #98: 刷新通讯录数据
2113
+ if (this._contactManager) {
2114
+ this._contactManager.flush();
2115
+ this._logger?.info('[F2A Adapter] ContactManager 数据已保存');
2116
+ this._contactManager = undefined;
2117
+ }
1320
2118
  // 停止 F2A 实例(新架构直接管理)
1321
2119
  if (this._f2a) {
1322
2120
  try {
@@ -1324,7 +2122,7 @@ class F2AOpenClawAdapter {
1324
2122
  this._logger?.info('[F2A Adapter] F2A 实例已停止');
1325
2123
  }
1326
2124
  catch (err) {
1327
- this._logger?.warn('[F2A Adapter] F2A 实例停止失败', { error: err instanceof Error ? err.message : String(err) });
2125
+ this._logger?.warn('[F2A Adapter] F2A 实例停止失败', { error: extractErrorMessage(err) });
1328
2126
  }
1329
2127
  this._f2a = undefined;
1330
2128
  }