antenna-fyi 1.3.40 → 1.3.42
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 +4 -1
- package/lib/cli.js +8 -6
- package/lib/core.js +39 -12
- package/lib/hermes-plugin/schemas.py +1 -0
- package/lib/hermes-plugin/tools.py +49 -1
- package/lib/mcp.js +5 -4
- package/lib/plugin-template/index.ts +46 -1
- package/package.json +1 -1
- package/skill/SKILL.md +12 -7
package/README.md
CHANGED
|
@@ -26,7 +26,10 @@ antenna checkin --id <platform>:<user_id> --lat 39.99 --lng 116.48
|
|
|
26
26
|
|
|
27
27
|
# View/edit your profile
|
|
28
28
|
antenna profile --id <platform>:<user_id>
|
|
29
|
-
antenna
|
|
29
|
+
antenna config --key <ant_xxx>
|
|
30
|
+
antenna profile --id <platform>:<user_id> --name Yi --line1 'Product Designer'
|
|
31
|
+
|
|
32
|
+
Profile writes require the user's Antenna API key from antenna.fyi/me. The CLI verifies the key and writes to the dashboard-linked `user:<uuid>` profile; agents should not create profiles from `channel:sender_id`.
|
|
30
33
|
|
|
31
34
|
# Accept a match
|
|
32
35
|
antenna accept --id <platform>:<user_id> --target <ref_or_id> --contact 'WeChat: yi'
|
package/lib/cli.js
CHANGED
|
@@ -68,10 +68,14 @@ export async function handleScan(f) {
|
|
|
68
68
|
|
|
69
69
|
export async function handleProfile(f) {
|
|
70
70
|
const id = resolveId(f);
|
|
71
|
-
if (!id) return console.error("Usage: antenna profile --id <platform>:<user_id> [--name Yi --personal-description '...' --looking-for '...' --conversation-style '...' --more-information '...' --tags 'AI,design,music' --city 'Beijing' --contact 'WeChat: yi' --slug mira --hide --visible true]");
|
|
71
|
+
if (!id) return console.error("Usage: antenna profile --id <platform>:<user_id> [--name Yi --personal-description '...' --looking-for '...' --conversation-style '...' --more-information '...' --tags 'AI,design,music' --city 'Beijing' --contact 'WeChat: yi' --slug mira --hide --visible true]\n Writes require: antenna config --key <your-api-key>");
|
|
72
72
|
if (f.name || f["personal-description"] !== undefined || f.line1 !== undefined || f["looking-for"] !== undefined || f.line2 !== undefined || f["conversation-style"] !== undefined || f.line3 !== undefined || f["more-information"] !== undefined || f.contact !== undefined || f.slug !== undefined || f.tags !== undefined || f.city !== undefined || f.visible !== undefined || f.hide !== undefined) {
|
|
73
|
+
const config = loadConfig();
|
|
74
|
+
if (!config.key) {
|
|
75
|
+
return console.error("❌ Profile writes require a dashboard API key. Run: antenna config --key <your-api-key>");
|
|
76
|
+
}
|
|
73
77
|
const visible = f.hide ? false : (f.visible !== undefined ? f.visible === 'true' || f.visible === true : undefined);
|
|
74
|
-
const payload = { device_id: id };
|
|
78
|
+
const payload = { device_id: config.device_id || id, api_key: config.key };
|
|
75
79
|
if (f.name) payload.display_name = f.name;
|
|
76
80
|
if (f["personal-description"] !== undefined) payload.line1 = f["personal-description"];
|
|
77
81
|
else if (f.line1 !== undefined) payload.line1 = f.line1;
|
|
@@ -85,8 +89,6 @@ export async function handleProfile(f) {
|
|
|
85
89
|
if (f.tags !== undefined) payload.interest_tags = typeof f.tags === 'string' ? f.tags.split(',').map(t => t.trim()).filter(Boolean) : [f.tags];
|
|
86
90
|
if (f.city !== undefined) payload.city = f.city;
|
|
87
91
|
if (visible !== undefined) payload.visible = visible;
|
|
88
|
-
const config = loadConfig();
|
|
89
|
-
if (config.key) payload.api_key = config.key;
|
|
90
92
|
const data = await setProfile(payload);
|
|
91
93
|
if (data?.error) {
|
|
92
94
|
console.error("❌ " + data.error);
|
|
@@ -445,7 +447,7 @@ export async function handleConfig(f) {
|
|
|
445
447
|
}
|
|
446
448
|
const config = loadConfig();
|
|
447
449
|
config.key = f.key;
|
|
448
|
-
config.device_id = result.device_id;
|
|
450
|
+
config.device_id = result.user_id ? `user:${result.user_id}` : result.device_id;
|
|
449
451
|
config.user_id = result.user_id;
|
|
450
452
|
config.display_name = result.display_name;
|
|
451
453
|
saveConfig(config);
|
|
@@ -1107,7 +1109,7 @@ Agent shortcuts:
|
|
|
1107
1109
|
Usage:
|
|
1108
1110
|
antenna scan --lat 39.99 --lng 116.48 [--radius 500] (max 1000) [--id <platform>:<user_id>]
|
|
1109
1111
|
antenna checkin --id <platform>:<user_id> --lat 39.99 --lng 116.48
|
|
1110
|
-
antenna profile --id <platform>:<user_id> [--name Yi --personal-description '...' --looking-for '...' --conversation-style '...' --more-information '...' --tags 'AI,design,music' --city 'Beijing' --contact 'WeChat: yi' --slug mira --hide --visible true]
|
|
1112
|
+
antenna profile --id <platform>:<user_id> [--name Yi --personal-description '...' --looking-for '...' --conversation-style '...' --more-information '...' --tags 'AI,design,music' --city 'Beijing' --contact 'WeChat: yi' --slug mira --hide --visible true] (writes require antenna config --key)
|
|
1111
1113
|
antenna accept --id <platform>:<user_id> --target <ref_or_device_id> [--contact 'WeChat: yi']
|
|
1112
1114
|
antenna pass --id <platform>:<user_id> --target <ref_or_device_id> (or --ref 1)
|
|
1113
1115
|
antenna matches --id <platform>:<user_id>
|
package/lib/core.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// All three import this instead of duplicating Supabase calls.
|
|
3
3
|
|
|
4
4
|
import { createClient } from "@supabase/supabase-js";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
|
|
6
7
|
const DEFAULT_URL = "https://bcudjloikmpcqwcptuyd.supabase.co";
|
|
7
8
|
const DEFAULT_KEY =
|
|
@@ -55,6 +56,17 @@ function mapSearchProfile(p, ref, query) {
|
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
function profileSlugCandidate(displayName, deviceId) {
|
|
60
|
+
const fromName = String(displayName || "")
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
63
|
+
.replace(/^-|-$/g, "")
|
|
64
|
+
.substring(0, 30);
|
|
65
|
+
if (fromName) return fromName;
|
|
66
|
+
const hash = createHash("sha1").update(String(deviceId || "antenna-user")).digest("hex").slice(0, 10);
|
|
67
|
+
return `user-${hash}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
async function generateMatchReason(myLines, theirLines) {
|
|
59
71
|
try {
|
|
60
72
|
const res = await fetch(`${_url || DEFAULT_URL}/functions/v1/generate-match-reason`, {
|
|
@@ -227,15 +239,15 @@ export async function scan({ lat, lng, radius_m = 500, device_id, supabaseUrl, s
|
|
|
227
239
|
// ─── Profile field metadata (self-describing API) ───────────────────
|
|
228
240
|
|
|
229
241
|
export const PROFILE_FIELDS = {
|
|
230
|
-
display_name: { label: "显示名称", description: "How you want to be called" },
|
|
231
|
-
personal_description: { label: "个人描述", description: "Who you are and what you do", maxLength: 220, required: true },
|
|
232
|
-
looking_for: { label: "想认识的人", description: "The kind of people you want to meet", maxLength: 140 },
|
|
233
|
-
conversation_style: { label: "想要的交流方式", description: "The type of conversations you want", maxLength: 160 },
|
|
234
|
-
more_information: { label: "更多信息", description: "Agent-generated rich context for better matching (not shown to others)", maxLength: 1000 },
|
|
235
|
-
interest_tags: { label: "兴趣标签", description: "Interest/topic tags shown on the card (up to 5)", maxItems: 5 },
|
|
236
|
-
city: { label: "国家/地区", description: "Country or region" },
|
|
237
|
-
links: { label: "社交链接", description: "Social links shown on the card footer (up to 3)", maxItems: 3 },
|
|
238
|
-
is_active: { label: "状态", description: "Whether the profile is active or quiet" },
|
|
242
|
+
display_name: { label: "显示名称", i18n: { en: "Display name", zh: "显示名称" }, description: "How you want to be called" },
|
|
243
|
+
personal_description: { label: "个人描述", i18n: { en: "Personal description", zh: "个人描述" }, description: "Who you are and what you do", maxLength: 220, required: true },
|
|
244
|
+
looking_for: { label: "想认识的人", i18n: { en: "Looking for", zh: "想认识的人" }, description: "The kind of people you want to meet", maxLength: 140 },
|
|
245
|
+
conversation_style: { label: "想要的交流方式", i18n: { en: "Conversation style", zh: "想要的交流方式" }, description: "The type of conversations you want", maxLength: 160 },
|
|
246
|
+
more_information: { label: "更多信息", i18n: { en: "More information", zh: "更多信息" }, description: "Agent-generated rich context for better matching (not shown to others)", maxLength: 1000 },
|
|
247
|
+
interest_tags: { label: "兴趣标签", i18n: { en: "Interest tags", zh: "兴趣标签" }, description: "Interest/topic tags shown on the card (up to 5)", maxItems: 5 },
|
|
248
|
+
city: { label: "国家/地区", i18n: { en: "Country/region", zh: "国家/地区" }, description: "Country or region" },
|
|
249
|
+
links: { label: "社交链接", i18n: { en: "Social links", zh: "社交链接" }, description: "Social links shown on the card footer (up to 3)", maxItems: 3 },
|
|
250
|
+
is_active: { label: "状态", i18n: { en: "Status", zh: "状态" }, description: "Whether the profile is active or quiet" },
|
|
239
251
|
};
|
|
240
252
|
|
|
241
253
|
// ─── getProfile ──────────────────────────────────────────────────────
|
|
@@ -289,6 +301,18 @@ export async function setProfile({
|
|
|
289
301
|
supabaseKey,
|
|
290
302
|
}) {
|
|
291
303
|
const sb = getClient(supabaseUrl, supabaseKey);
|
|
304
|
+
if (!api_key) {
|
|
305
|
+
throw new Error("Profile writes require the user's Antenna API key from antenna.fyi/me. Do not create an agent-only profile from sender_id/channel.");
|
|
306
|
+
}
|
|
307
|
+
const auth = await verifyApiKey({ key: api_key, supabaseUrl, supabaseKey });
|
|
308
|
+
if (!auth?.valid) {
|
|
309
|
+
throw new Error(auth?.error || "Invalid Antenna API key");
|
|
310
|
+
}
|
|
311
|
+
const dashboardDeviceId = auth.user_id ? `user:${auth.user_id}` : auth.device_id;
|
|
312
|
+
if (!dashboardDeviceId) {
|
|
313
|
+
throw new Error("API key verified but did not return a dashboard device_id");
|
|
314
|
+
}
|
|
315
|
+
device_id = dashboardDeviceId;
|
|
292
316
|
|
|
293
317
|
// Pack structured fields into matching_context JSON
|
|
294
318
|
let contextJson = matching_context;
|
|
@@ -335,7 +359,7 @@ export async function setProfile({
|
|
|
335
359
|
p_visible: visible,
|
|
336
360
|
p_matching_context: contextJson || null,
|
|
337
361
|
p_contact_info: contact_info || null,
|
|
338
|
-
|
|
362
|
+
p_api_key: api_key || null,
|
|
339
363
|
});
|
|
340
364
|
if (error) throw new Error(error.message);
|
|
341
365
|
|
|
@@ -366,9 +390,9 @@ export async function setProfile({
|
|
|
366
390
|
const profile = await getProfile({ device_id, supabaseUrl, supabaseKey });
|
|
367
391
|
profileSlug = profile?.profile_slug || null;
|
|
368
392
|
// Use explicitly passed slug, or auto-generate from display_name
|
|
369
|
-
const targetSlug = profile_slug || (!profileSlug
|
|
393
|
+
const targetSlug = profile_slug || (!profileSlug ? profileSlugCandidate(display_name, device_id) : null);
|
|
370
394
|
if (targetSlug && targetSlug !== profileSlug) {
|
|
371
|
-
const { data: slugResult } = await sb.rpc("set_profile_slug", { p_device_id: device_id, p_slug: targetSlug });
|
|
395
|
+
const { data: slugResult } = await sb.rpc("set_profile_slug", { p_device_id: device_id, p_slug: targetSlug, p_api_key: api_key || null });
|
|
372
396
|
if (slugResult?.set) profileSlug = targetSlug;
|
|
373
397
|
}
|
|
374
398
|
if (profileSlug) {
|
|
@@ -399,6 +423,7 @@ export async function setProfile({
|
|
|
399
423
|
p_device_id: device_id,
|
|
400
424
|
p_matching_context: JSON.stringify(ctx),
|
|
401
425
|
p_visible: profile?.visible ?? true,
|
|
426
|
+
p_api_key: api_key,
|
|
402
427
|
});
|
|
403
428
|
} catch {}
|
|
404
429
|
}
|
|
@@ -420,6 +445,8 @@ export async function setProfile({
|
|
|
420
445
|
public_url: publicUrl,
|
|
421
446
|
gps_bind_url: bindUrl,
|
|
422
447
|
archetype: archetypeResult || null,
|
|
448
|
+
api_key_verified: true,
|
|
449
|
+
dashboard_device_id: device_id,
|
|
423
450
|
next_step: "Send the public_url and gps_bind_url to the user. The GPS link should be opened on their phone to share location.",
|
|
424
451
|
};
|
|
425
452
|
}
|
|
@@ -56,6 +56,7 @@ PROFILE_SCHEMA = {
|
|
|
56
56
|
"line3": {"type": "string", "description": "What you're looking for"},
|
|
57
57
|
"visible": {"type": "boolean", "description": "Visible to others"},
|
|
58
58
|
"matching_context": {"type": "string", "description": "Free-form context for AI matching (interests, goals, etc.)"},
|
|
59
|
+
"api_key": {"type": "string", "description": "Required for action='set': user's Antenna API key from antenna.fyi/me. Profile writes use the dashboard-linked user:<uuid> profile."},
|
|
59
60
|
},
|
|
60
61
|
"required": ["action", "sender_id", "channel", "chat_id"],
|
|
61
62
|
},
|
|
@@ -5,6 +5,7 @@ shared backend if no env vars are set.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
|
+
import hashlib
|
|
8
9
|
import math
|
|
9
10
|
import os
|
|
10
11
|
import time
|
|
@@ -33,6 +34,28 @@ _last_ref_map: dict[str, str] = {} # ref → device_id from last scan
|
|
|
33
34
|
_my_device_ids: set[str] = set() # track this user's device_ids for match checking
|
|
34
35
|
|
|
35
36
|
|
|
37
|
+
def _profile_slug_candidate(display_name: str | None, device_id: str) -> str:
|
|
38
|
+
raw = "".join(ch.lower() if ch.isalnum() and ch.isascii() else "-" for ch in (display_name or ""))
|
|
39
|
+
slug = "-".join(part for part in raw.split("-") if part)[:30].strip("-")
|
|
40
|
+
if slug:
|
|
41
|
+
return slug
|
|
42
|
+
digest = hashlib.sha1(device_id.encode("utf-8")).hexdigest()[:10]
|
|
43
|
+
return f"user-{digest}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _dashboard_device_id(sb, api_key: str | None) -> tuple[str | None, str | None]:
|
|
47
|
+
if not api_key:
|
|
48
|
+
return None, "Profile writes require the user's Antenna API key from antenna.fyi/me. Do not create an agent-only profile from sender_id/channel."
|
|
49
|
+
resp = sb.rpc("verify_api_key", {"p_key": api_key}).execute()
|
|
50
|
+
data = resp.data or {}
|
|
51
|
+
if not data.get("valid"):
|
|
52
|
+
return None, data.get("error") or "Invalid Antenna API key"
|
|
53
|
+
device_id = f"user:{data.get('user_id')}" if data.get("user_id") else data.get("device_id")
|
|
54
|
+
if not device_id:
|
|
55
|
+
return None, "API key verified but did not return a dashboard device_id"
|
|
56
|
+
return device_id, None
|
|
57
|
+
|
|
58
|
+
|
|
36
59
|
def _get_url():
|
|
37
60
|
return os.environ.get("ANTENNA_SUPABASE_URL") or os.environ.get("ANTENNA_URL") or BUILTIN_URL
|
|
38
61
|
|
|
@@ -181,6 +204,9 @@ def handle_profile(params: dict) -> str:
|
|
|
181
204
|
return _ok({"exists": True, "profile": resp.data})
|
|
182
205
|
|
|
183
206
|
# set
|
|
207
|
+
did, auth_error = _dashboard_device_id(sb, params.get("api_key"))
|
|
208
|
+
if auth_error:
|
|
209
|
+
return _ok({"error": auth_error})
|
|
184
210
|
rpc_params = {
|
|
185
211
|
"p_device_id": did,
|
|
186
212
|
"p_display_name": params.get("display_name"),
|
|
@@ -192,10 +218,32 @@ def handle_profile(params: dict) -> str:
|
|
|
192
218
|
}
|
|
193
219
|
if params.get("matching_context") is not None:
|
|
194
220
|
rpc_params["p_matching_context"] = params["matching_context"]
|
|
221
|
+
rpc_params["p_api_key"] = params.get("api_key")
|
|
195
222
|
resp = sb.rpc("upsert_profile", rpc_params).execute()
|
|
196
223
|
|
|
197
224
|
if resp.data:
|
|
198
|
-
|
|
225
|
+
public_url = None
|
|
226
|
+
try:
|
|
227
|
+
profile_resp = sb.rpc("get_profile", {"p_device_id": did}).execute()
|
|
228
|
+
profile = profile_resp.data or {}
|
|
229
|
+
profile_slug = profile.get("profile_slug")
|
|
230
|
+
if not profile_slug:
|
|
231
|
+
target_slug = _profile_slug_candidate(params.get("display_name"), did)
|
|
232
|
+
slug_resp = sb.rpc("set_profile_slug", {"p_device_id": did, "p_slug": target_slug, "p_api_key": params.get("api_key")}).execute()
|
|
233
|
+
if isinstance(slug_resp.data, dict) and slug_resp.data.get("set"):
|
|
234
|
+
profile_slug = target_slug
|
|
235
|
+
if profile_slug:
|
|
236
|
+
public_url = f"https://www.antenna.fyi/p/{profile_slug}"
|
|
237
|
+
except Exception:
|
|
238
|
+
public_url = None
|
|
239
|
+
return _ok({
|
|
240
|
+
"updated": True,
|
|
241
|
+
"profile": resp.data,
|
|
242
|
+
"public_url": public_url,
|
|
243
|
+
"api_key_verified": True,
|
|
244
|
+
"dashboard_device_id": did,
|
|
245
|
+
"next_step": "IMPORTANT: 1) Send public_url to the user — this is their shareable profile link. 2) Call antenna_bind to generate a GPS link. Do not skip either step.",
|
|
246
|
+
})
|
|
199
247
|
return _ok({"error": "upsert_profile failed"})
|
|
200
248
|
|
|
201
249
|
|
package/lib/mcp.js
CHANGED
|
@@ -113,7 +113,7 @@ export async function startMcpServer() {
|
|
|
113
113
|
|
|
114
114
|
server.tool(
|
|
115
115
|
"antenna_profile",
|
|
116
|
-
"Get or set the user's Antenna profile card.
|
|
116
|
+
"Get or set the user's Antenna profile card. After setting a profile, send profile.public_url to the user as their public profile link, then send the GPS bind link.",
|
|
117
117
|
{
|
|
118
118
|
action: z.enum(["get", "set"]).describe("'get' to read, 'set' to write"),
|
|
119
119
|
sender_id: z.string().describe("The sender's user ID"),
|
|
@@ -128,8 +128,9 @@ export async function startMcpServer() {
|
|
|
128
128
|
links: z.array(z.string()).optional().describe("Social links shown on the card footer (up to 3)"),
|
|
129
129
|
is_active: z.boolean().optional().describe("Whether the profile is active or quiet"),
|
|
130
130
|
visible: z.boolean().optional().default(true),
|
|
131
|
+
api_key: z.string().optional().describe("Required for action='set': the user's Antenna API key from antenna.fyi/me. Profile writes use this key and the dashboard-linked user:<uuid> device_id."),
|
|
131
132
|
},
|
|
132
|
-
async ({ action, sender_id, channel, display_name, personal_description, looking_for, conversation_style, more_information, interest_tags, city, links, is_active, visible }) => {
|
|
133
|
+
async ({ action, sender_id, channel, display_name, personal_description, looking_for, conversation_style, more_information, interest_tags, city, links, is_active, visible, api_key }) => {
|
|
133
134
|
const deviceId = deriveDeviceId(sender_id, channel);
|
|
134
135
|
try {
|
|
135
136
|
if (action === "get") {
|
|
@@ -139,8 +140,8 @@ export async function startMcpServer() {
|
|
|
139
140
|
: { profile: null, message: "还没有名片。跟用户聊聊他们是谁、做什么、想认识什么人,然后帮他们创建。", fields: PROFILE_FIELDS };
|
|
140
141
|
return jsonResult(await withMatchNotifications(deviceId, result));
|
|
141
142
|
}
|
|
142
|
-
const data = await setProfile({ device_id: deviceId, display_name, line1: personal_description, line2: looking_for, line3: conversation_style, matching_context: more_information, interest_tags, city, links, is_active, visible });
|
|
143
|
-
return jsonResult(await withMatchNotifications(deviceId, { saved: true, profile: data }));
|
|
143
|
+
const data = await setProfile({ device_id: deviceId, display_name, line1: personal_description, line2: looking_for, line3: conversation_style, matching_context: more_information, interest_tags, city, links, is_active, visible, api_key });
|
|
144
|
+
return jsonResult(await withMatchNotifications(data.dashboard_device_id || deviceId, { saved: true, profile: data }));
|
|
144
145
|
} catch (e) {
|
|
145
146
|
return jsonResult({ error: e.message });
|
|
146
147
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
|
+
import { createHash } from "crypto";
|
|
3
4
|
|
|
4
5
|
// ─── Built-in Supabase config (shared backend, zero config) ─────────
|
|
5
6
|
|
|
@@ -66,6 +67,28 @@ function getSupabase(cfg: AntennaConfig): SupabaseClient {
|
|
|
66
67
|
return _supabaseClient;
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
function profileSlugCandidate(displayName: string | null | undefined, deviceId: string) {
|
|
71
|
+
const fromName = String(displayName || "")
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
74
|
+
.replace(/^-|-$/g, "")
|
|
75
|
+
.substring(0, 30);
|
|
76
|
+
if (fromName) return fromName;
|
|
77
|
+
return `user-${createHash("sha1").update(deviceId).digest("hex").slice(0, 10)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function resolveDashboardDeviceId(supabase: SupabaseClient, apiKey: string | null | undefined) {
|
|
81
|
+
if (!apiKey) {
|
|
82
|
+
return { error: "Profile writes require the user's Antenna API key from antenna.fyi/me. Do not create an agent-only profile from sender_id/channel." };
|
|
83
|
+
}
|
|
84
|
+
const { data, error } = await supabase.rpc("verify_api_key", { p_key: apiKey });
|
|
85
|
+
if (error) return { error: error.message };
|
|
86
|
+
if (!data?.valid) return { error: data?.error || "Invalid Antenna API key" };
|
|
87
|
+
const deviceId = data.user_id ? `user:${data.user_id}` : data.device_id;
|
|
88
|
+
if (!deviceId) return { error: "API key verified but did not return a dashboard device_id" };
|
|
89
|
+
return { deviceId, userId: data.user_id, displayName: data.display_name };
|
|
90
|
+
}
|
|
91
|
+
|
|
69
92
|
function isRateLimited(deviceId: string): boolean {
|
|
70
93
|
const now = Date.now();
|
|
71
94
|
const last = _lastScanTime.get(deviceId);
|
|
@@ -400,13 +423,14 @@ export default function register(api: any) {
|
|
|
400
423
|
line2: { type: "string", description: "Second line (what you're into)" },
|
|
401
424
|
line3: { type: "string", description: "Third line (what you're looking for)" },
|
|
402
425
|
visible: { type: "boolean", description: "Whether to be visible to others" },
|
|
426
|
+
api_key: { type: "string", description: "Required for action='set': user's Antenna API key from antenna.fyi/me. Profile writes use the dashboard-linked user:<uuid> profile." },
|
|
403
427
|
},
|
|
404
428
|
required: ["action", "sender_id", "channel"],
|
|
405
429
|
},
|
|
406
430
|
async execute(_id: string, params: any) {
|
|
407
431
|
const cfg = getConfig(api);
|
|
408
432
|
const supabase = getSupabase(cfg);
|
|
409
|
-
|
|
433
|
+
let deviceId = deriveDeviceId(params.sender_id, params.channel);
|
|
410
434
|
|
|
411
435
|
if (params.action === "get") {
|
|
412
436
|
const { data, error } = await supabase.rpc("get_profile", { p_device_id: deviceId });
|
|
@@ -420,19 +444,40 @@ export default function register(api: any) {
|
|
|
420
444
|
});
|
|
421
445
|
}
|
|
422
446
|
|
|
447
|
+
const resolved = await resolveDashboardDeviceId(supabase, params.api_key);
|
|
448
|
+
if (resolved.error) return ok({ error: resolved.error });
|
|
449
|
+
deviceId = resolved.deviceId!;
|
|
450
|
+
|
|
423
451
|
const { data, error } = await supabase.rpc("upsert_profile", {
|
|
424
452
|
p_device_id: deviceId,
|
|
425
453
|
p_display_name: params.display_name ?? null, p_emoji: null,
|
|
426
454
|
p_line1: params.line1 ?? null, p_line2: params.line2 ?? null,
|
|
427
455
|
p_line3: params.line3 ?? null, p_visible: params.visible ?? true,
|
|
456
|
+
p_api_key: params.api_key,
|
|
428
457
|
});
|
|
429
458
|
|
|
430
459
|
if (error) return ok({ error: error.message });
|
|
431
460
|
|
|
461
|
+
let publicUrl = null;
|
|
462
|
+
try {
|
|
463
|
+
const { data: profile } = await supabase.rpc("get_profile", { p_device_id: deviceId });
|
|
464
|
+
let profileSlug = profile?.profile_slug || null;
|
|
465
|
+
if (!profileSlug) {
|
|
466
|
+
const targetSlug = profileSlugCandidate(params.display_name, deviceId);
|
|
467
|
+
const { data: slugResult } = await supabase.rpc("set_profile_slug", { p_device_id: deviceId, p_slug: targetSlug, p_api_key: params.api_key });
|
|
468
|
+
if (slugResult?.set) profileSlug = targetSlug;
|
|
469
|
+
}
|
|
470
|
+
if (profileSlug) publicUrl = `https://www.antenna.fyi/p/${profileSlug}`;
|
|
471
|
+
} catch {}
|
|
472
|
+
|
|
432
473
|
return ok({
|
|
433
474
|
updated: true,
|
|
434
475
|
profile: { display_name: data.display_name,
|
|
435
476
|
line1: data.line1, line2: data.line2, line3: data.line3, visible: data.visible },
|
|
477
|
+
public_url: publicUrl,
|
|
478
|
+
api_key_verified: true,
|
|
479
|
+
dashboard_device_id: deviceId,
|
|
480
|
+
next_step: "IMPORTANT: Send the public_url to the user — this is their shareable profile link. Then call antenna_bind to generate a GPS link. Do not skip either step.",
|
|
436
481
|
});
|
|
437
482
|
},
|
|
438
483
|
});
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -38,9 +38,9 @@ Plugin 安装后,agent **主动**开始引导,不要等用户问。
|
|
|
38
38
|
**第一步:拿到 API key → 配置**
|
|
39
39
|
> "嘿,你装了 Antenna--它能帮你发现有意思的人。先确认一下,你在 antenna.fyi 注册过了吗?拿到 API key 了吗?"
|
|
40
40
|
|
|
41
|
-
用户给了 API key 后,调 `antenna config --key <key>` 验证。这会返回 `user_id` 和 `device_id`。
|
|
41
|
+
用户给了 API key 后,调 `antenna config --key <key>` 验证。这会返回 `user_id` 和 dashboard 绑定的 `device_id`。
|
|
42
42
|
|
|
43
|
-
**⚠️
|
|
43
|
+
**⚠️ Profile 写入必须通过用户的 Antenna API key。** 调 `antenna_profile(action="set")` 时传 `api_key`,tool 会验证 API key 并使用返回的 dashboard `device_id`(格式 `user:xxx`) 写入。不要自己拼 `channel:sender_id`,不要在用户拿到 API key 前凭空创建 profile。这样 agent 填的内容才会显示在 dashboard。
|
|
44
44
|
|
|
45
45
|
**第二步:聊天收集 → 生成名片 → 确认**
|
|
46
46
|
|
|
@@ -58,9 +58,9 @@ Plugin 安装后,agent **主动**开始引导,不要等用户问。
|
|
|
58
58
|
>
|
|
59
59
|
> 这样可以吗?要改哪里告诉我。
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
确认后调 `antenna_profile(action="set", api_key="ant_xxx")` 保存。**不要跳过确认。**
|
|
62
62
|
|
|
63
|
-
**⚠️
|
|
63
|
+
**⚠️ 保存 profile 时以 API key 为准;sender_id/channel 只用于上下文,不作为 profile 归属来源。**
|
|
64
64
|
|
|
65
65
|
**第三步:立刻推荐 2-3 个人**
|
|
66
66
|
|
|
@@ -71,10 +71,12 @@ Plugin 安装后,agent **主动**开始引导,不要等用户问。
|
|
|
71
71
|
|
|
72
72
|
**第四步:发公开链接给用户**
|
|
73
73
|
|
|
74
|
-
Profile 存好后,返回里会有 `public_url`(如 `antenna.fyi/p/yi`)
|
|
74
|
+
Profile 存好后,返回里会有 `public_url`(如 `antenna.fyi/p/yi`)。**必须立刻发给用户。不要只说"保存好了"。**
|
|
75
75
|
> "这是你的公开名片链接:[public_url]
|
|
76
76
|
> 你可以把它发给别人,对方的 agent 看到链接就能直接 accept 你。"
|
|
77
77
|
|
|
78
|
+
如果 `public_url` 为空,说明 profile link 生成失败。立刻重试保存 profile 或提示错误,不要进入下一步。
|
|
79
|
+
|
|
78
80
|
**第五步:调 antenna_bind 获取 GPS 链接**
|
|
79
81
|
|
|
80
82
|
**强制。** 名片存好后**必须立刻**调用 `antenna_bind` 生成链接并发给用户:
|
|
@@ -94,12 +96,12 @@ openclaw cron add --every 1h --message "Check antenna matches: call antenna_chec
|
|
|
94
96
|
|
|
95
97
|
### Linking to antenna.fyi account
|
|
96
98
|
|
|
97
|
-
|
|
99
|
+
Legacy only: 如果用户之前通过旧版 agent 创建过 profile(没有网站账号),现在注册了 antenna.fyi:
|
|
98
100
|
1. 让用户从 antenna.fyi/me 复制 API key
|
|
99
101
|
2. 调 `antenna_link_account(api_key = "ant_xxx")`
|
|
100
102
|
3. 确认:"关联成功!你现在可以在 dashboard 上看到完整的 profile 和匹配记录了。"
|
|
101
103
|
|
|
102
|
-
|
|
104
|
+
这只用于迁移旧数据。新流程禁止先创建 agent-only profile 再绑定;必须先拿 API key,再通过 API 写入 dashboard profile。
|
|
103
105
|
|
|
104
106
|
## When to use
|
|
105
107
|
|
|
@@ -162,6 +164,7 @@ openclaw cron add --every 1h --message "Check antenna matches: call antenna_chec
|
|
|
162
164
|
- `action`:"get" 或 "set"
|
|
163
165
|
- `sender_id`, `channel`, `chat_id`
|
|
164
166
|
- "set" 时传:`display_name`, `personal_description`, `looking_for`, `conversation_style`, `visible`, `matching_context`
|
|
167
|
+
- "set" 必须传 `api_key`。tool 会验证 key,并写入 API key 绑定的 dashboard profile;不要用 `sender_id/channel` 创建独立 profile。
|
|
165
168
|
|
|
166
169
|
名片内容:
|
|
167
170
|
- **display_name**:显示名称
|
|
@@ -170,6 +173,8 @@ openclaw cron add --every 1h --message "Check antenna matches: call antenna_chec
|
|
|
170
173
|
- **conversation_style**:想要的交流方式
|
|
171
174
|
- **matching_context**(more_information,不展示给别人):agent 基于对用户的了解生成的详细描述,~200 字。**这是匹配的核心数据源。** personal_description/looking_for/conversation_style 从它提炼出来,不是反过来。
|
|
172
175
|
|
|
176
|
+
**i18n:** 用户填写的内容按原文保存和展示,不要自动翻译用户的 profile 文本。Antenna 的网页 UI 会切换中文/英文标签; profile 内容本身保持用户选择的语言。保存后返回的 `public_url` 必须发给用户。
|
|
177
|
+
|
|
173
178
|
### `antenna_accept`
|
|
174
179
|
接受一个匹配。**不需要先 scan**--任何发现路径都可以触发 accept。
|
|
175
180
|
- `sender_id`, `channel`, `chat_id`:必填
|