antenna-fyi 1.3.40 → 1.3.41

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/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 ──────────────────────────────────────────────────────
@@ -335,7 +347,7 @@ export async function setProfile({
335
347
  p_visible: visible,
336
348
  p_matching_context: contextJson || null,
337
349
  p_contact_info: contact_info || null,
338
- ...(api_key ? { p_api_key: api_key } : {}),
350
+ p_api_key: api_key || null,
339
351
  });
340
352
  if (error) throw new Error(error.message);
341
353
 
@@ -366,9 +378,9 @@ export async function setProfile({
366
378
  const profile = await getProfile({ device_id, supabaseUrl, supabaseKey });
367
379
  profileSlug = profile?.profile_slug || null;
368
380
  // Use explicitly passed slug, or auto-generate from display_name
369
- const targetSlug = profile_slug || (!profileSlug && display_name ? display_name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").substring(0, 30) : null);
381
+ const targetSlug = profile_slug || (!profileSlug ? profileSlugCandidate(display_name, device_id) : null);
370
382
  if (targetSlug && targetSlug !== profileSlug) {
371
- const { data: slugResult } = await sb.rpc("set_profile_slug", { p_device_id: device_id, p_slug: targetSlug });
383
+ const { data: slugResult } = await sb.rpc("set_profile_slug", { p_device_id: device_id, p_slug: targetSlug, p_api_key: api_key || null });
372
384
  if (slugResult?.set) profileSlug = targetSlug;
373
385
  }
374
386
  if (profileSlug) {
@@ -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,15 @@ _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
+
36
46
  def _get_url():
37
47
  return os.environ.get("ANTENNA_SUPABASE_URL") or os.environ.get("ANTENNA_URL") or BUILTIN_URL
38
48
 
@@ -192,10 +202,30 @@ def handle_profile(params: dict) -> str:
192
202
  }
193
203
  if params.get("matching_context") is not None:
194
204
  rpc_params["p_matching_context"] = params["matching_context"]
205
+ rpc_params["p_api_key"] = params.get("api_key")
195
206
  resp = sb.rpc("upsert_profile", rpc_params).execute()
196
207
 
197
208
  if resp.data:
198
- return _ok({"updated": True, "profile": resp.data, "next_step": "IMPORTANT: Now call antenna_bind to generate a GPS link for the user. Do not skip this."})
209
+ public_url = None
210
+ try:
211
+ profile_resp = sb.rpc("get_profile", {"p_device_id": did}).execute()
212
+ profile = profile_resp.data or {}
213
+ profile_slug = profile.get("profile_slug")
214
+ if not profile_slug:
215
+ target_slug = _profile_slug_candidate(params.get("display_name"), did)
216
+ slug_resp = sb.rpc("set_profile_slug", {"p_device_id": did, "p_slug": target_slug, "p_api_key": params.get("api_key")}).execute()
217
+ if isinstance(slug_resp.data, dict) and slug_resp.data.get("set"):
218
+ profile_slug = target_slug
219
+ if profile_slug:
220
+ public_url = f"https://www.antenna.fyi/p/{profile_slug}"
221
+ except Exception:
222
+ public_url = None
223
+ return _ok({
224
+ "updated": True,
225
+ "profile": resp.data,
226
+ "public_url": public_url,
227
+ "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.",
228
+ })
199
229
  return _ok({"error": "upsert_profile failed"})
200
230
 
201
231
 
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. The profile has a display name and three descriptions: personal description, looking for, and conversation style.",
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"),
@@ -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,16 @@ 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
+
69
80
  function isRateLimited(deviceId: string): boolean {
70
81
  const now = Date.now();
71
82
  const last = _lastScanTime.get(deviceId);
@@ -425,14 +436,29 @@ export default function register(api: any) {
425
436
  p_display_name: params.display_name ?? null, p_emoji: null,
426
437
  p_line1: params.line1 ?? null, p_line2: params.line2 ?? null,
427
438
  p_line3: params.line3 ?? null, p_visible: params.visible ?? true,
439
+ p_api_key: null,
428
440
  });
429
441
 
430
442
  if (error) return ok({ error: error.message });
431
443
 
444
+ let publicUrl = null;
445
+ try {
446
+ const { data: profile } = await supabase.rpc("get_profile", { p_device_id: deviceId });
447
+ let profileSlug = profile?.profile_slug || null;
448
+ if (!profileSlug) {
449
+ const targetSlug = profileSlugCandidate(params.display_name, deviceId);
450
+ const { data: slugResult } = await supabase.rpc("set_profile_slug", { p_device_id: deviceId, p_slug: targetSlug, p_api_key: null });
451
+ if (slugResult?.set) profileSlug = targetSlug;
452
+ }
453
+ if (profileSlug) publicUrl = `https://www.antenna.fyi/p/${profileSlug}`;
454
+ } catch {}
455
+
432
456
  return ok({
433
457
  updated: true,
434
458
  profile: { display_name: data.display_name,
435
459
  line1: data.line1, line2: data.line2, line3: data.line3, visible: data.visible },
460
+ public_url: publicUrl,
461
+ 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
462
  });
437
463
  },
438
464
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antenna-fyi",
3
- "version": "1.3.40",
3
+ "version": "1.3.41",
4
4
  "description": "Antenna — nearby people discovery. CLI + MCP server + OpenClaw skill & plugin, all in one package.",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -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` 生成链接并发给用户:
@@ -170,6 +172,8 @@ openclaw cron add --every 1h --message "Check antenna matches: call antenna_chec
170
172
  - **conversation_style**:想要的交流方式
171
173
  - **matching_context**(more_information,不展示给别人):agent 基于对用户的了解生成的详细描述,~200 字。**这是匹配的核心数据源。** personal_description/looking_for/conversation_style 从它提炼出来,不是反过来。
172
174
 
175
+ **i18n:** 用户填写的内容按原文保存和展示,不要自动翻译用户的 profile 文本。Antenna 的网页 UI 会切换中文/英文标签; profile 内容本身保持用户选择的语言。保存后返回的 `public_url` 必须发给用户。
176
+
173
177
  ### `antenna_accept`
174
178
  接受一个匹配。**不需要先 scan**--任何发现路径都可以触发 accept。
175
179
  - `sender_id`, `channel`, `chat_id`:必填