antenna-openclaw-plugin 1.3.28 → 1.3.29

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/index.ts CHANGED
@@ -25,17 +25,17 @@ interface Profile {
25
25
  line1: string | null;
26
26
  line2: string | null;
27
27
  line3: string | null;
28
- emoji: string | null;
29
- profile_slug: string | null;
30
- matching_context: string | null;
31
28
  visible: boolean;
32
29
  last_seen_at?: string;
30
+ profile_slug?: string;
31
+ distance_m?: number;
32
+ dist_meters?: number;
33
+ matching_context?: string;
33
34
  }
34
35
 
35
36
  interface MatchResult {
36
37
  device_id: string;
37
38
  display_name: string | null;
38
- emoji: string | null;
39
39
  line1: string | null;
40
40
  line2: string | null;
41
41
  line3: string | null;
@@ -58,7 +58,7 @@ function getConfig(api: any): AntennaConfig {
58
58
  supabaseUrl: cfg.supabaseUrl || BUILTIN_SUPABASE_URL,
59
59
  supabaseKey: cfg.supabaseKey || BUILTIN_SUPABASE_ANON_KEY,
60
60
  defaultRadiusM: cfg.defaultRadiusM ?? 500,
61
- matchExpiryHours: cfg.matchExpiryHours ?? 168,
61
+ matchExpiryHours: cfg.matchExpiryHours ?? 24,
62
62
  maxMatches: cfg.maxMatches ?? 5,
63
63
  autoScanOnLocation: cfg.autoScanOnLocation ?? true,
64
64
  };
@@ -102,10 +102,10 @@ function extractWords(profile: Partial<Profile>): string[] {
102
102
  return text.split(/[\s,,。.!!??、;;::]+/).filter((w) => w.length > 1);
103
103
  }
104
104
 
105
- function deriveDeviceId(senderId: string, channel: string, chatId?: string): string {
105
+ function deriveDeviceId(senderId: string, channel: string, chatId?: string, api?: any): string {
106
106
  const id = `${channel}:${senderId}`;
107
107
  _knownDeviceIds.add(id);
108
- if (chatId) {
108
+ if (chatId && api) {
109
109
  _channelContext.set(id, chatId);
110
110
  // Persist to DB async
111
111
  try {
@@ -140,12 +140,13 @@ async function notifyUser(
140
140
  userId: string,
141
141
  message: string,
142
142
  logger: any,
143
+ api?: any,
143
144
  ): Promise<void> {
144
145
  const deviceId = `${channel}:${userId}`;
145
146
  let chatId = _channelContext.get(deviceId);
146
147
 
147
148
  // Fallback: read from DB if not in memory
148
- if (!chatId) {
149
+ if (!chatId && api) {
149
150
  try {
150
151
  const cfg = getConfig(api);
151
152
  const sb = getSupabase(cfg);
@@ -275,7 +276,7 @@ export default function register(api: any) {
275
276
  async execute(_id: string, params: any) {
276
277
  const cfg = getConfig(api);
277
278
  const supabase = getSupabase(cfg);
278
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
279
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
279
280
  const radius = params.radius_m ?? cfg.defaultRadiusM ?? 500;
280
281
 
281
282
  if (isRateLimited(deviceId)) {
@@ -307,29 +308,10 @@ export default function register(api: any) {
307
308
  const others = (nearby ?? []).filter((p: Profile) => p.device_id !== deviceId);
308
309
 
309
310
  if (others.length === 0) {
310
- // Fallback to global discover
311
- const { data: globalData } = await supabase.rpc("global_discover", {
312
- p_device_id: deviceId, p_limit: 1,
311
+ return ok({
312
+ profiles: [], count: 0, radius_m: radius,
313
+ message: `附近 ${radius}m 暂时没人。可以试试全球推荐(antenna_discover)或者把你的名片链接发给想认识的人。`,
313
314
  });
314
- const globalOthers = globalData || [];
315
- if (globalOthers.length > 0) {
316
- const gRefMap: Record<string, string> = {};
317
- const gProfiles = globalOthers.map((p: any, i: number) => {
318
- const ref = String(i + 1);
319
- gRefMap[ref] = p.device_id;
320
- return { ref, emoji: p.emoji || "👤", name: p.display_name || "匿名", line1: p.line1, line2: p.line2, line3: p.line3, more_information: p.matching_context || null, profile_slug: p.profile_slug || null };
321
- });
322
- (api as any)._antennaRefMap = gRefMap;
323
- try { await supabase.rpc("save_scan_refs", { p_owner: deviceId, p_refs: gRefMap }); } catch {}
324
- for (const p of globalOthers) {
325
- try { await supabase.rpc("log_recommendation", { p_device_id: deviceId, p_recommended_id: p.device_id }); } catch {}
326
- }
327
- return ok({
328
- profiles: gProfiles, count: gProfiles.length, radius_m: radius, global: true,
329
- message: `附近 ${radius}m 暂时没人。今天的全球推荐——这个人跟你可能聊得来。(每天 1 次)`,
330
- });
331
- }
332
- return ok({ profiles: [], message: `附近暂时没人,今天的全球推荐已经用完了。明天再来!` });
333
315
  }
334
316
 
335
317
  // Build ref mapping — never expose device_id
@@ -339,12 +321,10 @@ export default function register(api: any) {
339
321
  _refMap[ref] = p.device_id;
340
322
  return {
341
323
  ref,
342
- emoji: p.emoji || "👤",
343
324
  name: p.display_name || "匿名",
344
- line1: p.line1,
345
- line2: p.line2,
346
- line3: p.line3,
347
- more_information: p.matching_context || null,
325
+ personal_description: p.line1,
326
+ looking_for: p.line2,
327
+ conversation_style: p.line3,
348
328
  profile_slug: p.profile_slug || null,
349
329
  distance_m: p.distance_m ?? p.dist_meters ?? null,
350
330
  };
@@ -371,7 +351,7 @@ export default function register(api: any) {
371
351
  api.registerTool({
372
352
  name: "antenna_profile",
373
353
  description:
374
- "View or update the user's Antenna profile (name card). The profile has a display name, emoji, and three lines describing who they are.",
354
+ "View or update the user's Antenna profile (name card). The profile has a display name and three descriptions: personal description, looking for, and conversation style.",
375
355
  parameters: {
376
356
  type: "object",
377
357
  properties: {
@@ -380,45 +360,98 @@ export default function register(api: any) {
380
360
  channel: { type: "string", description: "The channel name" },
381
361
  chat_id: { type: "string", description: "REQUIRED for notifications. Pass the chat/channel ID from your message context so Antenna can send you match and event notifications." },
382
362
  display_name: { type: "string", description: "Display name" },
383
- emoji: { type: "string", description: "Profile emoji" },
384
- line1: { type: "string", description: "First line (who you are / what you do)" },
385
- line2: { type: "string", description: "Second line (what you're into)" },
386
- line3: { type: "string", description: "Third line (what you're looking for)" },
363
+ personal_description: { type: "string", description: "Personal description — who you are and what you do (max 220 chars)" },
364
+ looking_for: { type: "string", description: "Looking for the kind of people you want to meet (max 140 chars)" },
365
+ conversation_style: { type: "string", description: "Conversation style the type of conversations you want (max 160 chars)" },
387
366
  visible: { type: "boolean", description: "Whether to be visible to others" },
388
- matching_context: { type: "string", description: "More information / free-form context for AI matching (interests, goals, background, etc.)" },
367
+ more_information: { type: "string", description: "More information agent-generated rich context for better matching (not shown to others, max 1000 chars). Generate this FIRST, then derive personal_description, looking_for, and conversation_style from it." },
368
+ interest_tags: { type: "array", items: { type: "string" }, description: "Interest/topic tags shown on the card (up to 8)" },
369
+ city: { type: "string", description: "Country or region (e.g. 'United States', 'Beijing')" },
370
+ links: { type: "array", items: { type: "string" }, description: "Social links shown on the card footer (up to 3)" },
371
+ is_active: { type: "boolean", description: "Whether the profile is active or quiet" },
372
+ contact_info: { type: "string", description: "Contact info (WeChat, Telegram, email, etc.) — PRIVATE, only revealed to mutual matches. Ask the user what contact they want to share." },
389
373
  },
390
374
  required: ["action", "sender_id", "channel", "chat_id"],
391
375
  },
392
376
  async execute(_id: string, params: any) {
393
377
  const cfg = getConfig(api);
394
378
  const supabase = getSupabase(cfg);
395
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
379
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
396
380
 
397
381
  if (params.action === "get") {
398
382
  const { data, error } = await supabase.rpc("get_profile", { p_device_id: deviceId });
399
383
  if (error || !data) {
400
- return ok({ exists: false, message: "你还没有名片。告诉我你的名字、一个 emoji、和三句话介绍自己,我帮你创建。" });
384
+ return ok({
385
+ exists: false,
386
+ message: "你还没有名片。跟我聊聊你是谁、做什么、想认识什么人,我帮你创建。",
387
+ fields: {
388
+ display_name: { label: "显示名称", description: "How you want to be called" },
389
+ personal_description: { label: "个人描述", description: "Who you are and what you do", maxLength: 220, required: true },
390
+ looking_for: { label: "想认识的人", description: "The kind of people you want to meet", maxLength: 140 },
391
+ conversation_style: { label: "想要的交流方式", description: "The type of conversations you want", maxLength: 160 },
392
+ more_information: { label: "更多信息", description: "Agent-generated rich context for better matching (not shown to others)", maxLength: 1000 },
393
+ interest_tags: { label: "兴趣标签", description: "Interest/topic tags shown on the card (up to 8)", maxItems: 8 },
394
+ city: { label: "国家/地区", description: "Country or region" },
395
+ links: { label: "社交链接", description: "Social links shown on the card footer (up to 3)", maxItems: 3 },
396
+ is_active: { label: "状态", description: "Whether the profile is active or quiet" },
397
+ },
398
+ });
401
399
  }
402
400
  return ok({
403
401
  exists: true,
404
- profile: { display_name: data.display_name, emoji: data.emoji,
405
- line1: data.line1, line2: data.line2, line3: data.line3, visible: data.visible },
402
+ profile: { display_name: data.display_name,
403
+ personal_description: data.line1, looking_for: data.line2, conversation_style: data.line3, visible: data.visible, contact_info: data.contact_info || null },
404
+ fields: {
405
+ display_name: { label: "显示名称", description: "How you want to be called" },
406
+ personal_description: { label: "个人描述", description: "Who you are and what you do", maxLength: 220, required: true },
407
+ looking_for: { label: "想认识的人", description: "The kind of people you want to meet", maxLength: 140 },
408
+ conversation_style: { label: "想要的交流方式", description: "The type of conversations you want", maxLength: 160 },
409
+ more_information: { label: "更多信息", description: "Agent-generated rich context for better matching (not shown to others)", maxLength: 1000 },
410
+ interest_tags: { label: "兴趣标签", description: "Interest/topic tags shown on the card (up to 8)", maxItems: 8 },
411
+ city: { label: "国家/地区", description: "Country or region" },
412
+ links: { label: "社交链接", description: "Social links shown on the card footer (up to 3)", maxItems: 3 },
413
+ is_active: { label: "状态", description: "Whether the profile is active or quiet" },
414
+ },
406
415
  });
407
416
  }
408
417
 
418
+ // Pack structured fields into matching_context JSON
419
+ let contextJson = params.more_information ?? undefined;
420
+ if (params.interest_tags || params.city || params.links || params.is_active !== undefined) {
421
+ let existing: Record<string, any> = {};
422
+ if (contextJson) {
423
+ try { existing = JSON.parse(contextJson); } catch { existing = { context: contextJson }; }
424
+ } else {
425
+ // Read existing matching_context from DB to merge
426
+ try {
427
+ const { data: cur } = await supabase.rpc("get_profile", { p_device_id: deviceId });
428
+ if (cur?.matching_context) {
429
+ try { existing = JSON.parse(cur.matching_context); } catch {}
430
+ }
431
+ } catch {}
432
+ }
433
+ if (params.interest_tags) existing.interestTags = params.interest_tags;
434
+ if (params.city) existing.city = params.city;
435
+ if (params.links) existing.links = params.links;
436
+ if (params.is_active !== undefined) existing.isActive = params.is_active;
437
+ existing.version = existing.version || 1;
438
+ contextJson = JSON.stringify(existing);
439
+ }
440
+
409
441
  const { data, error } = await supabase.rpc("upsert_profile", {
410
442
  p_device_id: deviceId,
411
- p_display_name: params.display_name ?? null, p_emoji: params.emoji ?? null,
412
- p_line1: params.line1 ?? null, p_line2: params.line2 ?? null,
413
- p_line3: params.line3 ?? null, p_visible: params.visible ?? true,
414
- ...(params.matching_context != null ? { p_matching_context: params.matching_context } : {}),
443
+ p_display_name: params.display_name ?? null, p_emoji: null,
444
+ p_line1: params.personal_description ?? null, p_line2: params.looking_for ?? null,
445
+ p_line3: params.conversation_style ?? null, p_visible: params.visible ?? true,
446
+ ...(contextJson != null ? { p_matching_context: contextJson } : {}),
447
+ ...(params.contact_info != null ? { p_contact_info: params.contact_info } : {}),
415
448
  });
416
449
 
417
450
  if (error) return ok({ error: error.message });
418
451
 
419
452
  // Read back profile to get slug for public page link
420
- let publicUrl = null;
421
- let archetypeResult = null;
453
+ let publicUrl: string | null = null;
454
+ let gpsBindUrl: string | null = null;
422
455
  try {
423
456
  const { data: profile } = await supabase.rpc("get_profile", { p_device_id: deviceId });
424
457
  if (profile?.profile_slug) {
@@ -426,50 +459,25 @@ export default function register(api: any) {
426
459
  }
427
460
  } catch {}
428
461
 
429
- // Generate personalized archetype description via LLM (best-effort)
462
+ // Auto-generate GPS bind link
430
463
  try {
431
- const profileText = [data.line1, data.line2, data.line3, params.matching_context].filter(Boolean).join(". ");
432
- if (profileText) {
433
- const corpus = profileText.toLowerCase();
434
- const archetypeKw: Record<string, string[]> = {
435
- Prometheus: ["ai", "agent", "llm", "founder", "startup", "build", "developer", "tools"],
436
- Athena: ["product", "strategy", "research", "design", "craft", "pm", "ux"],
437
- Hermes: ["network", "connect", "community", "social", "bridge"],
438
- Apollo: ["music", "media", "content", "creator", "writing", "taste"],
439
- Artemis: ["independent", "explore", "freelance", "health", "outdoor"],
440
- Aphrodite: ["beauty", "brand", "fashion", "relationship"],
441
- Dionysus: ["event", "culture", "party", "art", "festival"],
442
- Hades: ["finance", "invest", "infrastructure", "backend", "security"],
443
- Persephone: ["transform", "cross", "research", "academic", "bridge"],
444
- Odysseus: ["founder", "journey", "resilience", "travel", "startup"],
445
- };
446
- let bestRole = "Prometheus"; let bestScore = 0;
447
- for (const [role, kws] of Object.entries(archetypeKw)) {
448
- const score = kws.reduce((s, kw) => s + (corpus.includes(kw) ? 1 : 0), 0);
449
- if (score > bestScore) { bestScore = score; bestRole = role; }
450
- }
451
- const cfg2 = getConfig(api);
452
- const supabaseUrl = cfg2.supabaseUrl || "https://bcudjloikmpcqwcptuyd.supabase.co";
453
- const supabaseKey = cfg2.supabaseKey || BUILTIN_SUPABASE_ANON_KEY;
454
- const res = await fetch(`${supabaseUrl}/functions/v1/generate-archetype`, {
455
- method: "POST",
456
- headers: { "Content-Type": "application/json", "Authorization": `Bearer ${supabaseKey}` },
457
- body: JSON.stringify({ archetype: bestRole, profile_text: profileText }),
458
- });
459
- if (res.ok) {
460
- const archData = await res.json();
461
- if (archData?.reason) archetypeResult = { archetype: bestRole, ...archData };
462
- }
464
+ const { data: bindData } = await supabase.rpc("create_bind_token", {
465
+ p_device_id: deviceId,
466
+ p_purpose: "profile",
467
+ p_event_code: null,
468
+ });
469
+ if (bindData?.token) {
470
+ gpsBindUrl = `https://www.antenna.fyi/locate?token=${bindData.token}`;
463
471
  }
464
472
  } catch {}
465
473
 
466
474
  return ok({
467
475
  updated: true,
468
476
  profile: { display_name: data.display_name,
469
- line1: data.line1, line2: data.line2, line3: data.line3, visible: data.visible },
477
+ personal_description: data.line1, looking_for: data.line2, conversation_style: data.line3, visible: data.visible },
470
478
  public_url: publicUrl,
471
- archetype: archetypeResult || null,
472
- next_step: "IMPORTANT: 1) Send the public_url to the user this is their shareable profile link. 2) Tell the user their archetype and the personalized reason. 3) Call antenna_bind to generate a GPS link. Do not skip any step.",
479
+ gps_bind_url: gpsBindUrl,
480
+ 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.",
473
481
  });
474
482
  },
475
483
  });
@@ -496,7 +504,7 @@ export default function register(api: any) {
496
504
  async execute(_id: string, params: any) {
497
505
  const cfg = getConfig(api);
498
506
  const supabase = getSupabase(cfg);
499
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
507
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
500
508
  const fuzzy = fuzzyCoords(params.lat, params.lng);
501
509
 
502
510
  // Check if user has a profile first
@@ -504,7 +512,7 @@ export default function register(api: any) {
504
512
  if (!profile) {
505
513
  return ok({
506
514
  checked_in: false,
507
- message: "你还没有名片,别人看到你也不知道你是谁。先创建一个名片吧(告诉我你的名字、emoji、三句话介绍自己)。",
515
+ message: "你还没有名片,别人看到你也不知道你是谁。先创建一个名片吧(跟我聊聊你是谁、做什么、想认识什么人)。",
508
516
  });
509
517
  }
510
518
 
@@ -528,7 +536,7 @@ export default function register(api: any) {
528
536
  api.registerTool({
529
537
  name: "antenna_accept",
530
538
  description:
531
- "Accept a match. Use 'ref' from scan results (e.g. '1', '2'), target_device_id, or profile_slug (from a public profile link like antenna.fyi/p/SLUG). Optionally share contact info.",
539
+ "Accept a match. Use 'ref' from scan results (e.g. '1', '2'), target_device_id, or profile_slug (from profile links like antenna.fyi/p/xxx). Optionally share contact info.",
532
540
  parameters: {
533
541
  type: "object",
534
542
  properties: {
@@ -536,8 +544,8 @@ export default function register(api: any) {
536
544
  channel: { type: "string" },
537
545
  chat_id: { type: "string", description: "REQUIRED for notifications. Pass the chat/channel ID from your message context so Antenna can send you match and event notifications." },
538
546
  ref: { type: "string", description: "Ref number from scan results (e.g. '1')" },
539
- target_device_id: { type: "string", description: "Device ID (use ref or profile_slug instead when possible)" },
540
- profile_slug: { type: "string", description: "Profile slug from a public profile link (e.g. 'yi' from antenna.fyi/p/yi). Resolves to device_id automatically." },
547
+ target_device_id: { type: "string", description: "Device ID (use ref instead when possible)" },
548
+ profile_slug: { type: "string", description: "Profile slug from profile link (e.g. 'yi' from antenna.fyi/p/yi)" },
541
549
  contact_info: { type: "string", description: "Optional contact info to share" },
542
550
  },
543
551
  required: ["sender_id", "channel", "chat_id"],
@@ -545,7 +553,7 @@ export default function register(api: any) {
545
553
  async execute(_id: string, params: any) {
546
554
  const cfg = getConfig(api);
547
555
  const supabase = getSupabase(cfg);
548
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
556
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
549
557
 
550
558
  // Resolve ref to device_id — try DB first, then memory fallback
551
559
  let targetId = params.target_device_id;
@@ -554,12 +562,13 @@ export default function register(api: any) {
554
562
  const { data: resolved } = await supabase.rpc("resolve_ref", { p_owner: deviceId, p_ref: params.ref });
555
563
  targetId = resolved || (api as any)._antennaRefMap?.[params.ref];
556
564
  }
557
- // Resolve profile_slug to device_id via get_profile_by_slug RPC
565
+ // Resolve profile_slug to device_id
558
566
  if (!targetId && params.profile_slug) {
559
- const { data: slugProfile } = await supabase.rpc("get_profile_by_slug", { p_slug: params.profile_slug });
560
- const resolved = Array.isArray(slugProfile) ? slugProfile[0] : slugProfile;
561
- if (resolved?.device_id) {
562
- targetId = resolved.device_id;
567
+ const { data: slugData } = await supabase.rpc("get_profile_by_slug", { p_slug: params.profile_slug });
568
+ if (slugData?.found) {
569
+ targetId = slugData.device_id;
570
+ } else {
571
+ return ok({ error: `Profile slug "${params.profile_slug}" not found.` });
563
572
  }
564
573
  }
565
574
  if (!targetId) {
@@ -629,7 +638,7 @@ export default function register(api: any) {
629
638
  async execute(_id: string, params: any) {
630
639
  const cfg = getConfig(api);
631
640
  const supabase = getSupabase(cfg);
632
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
641
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
633
642
 
634
643
  const { data, error } = await supabase.rpc("create_bind_token", {
635
644
  p_device_id: deviceId,
@@ -651,45 +660,6 @@ export default function register(api: any) {
651
660
  },
652
661
  });
653
662
 
654
- // ═══════════════════════════════════════════════════════════════════
655
- // Tool: antenna_link_account
656
- // ═══════════════════════════════════════════════════════════════════
657
- api.registerTool({
658
- name: "antenna_link_account",
659
- description:
660
- "Link your Antenna agent profile to your antenna.fyi website account. Pass the user's API key — the server verifies it and extracts the user_id. The agent never needs to know or pass user_id directly.",
661
- parameters: {
662
- type: "object",
663
- properties: {
664
- sender_id: { type: "string", description: "The sender's user ID" },
665
- channel: { type: "string", description: "The channel name" },
666
- chat_id: { type: "string", description: "REQUIRED. Pass the chat/channel ID from your message context." },
667
- api_key: { type: "string", description: "The user's Antenna API key (ant_xxx) from antenna.fyi/me" },
668
- },
669
- required: ["sender_id", "channel", "chat_id", "api_key"],
670
- },
671
- async execute(_id: string, params: any) {
672
- const cfg = getConfig(api);
673
- const supabase = getSupabase(cfg);
674
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
675
-
676
- const { data, error } = await supabase.rpc("bind_user_id", {
677
- p_device_id: deviceId,
678
- p_api_key: params.api_key,
679
- });
680
- if (error) return ok({ error: error.message });
681
-
682
- if (data?.error) {
683
- return ok(data);
684
- }
685
-
686
- return ok({
687
- ...data,
688
- message: "账号已关联!现在你可以在 antenna.fyi/me 看到你的完整 profile 和匹配记录了。",
689
- });
690
- },
691
- });
692
-
693
663
  // ═══════════════════════════════════════════════════════════════════
694
664
  // Tool: antenna_discover
695
665
  // ═══════════════════════════════════════════════════════════════════
@@ -709,7 +679,7 @@ export default function register(api: any) {
709
679
  async execute(_id: string, params: any) {
710
680
  const cfg = getConfig(api);
711
681
  const supabase = getSupabase(cfg);
712
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
682
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
713
683
 
714
684
  const { data: globalData } = await supabase.rpc("global_discover", {
715
685
  p_device_id: deviceId, p_limit: 1,
@@ -752,7 +722,7 @@ export default function register(api: any) {
752
722
  } catch { /* best effort */ }
753
723
  }
754
724
 
755
- profiles.push({ ref, emoji: p.emoji || "👤", name: p.display_name || "匿名", line1: p.line1, line2: p.line2, line3: p.line3, more_information: p.matching_context || null, profile_slug: p.profile_slug || null, match_reason });
725
+ profiles.push({ ref, name: p.display_name || "匿名", personal_description: p.line1, looking_for: p.line2, conversation_style: p.line3, profile_slug: p.profile_slug || null, match_reason });
756
726
  }
757
727
 
758
728
  // Persist refs + log recommendation
@@ -777,7 +747,7 @@ export default function register(api: any) {
777
747
  api.registerTool({
778
748
  name: "antenna_initial_recommendations",
779
749
  description:
780
- "Get initial recommendations for a new user — 2-3 people most similar to them. One-time only, does NOT consume daily discover quota. Use right after profile creation in onboarding.",
750
+ "First-time recommendations show 2-3 best matches right after profile creation. One-time only (second call returns empty). Does NOT consume daily discover quota. Use in onboarding step 3.",
781
751
  parameters: {
782
752
  type: "object",
783
753
  properties: {
@@ -790,27 +760,51 @@ export default function register(api: any) {
790
760
  async execute(_id: string, params: any) {
791
761
  const cfg = getConfig(api);
792
762
  const supabase = getSupabase(cfg);
793
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
763
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
794
764
 
795
- const { data: results, error } = await supabase.rpc("initial_recommendations", {
796
- p_device_id: deviceId,
797
- p_limit: 3,
765
+ // Check if already done via profile matching_context
766
+ const { data: myProfileCheck } = await supabase.rpc("get_profile", { p_device_id: deviceId });
767
+ if (myProfileCheck?.matching_context) {
768
+ try {
769
+ const ctx = JSON.parse(myProfileCheck.matching_context);
770
+ if (ctx.initialRecommendationsDone) {
771
+ return ok({ count: 0, profiles: [], message: "首次推荐已经用过了。用 antenna_discover 获取每日推荐。" });
772
+ }
773
+ } catch {}
774
+ }
775
+
776
+ const { data: globalData } = await supabase.rpc("global_discover", {
777
+ p_device_id: deviceId, p_limit: 3,
798
778
  });
799
779
 
800
- if (error) return ok({ error: error.message });
780
+ const results = globalData || [];
801
781
 
802
- if (!results || results.length === 0) {
803
- return ok({
804
- count: 0,
805
- profiles: [],
806
- initial: true,
807
- message: "暂时没有推荐,等有更多人加入!",
782
+ // Mark initial recommendations as done in matching_context
783
+ try {
784
+ let ctx: Record<string, any> = {};
785
+ if (myProfileCheck?.matching_context) {
786
+ try { ctx = JSON.parse(myProfileCheck.matching_context); } catch {}
787
+ }
788
+ ctx.initialRecommendationsDone = true;
789
+ await supabase.rpc("upsert_profile", {
790
+ p_device_id: deviceId,
791
+ p_display_name: myProfileCheck?.display_name ?? null,
792
+ p_emoji: null,
793
+ p_line1: myProfileCheck?.line1 ?? null,
794
+ p_line2: myProfileCheck?.line2 ?? null,
795
+ p_line3: myProfileCheck?.line3 ?? null,
796
+ p_visible: myProfileCheck?.visible ?? true,
797
+ p_matching_context: JSON.stringify(ctx),
808
798
  });
799
+ } catch {}
800
+
801
+ if (results.length === 0) {
802
+ return ok({ count: 0, profiles: [], message: "目前还没有足够的用户来匹配。你是早期用户!" });
809
803
  }
810
804
 
811
805
  const _refMap: Record<string, string> = {};
812
806
 
813
- // Get my profile for match reason generation
807
+ // Get my profile for match reason
814
808
  const { data: myProfile } = await supabase.rpc("get_profile", { p_device_id: deviceId });
815
809
  const myLines = myProfile ? [myProfile.line1, myProfile.line2, myProfile.line3].filter(Boolean).join(". ") : "";
816
810
 
@@ -839,21 +833,21 @@ export default function register(api: any) {
839
833
  } catch { /* best effort */ }
840
834
  }
841
835
 
842
- profiles.push({ ref, emoji: p.emoji || "👤", name: p.display_name || "匿名", personal_description: p.line1, looking_for: p.line2, conversation_style: p.line3, more_information: p.matching_context || null, profile_slug: p.profile_slug || null, match_reason });
836
+ profiles.push({
837
+ ref, name: p.display_name || "匿名",
838
+ personal_description: p.line1, looking_for: p.line2, conversation_style: p.line3, profile_slug: p.profile_slug || null, match_reason,
839
+ });
843
840
  }
844
841
 
845
- // Persist refs + log recommendations
846
842
  (api as any)._antennaRefMap = { ...(api as any)._antennaRefMap, ..._refMap };
847
- try {
848
- await supabase.rpc("save_scan_refs", { p_owner: deviceId, p_refs: _refMap });
849
- } catch { /* best effort */ }
843
+ try { await supabase.rpc("save_scan_refs", { p_owner: deviceId, p_refs: _refMap }); } catch {}
850
844
  for (const p of results) {
851
- await supabase.rpc("log_recommendation", { p_device_id: deviceId, p_recommended_id: (p as any).device_id });
845
+ await supabase.rpc("log_recommendation", { p_device_id: deviceId, p_recommended_id: p.device_id }).catch(() => {});
852
846
  }
853
847
 
854
848
  return ok({
855
849
  count: profiles.length, profiles, initial: true,
856
- message: "这是你的首次推荐——基于你的名片,这几个人跟你最匹配。",
850
+ message: "\uD83C\uDF1F 这是你的首次推荐——跟你最像的人:",
857
851
  });
858
852
  },
859
853
  });
@@ -885,11 +879,11 @@ export default function register(api: any) {
885
879
  async execute(_id: string, params: any) {
886
880
  const cfg = getConfig(api);
887
881
  const supabase = getSupabase(cfg);
888
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
882
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
889
883
  const { data, error } = await supabase.rpc("create_event", {
890
884
  p_name: params.name,
891
- p_lat: params.lat ?? null,
892
- p_lng: params.lng ?? null,
885
+ p_lat: params.lat || null,
886
+ p_lng: params.lng || null,
893
887
  p_created_by: deviceId,
894
888
  p_starts_at: params.starts_at || null,
895
889
  p_ends_at: params.ends_at || null,
@@ -922,7 +916,7 @@ export default function register(api: any) {
922
916
  async execute(_id: string, params: any) {
923
917
  const cfg = getConfig(api);
924
918
  const supabase = getSupabase(cfg);
925
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
919
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
926
920
  const { data, error } = await supabase.rpc("end_event", {
927
921
  p_code: params.code,
928
922
  p_device_id: deviceId,
@@ -954,7 +948,7 @@ export default function register(api: any) {
954
948
  async execute(_id: string, params: any) {
955
949
  const cfg = getConfig(api);
956
950
  const supabase = getSupabase(cfg);
957
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
951
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
958
952
 
959
953
  // Profile gate
960
954
  const { data: profile } = await supabase.rpc("get_profile", { p_device_id: deviceId });
@@ -973,7 +967,7 @@ export default function register(api: any) {
973
967
  } catch {}
974
968
  }
975
969
 
976
- const { data, error } = await supabase.rpc("join_event", { p_code: params.code, p_device_id: deviceId, p_lat: lat ?? null, p_lng: lng ?? null, p_application_context: params.application_context || null });
970
+ const { data, error } = await supabase.rpc("join_event", { p_code: params.code, p_device_id: deviceId, p_lat: lat || null, p_lng: lng || null, p_application_context: params.application_context || null });
977
971
  if (error) return ok({ error: error.message });
978
972
  if (!data?.joined) return ok(data);
979
973
 
@@ -1036,7 +1030,7 @@ export default function register(api: any) {
1036
1030
  async execute(_id: string, params: any) {
1037
1031
  const cfg = getConfig(api);
1038
1032
  const supabase = getSupabase(cfg);
1039
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1033
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1040
1034
 
1041
1035
  const { data, error } = await supabase.rpc("event_participants_list", { p_code: params.code, p_device_id: deviceId });
1042
1036
  if (error) return ok({ error: error.message });
@@ -1046,7 +1040,7 @@ export default function register(api: any) {
1046
1040
  const profiles = others.map((p, i) => {
1047
1041
  const ref = String(i + 1);
1048
1042
  _refMap[ref] = p.device_id;
1049
- return { ref, emoji: p.emoji || "👤", name: p.display_name || "匿名", line1: p.line1, line2: p.line2, line3: p.line3, more_information: p.matching_context || null, profile_slug: p.profile_slug || null, checked_in: !!p.checked_in, role: p.role || "participant", status: p.status || "active", application_context: p.application_context || null, source: "event" };
1043
+ return { ref, name: p.display_name || "匿名", personal_description: p.line1, looking_for: p.line2, conversation_style: p.line3, profile_slug: p.profile_slug || null, checked_in: !!p.checked_in, role: p.role || "participant", status: p.status || "active", application_context: p.application_context || null, source: "event" };
1050
1044
  });
1051
1045
 
1052
1046
  (api as any)._antennaRefMap = { ...(api as any)._antennaRefMap, ..._refMap };
@@ -1076,7 +1070,7 @@ export default function register(api: any) {
1076
1070
  async execute(_id: string, params: any) {
1077
1071
  const cfg = getConfig(api);
1078
1072
  const supabase = getSupabase(cfg);
1079
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1073
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1080
1074
 
1081
1075
  let targetId = params.target_device_id;
1082
1076
  if (!targetId && params.ref) {
@@ -1113,7 +1107,7 @@ export default function register(api: any) {
1113
1107
  async execute(_id: string, params: any) {
1114
1108
  const cfg = getConfig(api);
1115
1109
  const supabase = getSupabase(cfg);
1116
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1110
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1117
1111
  const fuzzy = (params.lat != null && params.lng != null) ? fuzzyCoords(params.lat, params.lng) : { lat: null, lng: null };
1118
1112
  const { data, error } = await supabase.rpc("event_checkin", {
1119
1113
  p_code: params.code,
@@ -1172,7 +1166,7 @@ export default function register(api: any) {
1172
1166
  async execute(_id: string, params: any) {
1173
1167
  const cfg = getConfig(api);
1174
1168
  const supabase = getSupabase(cfg);
1175
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1169
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1176
1170
 
1177
1171
  const { data: result } = await supabase.rpc("get_my_matches_with_profiles", { p_device_id: deviceId });
1178
1172
 
@@ -1187,18 +1181,18 @@ export default function register(api: any) {
1187
1181
  ref: String(i + 1),
1188
1182
  _device_id: m.target_id,
1189
1183
  name: m.name || "匿名",
1190
- emoji: m.emoji || "👤",
1191
- line1: m.line1, line2: m.line2, line3: m.line3,
1184
+ personal_description: m.line1, looking_for: m.line2, conversation_style: m.line3,
1185
+ profile_slug: m.profile_slug || null,
1192
1186
  their_contact: m.their_contact || null,
1193
1187
  you_shared: m.you_shared || null,
1194
1188
  }));
1195
1189
 
1196
1190
  const incomingAccepts = rawIncoming.map((m: any, i: number) => ({
1197
- ref: String(mutualMatches.length + i + 1),
1191
+ ref: String(i + 1),
1198
1192
  _device_id: m.target_id,
1199
1193
  name: m.name || "匿名",
1200
- emoji: m.emoji || "👤",
1201
- line1: m.line1, line2: m.line2, line3: m.line3,
1194
+ personal_description: m.line1, looking_for: m.line2, conversation_style: m.line3,
1195
+ profile_slug: m.profile_slug || null,
1202
1196
  }));
1203
1197
 
1204
1198
  // Clean up follow-up crons for mutual matches
@@ -1212,14 +1206,6 @@ export default function register(api: any) {
1212
1206
  if (incomingAccepts.length > 0) messages.push(`${incomingAccepts.length} 个人想认识你,等你回应`);
1213
1207
  if (messages.length === 0) messages.push("你接受了一些匹配,但对方还没有回应。耐心等等 ⏳");
1214
1208
 
1215
- // Persist ref map so accept(ref) resolves correctly
1216
- const _refMap: Record<string, string> = {};
1217
- for (const m of mutualMatches) _refMap[m.ref] = m._device_id;
1218
- for (const m of incomingAccepts) _refMap[m.ref] = m._device_id;
1219
- if (deviceId && Object.keys(_refMap).length > 0) {
1220
- try { await supabase.rpc("save_scan_refs", { p_owner: deviceId, p_refs: _refMap }); } catch { /* best effort */ }
1221
- }
1222
-
1223
1209
  return ok({
1224
1210
  mutual_matches: mutualMatches,
1225
1211
  incoming_accepts: incomingAccepts,
@@ -1248,22 +1234,18 @@ export default function register(api: any) {
1248
1234
  lng: { type: "number", description: "New event longitude" },
1249
1235
  starts_at: { type: "string", description: "New start time ISO" },
1250
1236
  ends_at: { type: "string", description: "New end time ISO" },
1251
- requires_approval: { type: "boolean", description: "Require host approval to join" },
1252
- screening_questions: { type: "array", items: { type: "string" }, description: "Screening questions for applicants" },
1253
1237
  },
1254
1238
  required: ["code", "sender_id", "channel", "chat_id"],
1255
1239
  },
1256
1240
  async execute(_id: string, params: any) {
1257
1241
  const cfg = getConfig(api);
1258
1242
  const supabase = getSupabase(cfg);
1259
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1243
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1260
1244
  const { data, error } = await supabase.rpc("update_event", {
1261
1245
  p_code: params.code, p_device_id: deviceId,
1262
1246
  p_name: params.name || null, p_description: params.description || null,
1263
- p_og_image: params.og_image || null, p_lat: params.lat ?? null, p_lng: params.lng ?? null,
1247
+ p_og_image: params.og_image || null, p_lat: params.lat || null, p_lng: params.lng || null,
1264
1248
  p_starts_at: params.starts_at || null, p_ends_at: params.ends_at || null,
1265
- ...(params.requires_approval != null ? { p_requires_approval: params.requires_approval } : {}),
1266
- ...(params.screening_questions != null ? { p_screening_questions: params.screening_questions } : {}),
1267
1249
  });
1268
1250
  if (error) return ok({ error: error.message });
1269
1251
  return ok(data);
@@ -1290,7 +1272,7 @@ export default function register(api: any) {
1290
1272
  async execute(_id: string, params: any) {
1291
1273
  const cfg = getConfig(api);
1292
1274
  const supabase = getSupabase(cfg);
1293
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1275
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1294
1276
  const { data, error } = await supabase.rpc("approve_participant", {
1295
1277
  p_code: params.code, p_device_id: deviceId, p_target_ref: params.ref,
1296
1278
  });
@@ -1319,7 +1301,7 @@ export default function register(api: any) {
1319
1301
  async execute(_id: string, params: any) {
1320
1302
  const cfg = getConfig(api);
1321
1303
  const supabase = getSupabase(cfg);
1322
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1304
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1323
1305
  const { data, error } = await supabase.rpc("reject_participant", {
1324
1306
  p_code: params.code, p_device_id: deviceId, p_target_ref: params.ref,
1325
1307
  });
@@ -1348,7 +1330,7 @@ export default function register(api: any) {
1348
1330
  async execute(_id: string, params: any) {
1349
1331
  const cfg = getConfig(api);
1350
1332
  const supabase = getSupabase(cfg);
1351
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1333
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1352
1334
  const { data, error } = await supabase.rpc("add_cohost", {
1353
1335
  p_code: params.code, p_device_id: deviceId, p_target_ref: params.ref,
1354
1336
  });
@@ -1362,34 +1344,72 @@ export default function register(api: any) {
1362
1344
  // ═══════════════════════════════════════════════════════════════════
1363
1345
  api.registerTool({
1364
1346
  name: "antenna_event_message",
1365
- description: "Send a message to event participants. Only creator or co-host can send. Omit ref to broadcast to all participants.",
1347
+ description:
1348
+ "Send a message to event participants. Only creator or co-host can send. Omit ref to broadcast to all.",
1366
1349
  parameters: {
1367
1350
  type: "object",
1368
1351
  properties: {
1369
1352
  code: { type: "string", description: "Event code" },
1370
- sender_id: { type: "string" },
1371
- channel: { type: "string" },
1353
+ sender_id: { type: "string", description: "The sender's user ID" },
1354
+ channel: { type: "string", description: "Channel name" },
1372
1355
  chat_id: { type: "string", description: "REQUIRED for notifications. Pass the chat/channel ID from your message context so Antenna can send you match and event notifications." },
1373
- message: { type: "string", description: "Message to send to participants" },
1374
- ref: { type: "string", description: "Ref number of specific participant (omit to broadcast to all)" },
1356
+ message: { type: "string", description: "Message to send" },
1357
+ ref: { type: "string", description: "Ref number of specific participant (omit for broadcast)" },
1375
1358
  },
1376
- required: ["code", "sender_id", "channel", "message", "chat_id"],
1359
+ required: ["code", "sender_id", "channel", "chat_id", "message"],
1377
1360
  },
1378
1361
  async execute(_id: string, params: any) {
1379
1362
  const cfg = getConfig(api);
1380
1363
  const supabase = getSupabase(cfg);
1381
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1382
- const { data, error } = await supabase.rpc("send_event_message", {
1364
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1365
+ const rpcParams: any = {
1383
1366
  p_code: params.code,
1384
1367
  p_device_id: deviceId,
1385
1368
  p_message: params.message,
1386
- ...(params.ref ? { p_target_ref: params.ref } : {}),
1387
- });
1369
+ };
1370
+ if (params.ref) rpcParams.p_target_ref = params.ref;
1371
+ const { data, error } = await supabase.rpc("send_event_message", rpcParams);
1388
1372
  if (error) return ok({ error: error.message });
1389
1373
  return ok(data);
1390
1374
  },
1391
1375
  });
1392
1376
 
1377
+ // ═══════════════════════════════════════════════════════════════════
1378
+ // Tool: antenna_link_account
1379
+ // ═══════════════════════════════════════════════════════════════════
1380
+ api.registerTool({
1381
+ name: "antenna_link_account",
1382
+ description:
1383
+ "Link your Antenna agent profile to your antenna.fyi website account. Pass the user's API key — the server verifies it and extracts the user_id. The agent never needs to know or pass user_id directly.",
1384
+ parameters: {
1385
+ type: "object",
1386
+ properties: {
1387
+ sender_id: { type: "string", description: "The sender's user ID" },
1388
+ channel: { type: "string", description: "Channel name" },
1389
+ chat_id: { type: "string", description: "REQUIRED for notifications. Pass the chat/channel ID from your message context so Antenna can send you match and event notifications." },
1390
+ api_key: { type: "string", description: "The user's Antenna API key (ant_xxx) from antenna.fyi/me" },
1391
+ },
1392
+ required: ["sender_id", "channel", "chat_id", "api_key"],
1393
+ },
1394
+ async execute(_id: string, params: any) {
1395
+ const cfg = getConfig(api);
1396
+ const supabase = getSupabase(cfg);
1397
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1398
+ const { data, error } = await supabase.rpc("bind_user_id", {
1399
+ p_device_id: deviceId,
1400
+ p_api_key: params.api_key,
1401
+ });
1402
+ if (error) return ok({ error: error.message });
1403
+ if (data?.error) return ok(data);
1404
+ return ok({
1405
+ ...data,
1406
+ message: "账号已关联!现在你可以在 antenna.fyi/me 看到你的完整 profile 和匹配记录了。",
1407
+ });
1408
+ },
1409
+ });
1410
+
1411
+ // ═══════════════════════════════════════════════════════════════════
1412
+ // Service: poll for new matches every 10 minutes → notify instantly
1393
1413
  // ═══════════════════════════════════════════════════════════════════
1394
1414
  const _notifiedMatches = new Set<string>(); // "deviceA→deviceB" already notified
1395
1415
 
@@ -1412,10 +1432,11 @@ export default function register(api: any) {
1412
1432
  async (payload: any) => {
1413
1433
  try {
1414
1434
  const targetDeviceId = payload.new?.device_id_b;
1415
- if (!targetDeviceId) return;
1435
+ if (!targetDeviceId || !_knownDeviceIds.has(targetDeviceId)) return;
1416
1436
 
1417
1437
  const key = `${payload.new.device_id_a}→${targetDeviceId}`;
1418
1438
  if (_notifiedMatches.has(key)) return;
1439
+ _notifiedMatches.add(key);
1419
1440
 
1420
1441
  const parts = targetDeviceId.split(":");
1421
1442
  if (parts.length < 2) return;
@@ -1427,7 +1448,6 @@ export default function register(api: any) {
1427
1448
 
1428
1449
  const { data: theirProfile } = await innerSb.rpc("get_profile", { p_device_id: payload.new.device_id_a });
1429
1450
  const name = theirProfile?.display_name || "有人";
1430
- const emoji = theirProfile?.emoji || "👤";
1431
1451
 
1432
1452
  // Check if mutual
1433
1453
  const { data: matches } = await innerSb.rpc("get_my_matches", { p_device_id: targetDeviceId });
@@ -1438,15 +1458,13 @@ export default function register(api: any) {
1438
1458
  if (myAccept) {
1439
1459
  const contact = payload.new.contact_info_a ? `\n对方的联系方式:${payload.new.contact_info_a}` : "";
1440
1460
  notifyUser(channel, userId,
1441
- `[Antenna] 🎉 双向匹配!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1442
- logger);
1443
- _notifiedMatches.add(key);
1461
+ `[Antenna] 🎉 双向匹配!${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1462
+ logger, api);
1444
1463
  stopFollowUpCron(targetDeviceId, payload.new.device_id_a, logger);
1445
1464
  } else {
1446
1465
  notifyUser(channel, userId,
1447
- `[Antenna] 📩 ${emoji} ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
1448
- logger);
1449
- _notifiedMatches.add(key);
1466
+ `[Antenna] 📩 ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
1467
+ logger, api);
1450
1468
  }
1451
1469
  } catch (err: any) {
1452
1470
  logger.warn("Antenna: realtime match handler error:", err.message);
@@ -1483,13 +1501,12 @@ export default function register(api: any) {
1483
1501
  // Get applicant profile
1484
1502
  const { data: applicant } = await epSb.rpc('get_profile', { p_device_id: applicantDeviceId });
1485
1503
  const aName = applicant?.display_name || '某人';
1486
- const aEmoji = applicant?.emoji || '👤';
1487
1504
 
1488
1505
  const parts = event.created_by.split(':');
1489
1506
  if (parts.length < 2) return;
1490
1507
  notifyUser(parts[0], parts.slice(1).join(':'),
1491
- `[Antenna] 📩 ${aEmoji} ${aName} 申请加入你的活动「${event.name}」\n\n用 antenna_event_scan --code ${event.code} 查看申请者名片并审批。`,
1492
- logger);
1508
+ `[Antenna] 📩 ${aName} 申请加入你的活动「${event.name}」\n\n用 antenna_event_scan --code ${event.code} 查看申请者名片并审批。`,
1509
+ logger, api);
1493
1510
  } catch (err: any) {
1494
1511
  logger.warn('Antenna: event participant INSERT handler error:', err.message);
1495
1512
  }
@@ -1518,11 +1535,11 @@ export default function register(api: any) {
1518
1535
  if (newStatus === 'active') {
1519
1536
  notifyUser(parts[0], parts.slice(1).join(':'),
1520
1537
  `[Antenna] ✅ 你的申请已通过!欢迎加入「${eventName}」\n\n用 antenna_event_scan --code ${event?.code} 查看其他参与者。`,
1521
- logger);
1538
+ logger, api);
1522
1539
  } else if (newStatus === 'rejected') {
1523
1540
  notifyUser(parts[0], parts.slice(1).join(':'),
1524
1541
  `[Antenna] ❌ 你的申请未通过「${eventName}」的审核。`,
1525
- logger);
1542
+ logger, api);
1526
1543
  }
1527
1544
  } catch (err: any) {
1528
1545
  logger.warn('Antenna: event participant UPDATE handler error:', err.message);
@@ -1542,9 +1559,10 @@ export default function register(api: any) {
1542
1559
  const cfg = getConfig(api);
1543
1560
  const supabase = getSupabase(cfg);
1544
1561
 
1545
- // Get all profiles with valid notification targets
1562
+ // Get all profiles that have been active in last 24h
1546
1563
  const { data: activeProfiles } = await supabase
1547
- .rpc("get_notification_targets", { p_since: "7 days" });
1564
+ .rpc("nearby_profiles", { p_lat: 0, p_lng: 0, p_radius_m: 999999999 })
1565
+ .select("device_id");
1548
1566
 
1549
1567
  if (!activeProfiles?.length) return;
1550
1568
 
@@ -1574,7 +1592,7 @@ export default function register(api: any) {
1574
1592
 
1575
1593
  for (const match of newMatches) {
1576
1594
  const notifyKey = `${match.device_id_a}→${match.device_id_b}`;
1577
- if (_notifiedMatches.has(notifyKey)) continue;
1595
+ _notifiedMatches.add(notifyKey);
1578
1596
 
1579
1597
  // Is this a new mutual match?
1580
1598
  if (match.device_id_a === deviceId) {
@@ -1582,38 +1600,36 @@ export default function register(api: any) {
1582
1600
  if (reverse) {
1583
1601
  const { data: theirProfile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_b });
1584
1602
  const name = theirProfile?.display_name || "对方";
1585
- const emoji = theirProfile?.emoji || "👤";
1586
1603
  const contact = reverse.contact_info_a ? `\n对方的联系方式:${reverse.contact_info_a}` : "";
1587
1604
  notifyUser(
1588
1605
  channel, userId,
1589
- `[Antenna] 🎉 双向匹配成功!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1590
- logger,
1606
+ `[Antenna] 🎉 双向匹配成功!${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1607
+ logger, api,
1591
1608
  );
1592
- _notifiedMatches.add(notifyKey);
1609
+ // Clean up follow-up crons
1593
1610
  stopFollowUpCron(deviceId, match.device_id_b, logger);
1594
1611
  }
1595
1612
  } else if (match.device_id_b === deviceId) {
1596
1613
  // Someone new accepted me
1597
1614
  const { data: theirProfile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_a });
1598
1615
  const name = theirProfile?.display_name || "有人";
1599
- const emoji = theirProfile?.emoji || "👤";
1600
1616
  const iAccepted = myMatches.find((m: any) => m.device_id_b === match.device_id_a);
1601
1617
  if (iAccepted) {
1618
+ // I already accepted them → mutual!
1602
1619
  const contact = match.contact_info_a ? `\n对方的联系方式:${match.contact_info_a}` : "";
1603
1620
  notifyUser(
1604
1621
  channel, userId,
1605
- `[Antenna] 🎉 双向匹配成功!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1606
- logger,
1622
+ `[Antenna] 🎉 双向匹配成功!${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1623
+ logger, api,
1607
1624
  );
1608
- _notifiedMatches.add(notifyKey);
1609
1625
  stopFollowUpCron(deviceId, match.device_id_a, logger);
1610
1626
  } else {
1627
+ // They accepted me but I haven't responded
1611
1628
  notifyUser(
1612
1629
  channel, userId,
1613
- `[Antenna] 📩 ${emoji} ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
1614
- logger,
1630
+ `[Antenna] 📩 ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
1631
+ logger, api,
1615
1632
  );
1616
- _notifiedMatches.add(notifyKey);
1617
1633
  }
1618
1634
  }
1619
1635
  }
@@ -1624,9 +1640,8 @@ export default function register(api: any) {
1624
1640
  }
1625
1641
  }
1626
1642
 
1627
- // ── Event approval polling (use notification targets, not _knownDeviceIds) ──
1628
- for (const profile of activeProfiles) {
1629
- const deviceId = profile.device_id;
1643
+ // ── Event approval polling ──
1644
+ for (const deviceId of _knownDeviceIds) {
1630
1645
  try {
1631
1646
  const { data: events } = await supabase.rpc("get_my_event_updates", { p_device_id: deviceId });
1632
1647
  if (!events?.length) continue;
@@ -1635,47 +1650,23 @@ export default function register(api: any) {
1635
1650
  const channel = parts[0];
1636
1651
  const userId = parts.slice(1).join(":");
1637
1652
  for (const ev of events) {
1638
- const key = `event:${deviceId}:${ev.event_id}:${ev.status}`;
1653
+ const key = `event:${ev.event_id}:${ev.status}`;
1639
1654
  if (_notifiedMatches.has(key)) continue;
1640
- if (ev.status === "active" && ev.role !== "creator" && ev.role !== "cohost" && ev.requires_approval) {
1655
+ _notifiedMatches.add(key);
1656
+ if (ev.status === "active" && ev.role !== "creator" && ev.role !== "cohost") {
1641
1657
  notifyUser(channel, userId,
1642
1658
  `[Antenna] ✅ 你的申请已通过!欢迎加入「${ev.event_name}」`,
1643
- logger,
1659
+ logger, api,
1644
1660
  );
1645
- _notifiedMatches.add(key);
1646
1661
  } else if (ev.status === "rejected") {
1647
1662
  notifyUser(channel, userId,
1648
1663
  `[Antenna] ❌ 你的申请未通过「${ev.event_name}」`,
1649
- logger,
1664
+ logger, api,
1650
1665
  );
1651
- _notifiedMatches.add(key);
1652
1666
  }
1653
1667
  }
1654
1668
  } catch { /* silent */ }
1655
1669
  }
1656
-
1657
- // ── Event messages polling ──
1658
- for (const profile of activeProfiles) {
1659
- const deviceId = profile.device_id;
1660
- try {
1661
- const { data: msgs } = await supabase.rpc("get_my_event_messages", { p_device_id: deviceId });
1662
- if (!msgs?.length) continue;
1663
- const parts = deviceId.split(":");
1664
- if (parts.length < 2) continue;
1665
- const channel = parts[0];
1666
- const userId = parts.slice(1).join(":");
1667
- for (const msg of msgs) {
1668
- const key = `evtmsg:${msg.event_id}:${msg.created_at}`;
1669
- if (_notifiedMatches.has(key)) continue;
1670
- const role = msg.sender_role === 'creator' ? '组织者' : '协办';
1671
- notifyUser(channel, userId,
1672
- `[Antenna] 📢 来自「${msg.event_name}」${role} ${msg.sender_emoji || ''} ${msg.sender_name}: ${msg.message}`,
1673
- logger,
1674
- );
1675
- _notifiedMatches.add(key);
1676
- }
1677
- } catch { /* silent */ }
1678
- }
1679
1670
  } catch (err: any) {
1680
1671
  logger.warn("Antenna: match poll error:", err.message);
1681
1672
  }
@@ -1695,6 +1686,171 @@ export default function register(api: any) {
1695
1686
  },
1696
1687
  });
1697
1688
 
1689
+ // ═══════════════════════════════════════════════════════════════════
1690
+ // Tool: antenna_drift_throw
1691
+ // ═══════════════════════════════════════════════════════════════════
1692
+ api.registerTool({
1693
+ name: "antenna_drift_throw",
1694
+ description: "Throw a drift bottle into the sea. Write a message (max 500 chars), a random stranger will pick it up. Anonymous.",
1695
+ parameters: {
1696
+ type: "object",
1697
+ properties: {
1698
+ sender_id: { type: "string", description: "The sender's user ID" },
1699
+ channel: { type: "string", description: "The channel name" },
1700
+ chat_id: { type: "string", description: "REQUIRED. Chat/channel ID for notifications." },
1701
+ message: { type: "string", description: "The message to put in the bottle (max 500 chars)" },
1702
+ },
1703
+ required: ["sender_id", "channel", "chat_id", "message"],
1704
+ },
1705
+ async execute(_id: string, params: any) {
1706
+ const cfg = getConfig(api);
1707
+ const supabase = getSupabase(cfg);
1708
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1709
+
1710
+ if (!params.message || params.message.length === 0) {
1711
+ return ok({ error: "empty_message", message: "漂流瓶不能是空的。" });
1712
+ }
1713
+ if (params.message.length > 500) {
1714
+ return ok({ error: "too_long", message: "最多 500 字。" });
1715
+ }
1716
+
1717
+ const { data, error } = await supabase.rpc("throw_drift_bottle", {
1718
+ p_device_id: deviceId,
1719
+ p_message: params.message,
1720
+ });
1721
+
1722
+ if (error) return ok({ error: error.message });
1723
+ return ok({ success: true, bottle_id: data?.bottle_id, total_thrown: data?.total_thrown, message: "🍾 漂流瓶已丢入海中,等待某个陌生人捡起它…" });
1724
+ },
1725
+ });
1726
+
1727
+ // ═══════════════════════════════════════════════════════════════════
1728
+ // Tool: antenna_drift_pick
1729
+ // ═══════════════════════════════════════════════════════════════════
1730
+ api.registerTool({
1731
+ name: "antenna_drift_pick",
1732
+ description: "Pick up a random drift bottle from the sea. You'll get an anonymous message from a stranger. You can only hold one bottle at a time.",
1733
+ parameters: {
1734
+ type: "object",
1735
+ properties: {
1736
+ sender_id: { type: "string", description: "The sender's user ID" },
1737
+ channel: { type: "string", description: "The channel name" },
1738
+ chat_id: { type: "string", description: "REQUIRED. Chat/channel ID for notifications." },
1739
+ },
1740
+ required: ["sender_id", "channel", "chat_id"],
1741
+ },
1742
+ async execute(_id: string, params: any) {
1743
+ const cfg = getConfig(api);
1744
+ const supabase = getSupabase(cfg);
1745
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1746
+
1747
+ const { data, error } = await supabase.rpc("pick_drift_bottle", {
1748
+ p_device_id: deviceId,
1749
+ });
1750
+
1751
+ if (error) return ok({ error: error.message });
1752
+ return ok(data);
1753
+ },
1754
+ });
1755
+
1756
+ // ═══════════════════════════════════════════════════════════════════
1757
+ // Tool: antenna_drift_reply
1758
+ // ═══════════════════════════════════════════════════════════════════
1759
+ api.registerTool({
1760
+ name: "antenna_drift_reply",
1761
+ description: "Reply to a drift bottle you picked up. Your reply will be sent back to the original thrower anonymously.",
1762
+ parameters: {
1763
+ type: "object",
1764
+ properties: {
1765
+ sender_id: { type: "string", description: "The sender's user ID" },
1766
+ channel: { type: "string", description: "The channel name" },
1767
+ chat_id: { type: "string", description: "REQUIRED. Chat/channel ID for notifications." },
1768
+ bottle_id: { type: "string", description: "The bottle ID to reply to" },
1769
+ reply: { type: "string", description: "Your reply message (max 500 chars)" },
1770
+ },
1771
+ required: ["sender_id", "channel", "chat_id", "bottle_id", "reply"],
1772
+ },
1773
+ async execute(_id: string, params: any) {
1774
+ const cfg = getConfig(api);
1775
+ const supabase = getSupabase(cfg);
1776
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1777
+
1778
+ if (!params.reply || params.reply.length === 0) {
1779
+ return ok({ error: "empty_reply", message: "回复不能是空的。" });
1780
+ }
1781
+ if (params.reply.length > 500) {
1782
+ return ok({ error: "too_long", message: "最多 500 字。" });
1783
+ }
1784
+
1785
+ const { data, error } = await supabase.rpc("reply_drift_bottle", {
1786
+ p_bottle_id: params.bottle_id,
1787
+ p_device_id: deviceId,
1788
+ p_reply: params.reply,
1789
+ });
1790
+
1791
+ if (error) return ok({ error: error.message });
1792
+ return ok(data);
1793
+ },
1794
+ });
1795
+
1796
+ // ═══════════════════════════════════════════════════════════════════
1797
+ // Tool: antenna_drift_check
1798
+ // ═══════════════════════════════════════════════════════════════════
1799
+ api.registerTool({
1800
+ name: "antenna_drift_check",
1801
+ description: "Check drift bottle status: any new replies to your bottles? Any bottles you picked up waiting for reply?",
1802
+ parameters: {
1803
+ type: "object",
1804
+ properties: {
1805
+ sender_id: { type: "string", description: "The sender's user ID" },
1806
+ channel: { type: "string", description: "The channel name" },
1807
+ chat_id: { type: "string", description: "REQUIRED. Chat/channel ID for notifications." },
1808
+ },
1809
+ required: ["sender_id", "channel", "chat_id"],
1810
+ },
1811
+ async execute(_id: string, params: any) {
1812
+ const cfg = getConfig(api);
1813
+ const supabase = getSupabase(cfg);
1814
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1815
+
1816
+ const { data, error } = await supabase.rpc("check_drift_bottles", {
1817
+ p_device_id: deviceId,
1818
+ });
1819
+
1820
+ if (error) return ok({ error: error.message });
1821
+ return ok(data);
1822
+ },
1823
+ });
1824
+
1825
+ // ═══════════════════════════════════════════════════════════════════
1826
+ // Tool: antenna_drift_my_bottles
1827
+ // ═══════════════════════════════════════════════════════════════════
1828
+ api.registerTool({
1829
+ name: "antenna_drift_my_bottles",
1830
+ description: "List all drift bottles you've thrown, with their status (floating/picked/replied).",
1831
+ parameters: {
1832
+ type: "object",
1833
+ properties: {
1834
+ sender_id: { type: "string", description: "The sender's user ID" },
1835
+ channel: { type: "string", description: "The channel name" },
1836
+ chat_id: { type: "string", description: "REQUIRED. Chat/channel ID for notifications." },
1837
+ },
1838
+ required: ["sender_id", "channel", "chat_id"],
1839
+ },
1840
+ async execute(_id: string, params: any) {
1841
+ const cfg = getConfig(api);
1842
+ const supabase = getSupabase(cfg);
1843
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1844
+
1845
+ const { data, error } = await supabase.rpc("get_my_bottles", {
1846
+ p_device_id: deviceId,
1847
+ });
1848
+
1849
+ if (error) return ok({ error: error.message });
1850
+ return ok(data);
1851
+ },
1852
+ });
1853
+
1698
1854
  // ═══════════════════════════════════════════════════════════════════
1699
1855
  // Hook: auto-scan when location is received
1700
1856
  // ═══════════════════════════════════════════════════════════════════