libp2p-mesh 2026.6.11 → 2026.6.13
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 +124 -0
- package/dist/src/agent-tools.d.ts +120 -1
- package/dist/src/agent-tools.js +153 -0
- package/dist/src/instance-peer-store.js +35 -1
- package/dist/src/instance-router.d.ts +2 -8
- package/dist/src/instance-router.js +94 -2
- package/dist/src/plugin.js +16 -3
- package/dist/src/profile-cli.d.ts +27 -0
- package/dist/src/profile-cli.js +47 -0
- package/dist/src/profile-wizard.d.ts +20 -0
- package/dist/src/profile-wizard.js +141 -0
- package/dist/src/setup-cli.d.ts +19 -0
- package/dist/src/setup-cli.js +32 -28
- package/dist/src/types.d.ts +63 -0
- package/dist/src/user-attributes.d.ts +6 -0
- package/dist/src/user-attributes.js +92 -0
- package/dist/src/user-md-attributes.d.ts +12 -0
- package/dist/src/user-md-attributes.js +202 -0
- package/dist/src/user-profile-store.d.ts +25 -0
- package/dist/src/user-profile-store.js +187 -0
- package/openclaw.plugin.json +2 -1
- package/package.json +1 -1
- package/src/agent-tools.ts +187 -1
- package/src/instance-peer-store.ts +41 -1
- package/src/instance-router.ts +121 -12
- package/src/plugin.ts +18 -3
- package/src/profile-cli.ts +85 -0
- package/src/profile-wizard.ts +204 -0
- package/src/setup-cli.ts +40 -29
- package/src/types.ts +68 -0
- package/src/user-attributes.ts +122 -0
- package/src/user-md-attributes.ts +256 -0
- package/src/user-profile-store.ts +259 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { SetupCancelledError, type SetupPrompter } from "./setup-wizard.js";
|
|
2
|
+
import type { UserPublicAttribute } from "./types.js";
|
|
3
|
+
import {
|
|
4
|
+
mergeUserPublicAttributes,
|
|
5
|
+
normalizeAttributeKey,
|
|
6
|
+
normalizeUserPublicAttribute,
|
|
7
|
+
} from "./user-attributes.js";
|
|
8
|
+
|
|
9
|
+
const CANCELLED_MESSAGE = "Profile update cancelled. No changes were written.";
|
|
10
|
+
const SAVED_MESSAGE = "Profile attributes saved.\n\nRestart the gateway to broadcast updated attributes.";
|
|
11
|
+
|
|
12
|
+
export type UserProfileWriter = {
|
|
13
|
+
replaceAttributes(attributes: UserPublicAttribute[]): Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type RunProfileWizardOptions = {
|
|
17
|
+
prompter: SetupPrompter;
|
|
18
|
+
readOnlyTags: UserPublicAttribute[];
|
|
19
|
+
profileAttributes: UserPublicAttribute[];
|
|
20
|
+
writer: UserProfileWriter;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ProfileWizardResult =
|
|
24
|
+
| { status: "saved"; attributes: UserPublicAttribute[]; message: string }
|
|
25
|
+
| { status: "cancelled"; message: string };
|
|
26
|
+
|
|
27
|
+
type ProfileAction =
|
|
28
|
+
| "add-attribute"
|
|
29
|
+
| "edit-attribute"
|
|
30
|
+
| "remove-attribute"
|
|
31
|
+
| "preview-finish"
|
|
32
|
+
| "cancel";
|
|
33
|
+
|
|
34
|
+
export async function runProfileWizard(options: RunProfileWizardOptions): Promise<ProfileWizardResult> {
|
|
35
|
+
try {
|
|
36
|
+
const readOnlyTags = options.readOnlyTags
|
|
37
|
+
.map((attribute) => normalizeUserPublicAttribute(attribute))
|
|
38
|
+
.filter((attribute): attribute is UserPublicAttribute => attribute?.kind === "tag");
|
|
39
|
+
let attributes = normalizeProfileAttributes(options.profileAttributes);
|
|
40
|
+
|
|
41
|
+
options.prompter.print(formatProfileOverview(readOnlyTags, attributes));
|
|
42
|
+
|
|
43
|
+
while (true) {
|
|
44
|
+
const action = await options.prompter.select<ProfileAction>("What do you want to do?", [
|
|
45
|
+
{ label: "Add structured attribute", value: "add-attribute" },
|
|
46
|
+
{ label: "Edit structured attribute", value: "edit-attribute" },
|
|
47
|
+
{ label: "Remove structured attribute", value: "remove-attribute" },
|
|
48
|
+
{ label: "Preview and finish", value: "preview-finish" },
|
|
49
|
+
{ label: "Cancel", value: "cancel" },
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
switch (action) {
|
|
53
|
+
case "add-attribute":
|
|
54
|
+
attributes = mergeUserPublicAttributes([], [...attributes, await promptForAttribute(options.prompter)]);
|
|
55
|
+
options.prompter.print(formatProfileOverview(readOnlyTags, attributes));
|
|
56
|
+
break;
|
|
57
|
+
case "edit-attribute":
|
|
58
|
+
attributes = await promptForAttributeEdit(options.prompter, attributes);
|
|
59
|
+
options.prompter.print(formatProfileOverview(readOnlyTags, attributes));
|
|
60
|
+
break;
|
|
61
|
+
case "remove-attribute":
|
|
62
|
+
attributes = await promptForAttributeRemoval(options.prompter, attributes);
|
|
63
|
+
options.prompter.print(formatProfileOverview(readOnlyTags, attributes));
|
|
64
|
+
break;
|
|
65
|
+
case "preview-finish":
|
|
66
|
+
options.prompter.print(formatProfilePreview(readOnlyTags, attributes));
|
|
67
|
+
if (!(await options.prompter.confirm("Save profile attributes?", true))) {
|
|
68
|
+
return cancelledResult();
|
|
69
|
+
}
|
|
70
|
+
await options.writer.replaceAttributes(attributes);
|
|
71
|
+
return {
|
|
72
|
+
status: "saved",
|
|
73
|
+
attributes,
|
|
74
|
+
message: SAVED_MESSAGE,
|
|
75
|
+
};
|
|
76
|
+
case "cancel":
|
|
77
|
+
return cancelledResult();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof SetupCancelledError) {
|
|
82
|
+
return cancelledResult();
|
|
83
|
+
}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeProfileAttributes(attributes: UserPublicAttribute[]): UserPublicAttribute[] {
|
|
89
|
+
return mergeUserPublicAttributes([], attributes).filter(
|
|
90
|
+
(attribute): attribute is UserPublicAttribute & { kind: "structured" } => attribute.kind === "structured",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function promptForAttribute(prompter: SetupPrompter): Promise<UserPublicAttribute> {
|
|
95
|
+
const category = await prompter.select("Attribute category", [
|
|
96
|
+
{ label: "Group", value: "group" },
|
|
97
|
+
{ label: "Project", value: "project" },
|
|
98
|
+
{ label: "Role", value: "role" },
|
|
99
|
+
{ label: "Skill", value: "skill" },
|
|
100
|
+
{ label: "Custom key", value: "custom" },
|
|
101
|
+
]);
|
|
102
|
+
const key = category === "custom"
|
|
103
|
+
? normalizeAttributeKey(await prompter.input("Custom key", { required: true }))
|
|
104
|
+
: category;
|
|
105
|
+
const value = await prompter.input("Attribute value", { required: true });
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
kind: "structured",
|
|
109
|
+
key,
|
|
110
|
+
value: value.trim(),
|
|
111
|
+
label: `${key}: ${value.trim()}`,
|
|
112
|
+
source: "profile",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function promptForAttributeEdit(
|
|
117
|
+
prompter: SetupPrompter,
|
|
118
|
+
attributes: UserPublicAttribute[],
|
|
119
|
+
): Promise<UserPublicAttribute[]> {
|
|
120
|
+
if (attributes.length === 0) {
|
|
121
|
+
prompter.print("No structured profile attributes configured.");
|
|
122
|
+
return attributes;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const selectedIndex = await selectAttributeIndex(prompter, "Attribute to edit", attributes);
|
|
126
|
+
const nextAttribute = await promptForAttribute(prompter);
|
|
127
|
+
|
|
128
|
+
return mergeUserPublicAttributes(
|
|
129
|
+
[],
|
|
130
|
+
attributes.map((attribute, index) => (index === selectedIndex ? nextAttribute : attribute)),
|
|
131
|
+
).filter((attribute) => attribute.kind === "structured");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function promptForAttributeRemoval(
|
|
135
|
+
prompter: SetupPrompter,
|
|
136
|
+
attributes: UserPublicAttribute[],
|
|
137
|
+
): Promise<UserPublicAttribute[]> {
|
|
138
|
+
if (attributes.length === 0) {
|
|
139
|
+
prompter.print("No structured profile attributes configured.");
|
|
140
|
+
return attributes;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const selectedIndex = await selectAttributeIndex(prompter, "Attribute to remove", attributes);
|
|
144
|
+
return attributes.filter((_attribute, index) => index !== selectedIndex);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function selectAttributeIndex(
|
|
148
|
+
prompter: SetupPrompter,
|
|
149
|
+
message: string,
|
|
150
|
+
attributes: UserPublicAttribute[],
|
|
151
|
+
): Promise<number> {
|
|
152
|
+
const selectedKey = await prompter.select(
|
|
153
|
+
message,
|
|
154
|
+
attributes.map((attribute, index) => ({
|
|
155
|
+
label: formatAttribute(attribute),
|
|
156
|
+
value: `attribute-index-${index}`,
|
|
157
|
+
})),
|
|
158
|
+
);
|
|
159
|
+
const match = /^attribute-index-(\d+)$/.exec(selectedKey);
|
|
160
|
+
return match ? Number(match[1]) : -1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatProfileOverview(readOnlyTags: UserPublicAttribute[], attributes: UserPublicAttribute[]): string {
|
|
164
|
+
return [
|
|
165
|
+
"Read-only USER.md tags:",
|
|
166
|
+
...formatAttributeList(readOnlyTags),
|
|
167
|
+
"",
|
|
168
|
+
"Structured profile attributes:",
|
|
169
|
+
...formatAttributeList(attributes),
|
|
170
|
+
].join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function formatProfilePreview(readOnlyTags: UserPublicAttribute[], attributes: UserPublicAttribute[]): string {
|
|
174
|
+
return [
|
|
175
|
+
"Preview: public attributes",
|
|
176
|
+
"",
|
|
177
|
+
"Read-only USER.md tags:",
|
|
178
|
+
...formatAttributeList(readOnlyTags),
|
|
179
|
+
"",
|
|
180
|
+
"Structured profile attributes to save:",
|
|
181
|
+
...formatAttributeList(attributes),
|
|
182
|
+
].join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formatAttributeList(attributes: UserPublicAttribute[]): string[] {
|
|
186
|
+
if (attributes.length === 0) {
|
|
187
|
+
return [" none"];
|
|
188
|
+
}
|
|
189
|
+
return attributes.map((attribute, index) => ` ${index + 1}. ${formatAttribute(attribute)}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatAttribute(attribute: UserPublicAttribute): string {
|
|
193
|
+
if (attribute.kind === "tag") {
|
|
194
|
+
return `${attribute.label} (USER.md tag, read-only)`;
|
|
195
|
+
}
|
|
196
|
+
return `${attribute.key}: ${attribute.value}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function cancelledResult(): ProfileWizardResult {
|
|
200
|
+
return {
|
|
201
|
+
status: "cancelled",
|
|
202
|
+
message: CANCELLED_MESSAGE,
|
|
203
|
+
};
|
|
204
|
+
}
|
package/src/setup-cli.ts
CHANGED
|
@@ -16,7 +16,18 @@ const SETUP_CLI_AFTER_WRITE = {
|
|
|
16
16
|
reason: "libp2p-mesh setup completed; restart manually to apply gateway changes.",
|
|
17
17
|
} as const;
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
export const LIBP2P_MESH_CLI_REGISTRATION = {
|
|
20
|
+
commands: ["libp2p-mesh"],
|
|
21
|
+
descriptors: [
|
|
22
|
+
{
|
|
23
|
+
name: "libp2p-mesh",
|
|
24
|
+
description: "Configure libp2p-mesh plugin.",
|
|
25
|
+
hasSubcommands: true,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ClosableSetupPrompter = SetupPrompter & {
|
|
20
31
|
close?: () => void;
|
|
21
32
|
};
|
|
22
33
|
|
|
@@ -32,37 +43,37 @@ export function registerLibp2pMeshSetupCli(api: OpenClawPluginApi, deps: SetupCl
|
|
|
32
43
|
.command("libp2p-mesh")
|
|
33
44
|
.description("Configure libp2p-mesh plugin.");
|
|
34
45
|
|
|
35
|
-
root
|
|
36
|
-
.command("setup")
|
|
37
|
-
.description("Run the libp2p-mesh setup wizard.")
|
|
38
|
-
.action(async () => {
|
|
39
|
-
const prompter = (deps.createPrompter?.(ctx) ?? createReadlinePrompter()) as ClosableSetupPrompter;
|
|
40
|
-
const writer = deps.createWriter?.(api) ?? createOpenClawConfigWriter(api);
|
|
41
|
-
try {
|
|
42
|
-
const result = await runSetupWizard({
|
|
43
|
-
currentConfig: ctx.config as OpenClawConfigLike,
|
|
44
|
-
prompter,
|
|
45
|
-
writer,
|
|
46
|
-
});
|
|
47
|
-
prompter.print(result.message);
|
|
48
|
-
} finally {
|
|
49
|
-
prompter.close?.();
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
commands: ["libp2p-mesh"],
|
|
55
|
-
descriptors: [
|
|
56
|
-
{
|
|
57
|
-
name: "libp2p-mesh",
|
|
58
|
-
description: "Configure libp2p-mesh plugin.",
|
|
59
|
-
hasSubcommands: true,
|
|
60
|
-
},
|
|
61
|
-
],
|
|
46
|
+
registerLibp2pMeshSetupCommand(root, api, ctx, deps);
|
|
62
47
|
},
|
|
48
|
+
LIBP2P_MESH_CLI_REGISTRATION,
|
|
63
49
|
);
|
|
64
50
|
}
|
|
65
51
|
|
|
52
|
+
export function registerLibp2pMeshSetupCommand(
|
|
53
|
+
root: { command(name: string): { description(text: string): { action(handler: () => Promise<void>): void } } },
|
|
54
|
+
api: OpenClawPluginApi,
|
|
55
|
+
ctx: OpenClawPluginCliContext,
|
|
56
|
+
deps: SetupCliDeps = {},
|
|
57
|
+
): void {
|
|
58
|
+
root
|
|
59
|
+
.command("setup")
|
|
60
|
+
.description("Run the libp2p-mesh setup wizard.")
|
|
61
|
+
.action(async () => {
|
|
62
|
+
const prompter = (deps.createPrompter?.(ctx) ?? createReadlinePrompter()) as ClosableSetupPrompter;
|
|
63
|
+
const writer = deps.createWriter?.(api) ?? createOpenClawConfigWriter(api);
|
|
64
|
+
try {
|
|
65
|
+
const result = await runSetupWizard({
|
|
66
|
+
currentConfig: ctx.config as OpenClawConfigLike,
|
|
67
|
+
prompter,
|
|
68
|
+
writer,
|
|
69
|
+
});
|
|
70
|
+
prompter.print(result.message);
|
|
71
|
+
} finally {
|
|
72
|
+
prompter.close?.();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
function createOpenClawConfigWriter(api: OpenClawPluginApi): SetupConfigWriter {
|
|
67
78
|
return {
|
|
68
79
|
async write(nextConfig) {
|
|
@@ -83,7 +94,7 @@ function replaceConfig(draft: OpenClawConfig, nextConfig: OpenClawConfig): void
|
|
|
83
94
|
Object.assign(draft, structuredClone(nextConfig));
|
|
84
95
|
}
|
|
85
96
|
|
|
86
|
-
function createReadlinePrompter(): ClosableSetupPrompter {
|
|
97
|
+
export function createReadlinePrompter(): ClosableSetupPrompter {
|
|
87
98
|
const readline = createInterface({ input, output });
|
|
88
99
|
|
|
89
100
|
return {
|
package/src/types.ts
CHANGED
|
@@ -47,6 +47,7 @@ export interface InstanceAnnouncePayload {
|
|
|
47
47
|
instanceName?: string;
|
|
48
48
|
multiaddrs: string[];
|
|
49
49
|
pubkey?: string;
|
|
50
|
+
userPublicAttributes?: UserPublicAttribute[];
|
|
50
51
|
announcedAt: number;
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -86,12 +87,32 @@ export interface DeliveryAckPayload {
|
|
|
86
87
|
error?: string;
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
export type UserPublicAttribute =
|
|
91
|
+
| {
|
|
92
|
+
kind: "tag";
|
|
93
|
+
value: string;
|
|
94
|
+
label: string;
|
|
95
|
+
source: "USER.md";
|
|
96
|
+
}
|
|
97
|
+
| {
|
|
98
|
+
kind: "structured";
|
|
99
|
+
key: "group" | "project" | "role" | "skill" | "custom" | string;
|
|
100
|
+
value: string;
|
|
101
|
+
label: string;
|
|
102
|
+
source: "profile";
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type UserAttributeMatch =
|
|
106
|
+
| { kind: "tag"; value: string }
|
|
107
|
+
| { kind: "structured"; key: string; value: string };
|
|
108
|
+
|
|
89
109
|
export interface InstancePeerRecord {
|
|
90
110
|
instanceId: string;
|
|
91
111
|
peerId: string;
|
|
92
112
|
instanceName?: string;
|
|
93
113
|
multiaddrs: string[];
|
|
94
114
|
pubkey?: string;
|
|
115
|
+
userPublicAttributes?: UserPublicAttribute[];
|
|
95
116
|
lastSeenAt: number;
|
|
96
117
|
lastAnnouncedAt: number;
|
|
97
118
|
source: "announce";
|
|
@@ -114,6 +135,29 @@ export interface InstancePeerStore {
|
|
|
114
135
|
}>;
|
|
115
136
|
}
|
|
116
137
|
|
|
138
|
+
export type UserAttributeMessageTarget = {
|
|
139
|
+
instanceId: string;
|
|
140
|
+
instanceName?: string;
|
|
141
|
+
peerId: string;
|
|
142
|
+
matchedAttribute: UserPublicAttribute;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export type UserAttributeMessageDeliveryResult = UserAttributeMessageTarget & {
|
|
146
|
+
sent: boolean;
|
|
147
|
+
delivered: boolean;
|
|
148
|
+
error?: string;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export type UserAttributeMessageResult = {
|
|
152
|
+
matched: number;
|
|
153
|
+
sent: number;
|
|
154
|
+
delivered: number;
|
|
155
|
+
failed: number;
|
|
156
|
+
targets?: UserAttributeMessageTarget[];
|
|
157
|
+
results?: UserAttributeMessageDeliveryResult[];
|
|
158
|
+
error?: string;
|
|
159
|
+
};
|
|
160
|
+
|
|
117
161
|
export interface InboundDeliveryRequest {
|
|
118
162
|
channel: string;
|
|
119
163
|
target: string;
|
|
@@ -139,6 +183,25 @@ export interface InboundDeliveryAdapter {
|
|
|
139
183
|
deliver(request: InboundDeliveryRequest): Promise<InboundDeliveryResult>;
|
|
140
184
|
}
|
|
141
185
|
|
|
186
|
+
export type InstanceRouterOptions = {
|
|
187
|
+
mesh: MeshNetwork;
|
|
188
|
+
store: InstancePeerStore;
|
|
189
|
+
delivery: InboundDeliveryAdapter;
|
|
190
|
+
config?: MeshConfig;
|
|
191
|
+
logger?: {
|
|
192
|
+
info?: (message: string) => void;
|
|
193
|
+
debug?: (message: string) => void;
|
|
194
|
+
warn?: (message: string) => void;
|
|
195
|
+
error?: (message: string) => void;
|
|
196
|
+
};
|
|
197
|
+
userAttributeSource?: {
|
|
198
|
+
loadTags(): Promise<UserPublicAttribute[]>;
|
|
199
|
+
};
|
|
200
|
+
userProfileStore?: {
|
|
201
|
+
listAttributes(): Promise<UserPublicAttribute[]>;
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
142
205
|
export interface InstanceRouter {
|
|
143
206
|
start(): Promise<void>;
|
|
144
207
|
stop(): Promise<void>;
|
|
@@ -157,6 +220,11 @@ export interface InstanceRouter {
|
|
|
157
220
|
deliveryResults?: DeliveryTargetResult[];
|
|
158
221
|
error?: string;
|
|
159
222
|
}>;
|
|
223
|
+
sendUserAttributeMessage(
|
|
224
|
+
match: UserAttributeMatch,
|
|
225
|
+
message: string,
|
|
226
|
+
options?: { dryRun?: boolean },
|
|
227
|
+
): Promise<UserAttributeMessageResult>;
|
|
160
228
|
}
|
|
161
229
|
|
|
162
230
|
export interface MeshConfig {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { UserAttributeMatch, UserPublicAttribute } from "./types.js";
|
|
2
|
+
|
|
3
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
4
|
+
return typeof value === "object" && value !== null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function trimmedString(value: unknown): string | undefined {
|
|
8
|
+
if (typeof value !== "string") {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const trimmed = value.trim();
|
|
13
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeAttributeValue(value: string): string {
|
|
17
|
+
return value.trim().toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeAttributeKey(key: string): string {
|
|
21
|
+
return key.trim().toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeUserPublicAttribute(value: unknown): UserPublicAttribute | undefined {
|
|
25
|
+
if (!isRecord(value)) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (value.kind === "tag") {
|
|
30
|
+
if (value.source !== "USER.md") {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const attributeValue = trimmedString(value.value);
|
|
35
|
+
const label = trimmedString(value.label);
|
|
36
|
+
if (!attributeValue || !label) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
kind: "tag",
|
|
42
|
+
value: attributeValue,
|
|
43
|
+
label,
|
|
44
|
+
source: "USER.md",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (value.kind === "structured") {
|
|
49
|
+
if (value.source !== "profile") {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const key = trimmedString(value.key);
|
|
54
|
+
const attributeValue = trimmedString(value.value);
|
|
55
|
+
const label = trimmedString(value.label);
|
|
56
|
+
if (!key || !attributeValue || !label) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
kind: "structured",
|
|
62
|
+
key: normalizeAttributeKey(key),
|
|
63
|
+
value: attributeValue,
|
|
64
|
+
label,
|
|
65
|
+
source: "profile",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function attributeDedupeKey(attribute: UserPublicAttribute): string {
|
|
73
|
+
if (attribute.kind === "tag") {
|
|
74
|
+
return `tag:${normalizeAttributeValue(attribute.value)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return `structured:${normalizeAttributeKey(attribute.key)}:${normalizeAttributeValue(attribute.value)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function mergeUserPublicAttributes(
|
|
81
|
+
userMdTags: UserPublicAttribute[],
|
|
82
|
+
profileAttributes: UserPublicAttribute[],
|
|
83
|
+
): UserPublicAttribute[] {
|
|
84
|
+
const merged: UserPublicAttribute[] = [];
|
|
85
|
+
const seen = new Set<string>();
|
|
86
|
+
|
|
87
|
+
for (const rawAttribute of [...userMdTags, ...profileAttributes]) {
|
|
88
|
+
const attribute = normalizeUserPublicAttribute(rawAttribute);
|
|
89
|
+
if (!attribute) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const key = attributeDedupeKey(attribute);
|
|
94
|
+
if (seen.has(key)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
seen.add(key);
|
|
99
|
+
merged.push(attribute);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return merged;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function matchesUserAttribute(attribute: UserPublicAttribute, match: UserAttributeMatch): boolean {
|
|
106
|
+
if (attribute.kind !== match.kind) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (attribute.kind === "tag") {
|
|
111
|
+
return normalizeAttributeValue(attribute.value) === normalizeAttributeValue(match.value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (match.kind !== "structured") {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
normalizeAttributeKey(attribute.key) === normalizeAttributeKey(match.key) &&
|
|
120
|
+
normalizeAttributeValue(attribute.value) === normalizeAttributeValue(match.value)
|
|
121
|
+
);
|
|
122
|
+
}
|