libp2p-mesh 2026.6.19 → 2026.6.20

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 CHANGED
@@ -75,6 +75,159 @@ The generated config shape is:
75
75
  }
76
76
  ```
77
77
 
78
+ ## 安装后配置流程
79
+
80
+ 推荐用户安装插件后按下面顺序配置。所有配置都通过 CLI 完成,不需要手动打开 `openclaw.json`。
81
+
82
+ ### 1. 安装或更新插件
83
+
84
+ ```bash
85
+ openclaw plugins update libp2p-mesh@latest
86
+ ```
87
+
88
+ 如果是首次安装,也可以使用:
89
+
90
+ ```bash
91
+ openclaw install libp2p-mesh
92
+ ```
93
+
94
+ ### 2. 运行网络和入站目标配置向导
95
+
96
+ ```bash
97
+ openclaw libp2p-mesh setup
98
+ ```
99
+
100
+ 这个向导会写入:
101
+
102
+ ```text
103
+ plugins.entries["libp2p-mesh"].config
104
+ ```
105
+
106
+ 不会写入 `channels["libp2p-mesh"]`。常见选择:
107
+
108
+ - 同一局域网测试:选择 LAN / mDNS。
109
+ - 跨网络或需要 relay:选择 bootstrap / relay 相关模式。
110
+ - 需要把收到的 P2P 消息投递到飞书、QQ、Telegram 等 channel:配置 `inboundTargets`。
111
+ - 暂时只需要工具能力、不接收消息:选择禁用入站投递,写入 `inboundTargets: []`。
112
+
113
+ 入站目标示例:
114
+
115
+ ```json
116
+ {
117
+ "id": "feishu-main",
118
+ "channel": "feishu",
119
+ "target": "user:ou_xxx"
120
+ }
121
+ ```
122
+
123
+ QQ 单聊示例:
124
+
125
+ ```json
126
+ {
127
+ "id": "qqbot-main",
128
+ "channel": "qqbot",
129
+ "target": "user:<senderId>"
130
+ }
131
+ ```
132
+
133
+ 其中 `<senderId>` 可以从 QQ channel 日志里的 `senderId` 取得。
134
+
135
+ ### 3. 一键安装固定 Agent 提示词
136
+
137
+ ```bash
138
+ openclaw libp2p-mesh prompt install
139
+ ```
140
+
141
+ 该命令会把内置的 P2P 中继助手规则写入:
142
+
143
+ ```text
144
+ ~/.openclaw/workspace/AGENTS.md
145
+ ```
146
+
147
+ 它不会覆盖整个文件,只维护下面两个 marker 之间的区块:
148
+
149
+ ```md
150
+ <!-- libp2p-mesh:prompt:start -->
151
+ # P2P 中继助手规则
152
+ ...
153
+ <!-- libp2p-mesh:prompt:end -->
154
+ ```
155
+
156
+ 如果区块已经存在,命令会询问是否更新到插件内置的最新版。
157
+
158
+ ### 4. 可选:配置用户公开属性
159
+
160
+ ```bash
161
+ openclaw libp2p-mesh profile
162
+ ```
163
+
164
+ `USER.md` 中的公开 tag 会在 gateway 启动时只读提取,不会写回 `USER.md`。用户手动新增的结构化属性会保存到:
165
+
166
+ ```text
167
+ ~/.openclaw/libp2p/user-profile.json
168
+ ```
169
+
170
+ 例如:
171
+
172
+ ```json
173
+ {
174
+ "kind": "structured",
175
+ "key": "group",
176
+ "value": "实验室",
177
+ "label": "实验室",
178
+ "source": "profile"
179
+ }
180
+ ```
181
+
182
+ 之后按属性发送时使用 selector:
183
+
184
+ ```text
185
+ group=实验室
186
+ tag:P2P
187
+ #P2P
188
+ ```
189
+
190
+ `selector` 是发送工具调用时的临时匹配条件,不需要写入配置文件。
191
+
192
+ ### 5. 可选:配置 announce 日志级别
193
+
194
+ ```bash
195
+ openclaw libp2p-mesh debug
196
+ ```
197
+
198
+ 推荐保持默认 `summary`。如果排查发现、属性或地址广播问题,可以临时切到 `payload` 查看完整 announce JSON;排查结束后再切回 `summary` 或 `off`。
199
+
200
+ ### 6. 可选:配置远端实例本地标签
201
+
202
+ ```bash
203
+ openclaw libp2p-mesh labels
204
+ ```
205
+
206
+ Labels are private local notes for remote instances you have already discovered. Use them when you want your own grouping to drive sends without asking the remote user to publish that attribute.
207
+
208
+ ### 7. 重启 gateway
209
+
210
+ 完成 `setup`、`prompt install` 或 `profile` 后,重启 gateway 让配置和提示词生效:
211
+
212
+ ```bash
213
+ openclaw gateway restart
214
+ ```
215
+
216
+ 或者停止后重新运行:
217
+
218
+ ```bash
219
+ openclaw gateway
220
+ ```
221
+
222
+ 启动后可以观察日志:
223
+
224
+ ```text
225
+ [libp2p-mesh] Sent instance announce ... attrs=2
226
+ [libp2p-mesh] Received instance announce ... changed=true
227
+ ```
228
+
229
+ 其中 `attrs` 是本次 announce 中携带的用户公开属性数量。
230
+
78
231
  ## Configuration
79
232
 
80
233
  Use the interactive setup command for first-time configuration and later edits:
@@ -471,7 +624,7 @@ Tools are not configured in `openclaw.json`; they are registered automatically b
471
624
 
472
625
  `libp2p-mesh` can announce user public attributes with instance route announcements. These attributes help agents find matching OpenClaw instances after those instances have already been discovered through the mesh.
473
626
 
474
- There are two sources:
627
+ There are two public sources:
475
628
 
476
629
  - `USER.md` tags are extracted read-only at gateway startup. The plugin never edits `USER.md`.
477
630
  - `user-profile.json` stores manually managed structured attributes such as group, project, role, skill, or a custom key.
@@ -568,12 +721,54 @@ Remote attributes are cached in plugin-managed instance state under `instance-pe
568
721
  }
569
722
  ```
570
723
 
571
- Use `p2p_send_user_attribute_message` for attribute-based group messages. Always dry-run first. If the dry run matches targets, call the same tool again immediately with the same selector and `dryRun: false`:
724
+ ### Local peer labels
725
+
726
+ Use local peer labels when you want to classify remote instances privately on this machine:
727
+
728
+ ```bash
729
+ openclaw libp2p-mesh labels
730
+ ```
731
+
732
+ The default labels path is:
733
+
734
+ ```text
735
+ ~/.openclaw/libp2p/peer-labels.json
736
+ ```
737
+
738
+ When `OPENCLAW_STATE_DIR` is set:
739
+
740
+ ```text
741
+ $OPENCLAW_STATE_DIR/libp2p/peer-labels.json
742
+ ```
743
+
744
+ Example `peer-labels.json`:
745
+
746
+ ```json
747
+ {
748
+ "version": 1,
749
+ "updatedAt": 1782180000000,
750
+ "peers": {
751
+ "alice-mac@AQIDBAUGBweI.7a3f9e2b": {
752
+ "labels": [
753
+ { "key": "group", "value": "实验室" },
754
+ { "key": "project", "value": "openclaw" }
755
+ ]
756
+ }
757
+ }
758
+ }
759
+ ```
760
+
761
+ Privacy boundary: `peer-labels.json` is local state for your gateway. It is not announced to peers, not written into remote `instance-peer.json.userPublicAttributes`, and not visible to the remote user through the mesh protocol. Public attributes in `USER.md` and `user-profile.json` are still broadcast with instance announce messages.
762
+
763
+ Use `p2p_send_user_attribute_message` for attribute-based group messages. It defaults to public attributes only, equivalent to `scope="public"`. Always dry-run first. If the dry run matches targets, call the same tool again immediately with the same selector, scope, message, and `dryRun: false`.
764
+
765
+ Public scope matches attributes that remote instances announced from their own `USER.md` or `user-profile.json`:
572
766
 
573
767
  ```text
574
768
  p2p_send_user_attribute_message({
575
769
  "selector": "project=openclaw",
576
770
  "message": "今晚同步一下进展",
771
+ "scope": "public",
577
772
  "dryRun": true
578
773
  })
579
774
  ```
@@ -584,11 +779,34 @@ After a matching dry run:
584
779
  p2p_send_user_attribute_message({
585
780
  "selector": "project=openclaw",
586
781
  "message": "今晚同步一下进展",
782
+ "scope": "public",
587
783
  "dryRun": false
588
784
  })
589
785
  ```
590
786
 
591
- Selectors use `key=value` for structured profile attributes. Tag matches use `tag:value` or `#value`. Bare selectors such as `实验室` are rejected because they are ambiguous; use `group=实验室` for a structured group or `tag:实验室` for a USER.md tag.
787
+ Local scope, written as `scope="local"` in prompt instructions or `"scope": "local"` in tool JSON, matches only labels from your `peer-labels.json`:
788
+
789
+ ```text
790
+ p2p_send_user_attribute_message({
791
+ "selector": "group=实验室",
792
+ "message": "我按本地归类发一个提醒",
793
+ "scope": "local",
794
+ "dryRun": true
795
+ })
796
+ ```
797
+
798
+ All scope matches both sources and deduplicates by instance:
799
+
800
+ ```text
801
+ p2p_send_user_attribute_message({
802
+ "selector": "project=openclaw",
803
+ "message": "公开属性和本地标签都算",
804
+ "scope": "all",
805
+ "dryRun": true
806
+ })
807
+ ```
808
+
809
+ Selectors use `key=value` for structured profile attributes or local labels. Tag matches use `tag:value` or `#value` for public `USER.md` tags. Bare selectors such as `实验室` are rejected because they are ambiguous; use `group=实验室` for a structured group or `tag:实验室` for a USER.md tag.
592
810
 
593
811
  ```text
594
812
  p2p_send_user_attribute_message({
@@ -1,4 +1,7 @@
1
- import type { DeliveryTargetResult, InstanceRouter, MeshNetwork, UserPublicAttribute } from "./types.js";
1
+ import type { DeliveryTargetResult, InstancePeerRecord, InstanceRouter, LocalPeerLabelAttribute, MeshNetwork, PeerLabelStore } from "./types.js";
2
+ type BuildP2PToolsOptions = {
3
+ peerLabelStore?: Pick<PeerLabelStore, "listLabels">;
4
+ };
2
5
  type SendUserAttributeToolParams = {
3
6
  selector?: unknown;
4
7
  match?: {
@@ -8,8 +11,9 @@ type SendUserAttributeToolParams = {
8
11
  };
9
12
  message?: unknown;
10
13
  dryRun?: unknown;
14
+ scope?: unknown;
11
15
  };
12
- export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter): ({
16
+ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter, options?: BuildP2PToolsOptions): ({
13
17
  name: string;
14
18
  label: string;
15
19
  description: string;
@@ -29,8 +33,10 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
29
33
  selector?: undefined;
30
34
  match?: undefined;
31
35
  dryRun?: undefined;
36
+ scope?: undefined;
32
37
  };
33
38
  required: string[];
39
+ anyOf?: undefined;
34
40
  };
35
41
  execute(_toolCallId: string, params: {
36
42
  peerId: string;
@@ -78,8 +84,10 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
78
84
  selector?: undefined;
79
85
  match?: undefined;
80
86
  dryRun?: undefined;
87
+ scope?: undefined;
81
88
  };
82
89
  required: string[];
90
+ anyOf?: undefined;
83
91
  };
84
92
  execute(_toolCallId: string, params: {
85
93
  topic: string;
@@ -121,8 +129,10 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
121
129
  selector?: undefined;
122
130
  match?: undefined;
123
131
  dryRun?: undefined;
132
+ scope?: undefined;
124
133
  };
125
134
  required?: undefined;
135
+ anyOf?: undefined;
126
136
  };
127
137
  execute(_toolCallId: string): Promise<{
128
138
  content: {
@@ -163,8 +173,10 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
163
173
  selector?: undefined;
164
174
  match?: undefined;
165
175
  dryRun?: undefined;
176
+ scope?: undefined;
166
177
  };
167
178
  required?: undefined;
179
+ anyOf?: undefined;
168
180
  };
169
181
  execute(_toolCallId: string): Promise<{
170
182
  content: {
@@ -214,8 +226,10 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
214
226
  selector?: undefined;
215
227
  match?: undefined;
216
228
  dryRun?: undefined;
229
+ scope?: undefined;
217
230
  };
218
231
  required?: undefined;
232
+ anyOf?: undefined;
219
233
  };
220
234
  execute(_toolCallId: string): Promise<{
221
235
  content: {
@@ -258,8 +272,10 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
258
272
  selector?: undefined;
259
273
  match?: undefined;
260
274
  dryRun?: undefined;
275
+ scope?: undefined;
261
276
  };
262
277
  required?: undefined;
278
+ anyOf?: undefined;
263
279
  };
264
280
  execute(_toolCallId: string): Promise<{
265
281
  content: {
@@ -279,18 +295,10 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
279
295
  text: string;
280
296
  }[];
281
297
  details: {
282
- instances: {
298
+ instances: (InstancePeerRecord & {
283
299
  connected: boolean;
284
- instanceId: string;
285
- peerId: string;
286
- instanceName?: string;
287
- multiaddrs: string[];
288
- pubkey?: string;
289
- userPublicAttributes?: UserPublicAttribute[];
290
- lastSeenAt: number;
291
- lastAnnouncedAt: number;
292
- source: "announce";
293
- }[];
300
+ localLabels: LocalPeerLabelAttribute[];
301
+ })[];
294
302
  count: number;
295
303
  initialized?: undefined;
296
304
  error?: undefined;
@@ -326,8 +334,10 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
326
334
  selector?: undefined;
327
335
  match?: undefined;
328
336
  dryRun?: undefined;
337
+ scope?: undefined;
329
338
  };
330
339
  required: string[];
340
+ anyOf?: undefined;
331
341
  };
332
342
  execute(_toolCallId: string, params: {
333
343
  instanceId: string;
@@ -377,7 +387,7 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
377
387
  }[];
378
388
  details: {
379
389
  found: boolean;
380
- route: import("./types.js").InstancePeerRecord;
390
+ route: InstancePeerRecord;
381
391
  initialized?: undefined;
382
392
  error?: undefined;
383
393
  instanceId?: undefined;
@@ -404,8 +414,10 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
404
414
  selector?: undefined;
405
415
  match?: undefined;
406
416
  dryRun?: undefined;
417
+ scope?: undefined;
407
418
  };
408
419
  required: string[];
420
+ anyOf?: undefined;
409
421
  };
410
422
  execute(_toolCallId: string, params: {
411
423
  instanceId: string;
@@ -521,11 +533,19 @@ export declare function buildP2PTools(mesh: MeshNetwork, router?: InstanceRouter
521
533
  type: "boolean";
522
534
  description: string;
523
535
  };
536
+ scope: {
537
+ type: "string";
538
+ enum: string[];
539
+ description: string;
540
+ };
524
541
  peerId?: undefined;
525
542
  topic?: undefined;
526
543
  instanceId?: undefined;
527
544
  };
528
545
  required: string[];
546
+ anyOf: {
547
+ required: string[];
548
+ }[];
529
549
  };
530
550
  execute(_toolCallId: string, params: SendUserAttributeToolParams): Promise<{
531
551
  content: {
@@ -23,7 +23,7 @@ function instanceTargetLabel(target) {
23
23
  return `${target.instanceId}${name} -> ${target.peerId}`;
24
24
  }
25
25
  function formatUserAttributeTargets(targets) {
26
- return targets.map((target) => `${instanceTargetLabel(target)} [${attributeLabel(target.matchedAttribute)}]`);
26
+ return targets.map((target) => `${instanceTargetLabel(target)} [${target.matchSource}:${attributeLabel(target.matchedAttribute)}]`);
27
27
  }
28
28
  function formatUserAttributeResults(results) {
29
29
  return results.map((result) => {
@@ -37,6 +37,78 @@ function formatUserAttributeResults(results) {
37
37
  return `${instanceTargetLabel(result)}:${status}`;
38
38
  });
39
39
  }
40
+ function formatPublicAttribute(attribute) {
41
+ if (attribute.kind === "tag") {
42
+ return [
43
+ " - kind: tag",
44
+ ` value: ${attribute.value}`,
45
+ ` label: ${attribute.label}`,
46
+ ` source: ${attribute.source}`,
47
+ ];
48
+ }
49
+ return [
50
+ " - kind: structured",
51
+ ` key: ${attribute.key}`,
52
+ ` value: ${attribute.value}`,
53
+ ` label: ${attribute.label}`,
54
+ ` source: ${attribute.source}`,
55
+ ];
56
+ }
57
+ function formatLocalLabel(label) {
58
+ return [
59
+ " - kind: structured",
60
+ ` key: ${label.key}`,
61
+ ` value: ${label.value}`,
62
+ ` label: ${label.label}`,
63
+ ` source: ${label.source}`,
64
+ ];
65
+ }
66
+ function formatInstanceList(rows) {
67
+ if (rows.length === 0) {
68
+ return "No OpenClaw instances discovered yet.";
69
+ }
70
+ const lines = [`Discovered OpenClaw instances: ${rows.length}`];
71
+ rows.forEach((entry, index) => {
72
+ lines.push("", `${index + 1}. ${entry.instanceId}`, ` peerId: ${entry.peerId}`, ` instanceName: ${entry.instanceName ?? "(none)"}`, ` connected: ${entry.connected}`);
73
+ if ((entry.userPublicAttributes ?? []).length === 0) {
74
+ lines.push(" userPublicAttributes: none");
75
+ }
76
+ else {
77
+ lines.push(" userPublicAttributes:");
78
+ for (const attribute of entry.userPublicAttributes ?? []) {
79
+ lines.push(...formatPublicAttribute(attribute));
80
+ }
81
+ }
82
+ if (entry.localLabels.length === 0) {
83
+ lines.push(" localLabels: none");
84
+ }
85
+ else {
86
+ lines.push(" localLabels:");
87
+ for (const label of entry.localLabels) {
88
+ lines.push(...formatLocalLabel(label));
89
+ }
90
+ }
91
+ });
92
+ return lines.join("\n");
93
+ }
94
+ function normalizeUserAttributeScope(scope) {
95
+ if (scope === undefined) {
96
+ return undefined;
97
+ }
98
+ if (scope === "public" || scope === "local" || scope === "all") {
99
+ return scope;
100
+ }
101
+ return undefined;
102
+ }
103
+ function validateUserAttributeScope(scope) {
104
+ if (scope === undefined ||
105
+ scope === "public" ||
106
+ scope === "local" ||
107
+ scope === "all") {
108
+ return undefined;
109
+ }
110
+ return 'scope must be "public", "local", or "all".';
111
+ }
40
112
  function normalizeUserAttributeSelector(selector) {
41
113
  const value = typeof selector === "string" ? selector.trim() : "";
42
114
  if (!value) {
@@ -82,7 +154,7 @@ function normalizeUserAttributeMatch(params) {
82
154
  }
83
155
  return 'match.kind must be "tag" or "structured".';
84
156
  }
85
- export function buildP2PTools(mesh, router) {
157
+ export function buildP2PTools(mesh, router, options = {}) {
86
158
  return [
87
159
  {
88
160
  name: "p2p_send_message",
@@ -294,15 +366,15 @@ export function buildP2PTools(mesh, router) {
294
366
  try {
295
367
  const instances = await router.listInstances();
296
368
  const connected = new Set(mesh.getConnectedPeers());
297
- const rows = instances.map((entry) => ({
369
+ const rows = await Promise.all(instances.map(async (entry) => ({
298
370
  ...entry,
371
+ userPublicAttributes: entry.userPublicAttributes ?? [],
299
372
  connected: connected.has(entry.peerId),
300
- }));
301
- const text = rows.length === 0
302
- ? "No OpenClaw instances discovered yet."
303
- : rows
304
- .map((entry) => `${entry.instanceId} -> ${entry.peerId}${entry.connected ? " (connected)" : ""}`)
305
- .join("\n");
373
+ localLabels: options.peerLabelStore
374
+ ? await options.peerLabelStore.listLabels(entry.instanceId)
375
+ : [],
376
+ })));
377
+ const text = formatInstanceList(rows);
306
378
  return {
307
379
  content: [{ type: "text", text }],
308
380
  details: { instances: rows, count: rows.length },
@@ -442,7 +514,7 @@ export function buildP2PTools(mesh, router) {
442
514
  {
443
515
  name: "p2p_send_user_attribute_message",
444
516
  label: "P2P Send User Attribute Message",
445
- description: 'Send a user message to discovered OpenClaw instances matching a public user attribute selector. Use selectors like "group=实验室", "project=小龙虾", "tag:P2P", or "#P2P". First run a dry run with dryRun=true to preview targets; if targets match, call again immediately with dryRun=false and the same selector.',
517
+ description: 'Send a user message to discovered OpenClaw instances matching an attribute selector. scope controls the source: "public" for announced user attributes, "local" for local peer labels, or "all" for both; defaults to public. Use selectors like "group=实验室", "project=小龙虾", "tag:P2P", or "#P2P". First run a dry run with dryRun=true to preview targets; if targets match, call again immediately with dryRun=false and the same selector, scope, and message.',
446
518
  parameters: {
447
519
  type: "object",
448
520
  properties: {
@@ -493,8 +565,14 @@ export function buildP2PTools(mesh, router) {
493
565
  type: "boolean",
494
566
  description: "Preview matching instances without sending. Run this before group sending.",
495
567
  },
568
+ scope: {
569
+ type: "string",
570
+ enum: ["public", "local", "all"],
571
+ description: "Attribute source to match: public announced attributes, local peer labels, or both. Defaults to public.",
572
+ },
496
573
  },
497
- required: ["selector", "message"],
574
+ required: ["message"],
575
+ anyOf: [{ required: ["selector"] }, { required: ["match"] }],
498
576
  },
499
577
  async execute(_toolCallId, params) {
500
578
  if (!router) {
@@ -505,9 +583,15 @@ export function buildP2PTools(mesh, router) {
505
583
  };
506
584
  }
507
585
  const match = normalizeUserAttributeMatch(params);
586
+ const scopeError = validateUserAttributeScope(params.scope);
587
+ const scope = normalizeUserAttributeScope(params.scope);
508
588
  const message = typeof params.message === "string" ? params.message.trim() : "";
509
- if (typeof match === "string" || !message) {
510
- const error = typeof match === "string" ? match : "message is required.";
589
+ if (typeof match === "string" || scopeError || !message) {
590
+ const error = typeof match === "string"
591
+ ? match
592
+ : scopeError
593
+ ? scopeError
594
+ : "message is required.";
511
595
  return {
512
596
  content: [{ type: "text", text: error }],
513
597
  details: { error },
@@ -515,7 +599,8 @@ export function buildP2PTools(mesh, router) {
515
599
  };
516
600
  }
517
601
  const dryRun = params.dryRun === true;
518
- const result = await router.sendUserAttributeMessage(match, message, { dryRun });
602
+ const options = scope ? { dryRun, scope } : { dryRun };
603
+ const result = await router.sendUserAttributeMessage(match, message, options);
519
604
  if (result.error) {
520
605
  return {
521
606
  content: [{ type: "text", text: result.error }],
@@ -1,4 +1,4 @@
1
- import { matchesUserAttribute, mergeUserPublicAttributes } from "./user-attributes.js";
1
+ import { matchesUserAttribute, mergeUserPublicAttributes, normalizeAttributeKey, normalizeAttributeValue, } from "./user-attributes.js";
2
2
  const MAX_DELIVERY_CACHE_ENTRIES = 1000;
3
3
  function parsePayload(msg) {
4
4
  try {
@@ -34,6 +34,25 @@ function describeAttributeMatch(match) {
34
34
  }
35
35
  return `structured attribute ${match.key}=${match.value}`;
36
36
  }
37
+ function effectiveAttributeMatchScope(scope) {
38
+ return scope ?? "public";
39
+ }
40
+ function matchesLocalPeerLabel(attribute, match) {
41
+ if (match.kind !== "structured") {
42
+ return false;
43
+ }
44
+ return (normalizeAttributeKey(attribute.key) === normalizeAttributeKey(match.key) &&
45
+ normalizeAttributeValue(attribute.value) === normalizeAttributeValue(match.value));
46
+ }
47
+ function buildUserAttributeTarget(record, matchedAttribute, matchSource) {
48
+ return {
49
+ instanceId: record.instanceId,
50
+ ...(record.instanceName ? { instanceName: record.instanceName } : {}),
51
+ peerId: record.peerId,
52
+ matchedAttribute,
53
+ matchSource,
54
+ };
55
+ }
37
56
  function displayTargetId(target) {
38
57
  const id = typeof target.id === "string" ? target.id.trim() : "";
39
58
  return id && id.length > 0 ? id : undefined;
@@ -475,27 +494,36 @@ export function createInstanceRouter(options) {
475
494
  error: ack.error,
476
495
  };
477
496
  }
478
- async function resolveUserAttributeTargets(match) {
497
+ async function resolveUserAttributeTargets(match, scope) {
479
498
  const records = await store.list();
480
499
  const targets = [];
481
500
  for (const record of records) {
482
- const matchedAttribute = record.userPublicAttributes?.find((attribute) => matchesUserAttribute(attribute, match));
483
- if (!matchedAttribute) {
501
+ const publicAttribute = scope === "local"
502
+ ? undefined
503
+ : record.userPublicAttributes?.find((attribute) => matchesUserAttribute(attribute, match));
504
+ const localAttribute = scope === "public"
505
+ ? undefined
506
+ : (await options.peerLabelStore?.listLabels(record.instanceId))?.find((attribute) => matchesLocalPeerLabel(attribute, match));
507
+ if (publicAttribute && localAttribute && scope === "all") {
508
+ targets.push(buildUserAttributeTarget(record, publicAttribute, "all"));
484
509
  continue;
485
510
  }
486
- targets.push({
487
- instanceId: record.instanceId,
488
- instanceName: record.instanceName,
489
- peerId: record.peerId,
490
- matchedAttribute,
491
- });
511
+ if (publicAttribute) {
512
+ targets.push(buildUserAttributeTarget(record, publicAttribute, "public"));
513
+ continue;
514
+ }
515
+ if (localAttribute) {
516
+ targets.push(buildUserAttributeTarget(record, localAttribute, "local"));
517
+ }
492
518
  }
493
519
  return targets;
494
520
  }
495
521
  async function sendUserAttributeMessage(match, message, sendOptions = {}) {
496
- const targets = await resolveUserAttributeTargets(match);
522
+ const scope = effectiveAttributeMatchScope(sendOptions.scope);
523
+ const targets = await resolveUserAttributeTargets(match, scope);
497
524
  if (targets.length === 0) {
498
525
  return {
526
+ scope,
499
527
  matched: 0,
500
528
  sent: 0,
501
529
  delivered: 0,
@@ -505,6 +533,7 @@ export function createInstanceRouter(options) {
505
533
  }
506
534
  if (sendOptions.dryRun === true) {
507
535
  return {
536
+ scope,
508
537
  matched: targets.length,
509
538
  sent: 0,
510
539
  delivered: 0,
@@ -538,6 +567,7 @@ export function createInstanceRouter(options) {
538
567
  }
539
568
  }
540
569
  return {
570
+ scope,
541
571
  matched: targets.length,
542
572
  sent: results.filter((result) => result.sent).length,
543
573
  delivered: results.filter((result) => result.delivered).length,