antenna-openclaw-plugin 1.3.38 → 1.3.39

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;
28
31
  visible: boolean;
29
32
  last_seen_at?: string;
30
- profile_slug?: string;
31
- distance_m?: number;
32
- dist_meters?: number;
33
- matching_context?: string;
34
33
  }
35
34
 
36
35
  interface MatchResult {
37
36
  device_id: string;
38
37
  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 ?? 24,
61
+ matchExpiryHours: cfg.matchExpiryHours ?? 168,
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, api?: any): string {
105
+ function deriveDeviceId(senderId: string, channel: string, chatId?: string): string {
106
106
  const id = `${channel}:${senderId}`;
107
107
  _knownDeviceIds.add(id);
108
- if (chatId && api) {
108
+ if (chatId) {
109
109
  _channelContext.set(id, chatId);
110
110
  // Persist to DB async
111
111
  try {
@@ -122,6 +122,32 @@ function ok(data: any) {
122
122
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
123
123
  }
124
124
 
125
+ async function generateEmbeddingForQuery(cfg: AntennaConfig, text: string): Promise<number[] | null> {
126
+ try {
127
+ const supabaseUrl = cfg.supabaseUrl || BUILTIN_SUPABASE_URL;
128
+ const supabaseKey = cfg.supabaseKey || BUILTIN_SUPABASE_ANON_KEY;
129
+ const res = await fetch(`${supabaseUrl}/functions/v1/generate-embedding`, {
130
+ method: "POST",
131
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${supabaseKey}` },
132
+ body: JSON.stringify({ text }),
133
+ });
134
+ if (!res.ok) return null;
135
+ const data = await res.json();
136
+ return data?.embedding || null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ function intentSearchReason(query: string, profile: any): string {
143
+ if (profile.recommendation_reason) return profile.recommendation_reason;
144
+ const tags = Array.isArray(profile.interest_tags) && profile.interest_tags.length
145
+ ? ` Tags: ${profile.interest_tags.slice(0, 3).join(", ")}.`
146
+ : "";
147
+ const score = typeof profile.match_score === "number" ? ` Score: ${profile.match_score.toFixed(2)}.` : "";
148
+ return `Matches the intent "${query}".${tags}${score}`.trim();
149
+ }
150
+
125
151
  // ─── Cron helpers ────────────────────────────────────────────────────
126
152
 
127
153
  const FOLLOW_UP_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
@@ -140,13 +166,12 @@ async function notifyUser(
140
166
  userId: string,
141
167
  message: string,
142
168
  logger: any,
143
- api?: any,
144
169
  ): Promise<void> {
145
170
  const deviceId = `${channel}:${userId}`;
146
171
  let chatId = _channelContext.get(deviceId);
147
172
 
148
173
  // Fallback: read from DB if not in memory
149
- if (!chatId && api) {
174
+ if (!chatId) {
150
175
  try {
151
176
  const cfg = getConfig(api);
152
177
  const sb = getSupabase(cfg);
@@ -276,7 +301,7 @@ export default function register(api: any) {
276
301
  async execute(_id: string, params: any) {
277
302
  const cfg = getConfig(api);
278
303
  const supabase = getSupabase(cfg);
279
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
304
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
280
305
  const radius = params.radius_m ?? cfg.defaultRadiusM ?? 500;
281
306
 
282
307
  if (isRateLimited(deviceId)) {
@@ -308,10 +333,38 @@ export default function register(api: any) {
308
333
  const others = (nearby ?? []).filter((p: Profile) => p.device_id !== deviceId);
309
334
 
310
335
  if (others.length === 0) {
311
- return ok({
312
- profiles: [], count: 0, radius_m: radius,
313
- message: `附近 ${radius}m 暂时没人。可以试试全球推荐(antenna_discover)或者把你的名片链接发给想认识的人。`,
336
+ // Fallback to global discover
337
+ const { data: globalData } = await supabase.rpc("global_discover", {
338
+ p_device_id: deviceId, p_limit: 1,
314
339
  });
340
+ const globalOthers = globalData || [];
341
+ if (globalOthers.length > 0) {
342
+ const gRefMap: Record<string, string> = {};
343
+ const gProfiles = globalOthers.map((p: any, i: number) => {
344
+ const ref = String(i + 1);
345
+ gRefMap[ref] = p.device_id;
346
+ return {
347
+ ref: ref,
348
+ name: p.display_name || "匿名",
349
+ personal_description: p.line1,
350
+ looking_for: p.line2,
351
+ conversation_style: p.line3,
352
+ more_information: p.matching_context || null,
353
+ profile_slug: p.profile_slug || null,
354
+ distance_m: p.distance_m ?? p.dist_meters ?? null,
355
+ };
356
+ });
357
+ (api as any)._antennaRefMap = gRefMap;
358
+ try { await supabase.rpc("save_scan_refs", { p_owner: deviceId, p_refs: gRefMap }); } catch {}
359
+ for (const p of globalOthers) {
360
+ try { await supabase.rpc("log_recommendation", { p_device_id: deviceId, p_recommended_id: p.device_id }); } catch {}
361
+ }
362
+ return ok({
363
+ profiles: gProfiles, count: gProfiles.length, radius_m: radius, global: true,
364
+ message: `附近 ${radius}m 暂时没人。今天的全球推荐——这个人跟你可能聊得来。(每天 1 次)`,
365
+ });
366
+ }
367
+ return ok({ profiles: [], message: `附近暂时没人,今天的全球推荐已经用完了。明天再来!` });
315
368
  }
316
369
 
317
370
  // Build ref mapping — never expose device_id
@@ -320,11 +373,12 @@ export default function register(api: any) {
320
373
  const ref = String(i + 1);
321
374
  _refMap[ref] = p.device_id;
322
375
  return {
323
- ref,
376
+ ref: ref,
324
377
  name: p.display_name || "匿名",
325
378
  personal_description: p.line1,
326
379
  looking_for: p.line2,
327
380
  conversation_style: p.line3,
381
+ more_information: p.matching_context || null,
328
382
  profile_slug: p.profile_slug || null,
329
383
  distance_m: p.distance_m ?? p.dist_meters ?? null,
330
384
  };
@@ -351,7 +405,7 @@ export default function register(api: any) {
351
405
  api.registerTool({
352
406
  name: "antenna_profile",
353
407
  description:
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.",
408
+ "View or update the user's Antenna profile (name card). The profile has a display name, emoji, and three lines describing who they are.",
355
409
  parameters: {
356
410
  type: "object",
357
411
  properties: {
@@ -360,98 +414,45 @@ export default function register(api: any) {
360
414
  channel: { type: "string", description: "The channel name" },
361
415
  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." },
362
416
  display_name: { type: "string", description: "Display name" },
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)" },
417
+ emoji: { type: "string", description: "Profile emoji" },
418
+ line1: { type: "string", description: "First line (who you are / what you do)" },
419
+ line2: { type: "string", description: "Second line (what you're into)" },
420
+ line3: { type: "string", description: "Third line (what you're looking for)" },
366
421
  visible: { type: "boolean", description: "Whether to be visible to others" },
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 5)" },
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." },
422
+ matching_context: { type: "string", description: "More information / free-form context for AI matching (interests, goals, background, etc.)" },
373
423
  },
374
424
  required: ["action", "sender_id", "channel", "chat_id"],
375
425
  },
376
426
  async execute(_id: string, params: any) {
377
427
  const cfg = getConfig(api);
378
428
  const supabase = getSupabase(cfg);
379
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
429
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
380
430
 
381
431
  if (params.action === "get") {
382
432
  const { data, error } = await supabase.rpc("get_profile", { p_device_id: deviceId });
383
433
  if (error || !data) {
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 5)", maxItems: 5 },
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
- });
434
+ return ok({ exists: false, message: "你还没有名片。告诉我你的名字、一个 emoji、和三句话介绍自己,我帮你创建。" });
399
435
  }
400
436
  return ok({
401
437
  exists: true,
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 5)", maxItems: 5 },
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
- },
438
+ profile: { display_name: data.display_name, emoji: data.emoji,
439
+ line1: data.line1, line2: data.line2, line3: data.line3, visible: data.visible },
415
440
  });
416
441
  }
417
442
 
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
-
441
443
  const { data, error } = await supabase.rpc("upsert_profile", {
442
444
  p_device_id: deviceId,
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 } : {}),
445
+ p_display_name: params.display_name ?? null, p_emoji: params.emoji ?? null,
446
+ p_line1: params.line1 ?? null, p_line2: params.line2 ?? null,
447
+ p_line3: params.line3 ?? null, p_visible: params.visible ?? true,
448
+ ...(params.matching_context != null ? { p_matching_context: params.matching_context } : {}),
448
449
  });
449
450
 
450
451
  if (error) return ok({ error: error.message });
451
452
 
452
453
  // Read back profile to get slug for public page link
453
- let publicUrl: string | null = null;
454
- let gpsBindUrl: string | null = null;
454
+ let publicUrl = null;
455
+ let archetypeResult = null;
455
456
  try {
456
457
  const { data: profile } = await supabase.rpc("get_profile", { p_device_id: deviceId });
457
458
  if (profile?.profile_slug) {
@@ -459,25 +460,50 @@ export default function register(api: any) {
459
460
  }
460
461
  } catch {}
461
462
 
462
- // Auto-generate GPS bind link
463
+ // Generate personalized archetype description via LLM (best-effort)
463
464
  try {
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}`;
465
+ const profileText = [data.line1, data.line2, data.line3, params.matching_context].filter(Boolean).join(". ");
466
+ if (profileText) {
467
+ const corpus = profileText.toLowerCase();
468
+ const archetypeKw: Record<string, string[]> = {
469
+ Prometheus: ["ai", "agent", "llm", "founder", "startup", "build", "developer", "tools"],
470
+ Athena: ["product", "strategy", "research", "design", "craft", "pm", "ux"],
471
+ Hermes: ["network", "connect", "community", "social", "bridge"],
472
+ Apollo: ["music", "media", "content", "creator", "writing", "taste"],
473
+ Artemis: ["independent", "explore", "freelance", "health", "outdoor"],
474
+ Aphrodite: ["beauty", "brand", "fashion", "relationship"],
475
+ Dionysus: ["event", "culture", "party", "art", "festival"],
476
+ Hades: ["finance", "invest", "infrastructure", "backend", "security"],
477
+ Persephone: ["transform", "cross", "research", "academic", "bridge"],
478
+ Odysseus: ["founder", "journey", "resilience", "travel", "startup"],
479
+ };
480
+ let bestRole = "Prometheus"; let bestScore = 0;
481
+ for (const [role, kws] of Object.entries(archetypeKw)) {
482
+ const score = kws.reduce((s, kw) => s + (corpus.includes(kw) ? 1 : 0), 0);
483
+ if (score > bestScore) { bestScore = score; bestRole = role; }
484
+ }
485
+ const cfg2 = getConfig(api);
486
+ const supabaseUrl = cfg2.supabaseUrl || "https://bcudjloikmpcqwcptuyd.supabase.co";
487
+ const supabaseKey = cfg2.supabaseKey || BUILTIN_SUPABASE_ANON_KEY;
488
+ const res = await fetch(`${supabaseUrl}/functions/v1/generate-archetype`, {
489
+ method: "POST",
490
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${supabaseKey}` },
491
+ body: JSON.stringify({ archetype: bestRole, profile_text: profileText }),
492
+ });
493
+ if (res.ok) {
494
+ const archData = await res.json();
495
+ if (archData?.reason) archetypeResult = { archetype: bestRole, ...archData };
496
+ }
471
497
  }
472
498
  } catch {}
473
499
 
474
500
  return ok({
475
501
  updated: true,
476
502
  profile: { display_name: data.display_name,
477
- personal_description: data.line1, looking_for: data.line2, conversation_style: data.line3, visible: data.visible },
503
+ line1: data.line1, line2: data.line2, line3: data.line3, visible: data.visible },
478
504
  public_url: publicUrl,
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.",
505
+ archetype: archetypeResult || null,
506
+ 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.",
481
507
  });
482
508
  },
483
509
  });
@@ -504,7 +530,7 @@ export default function register(api: any) {
504
530
  async execute(_id: string, params: any) {
505
531
  const cfg = getConfig(api);
506
532
  const supabase = getSupabase(cfg);
507
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
533
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
508
534
  const fuzzy = fuzzyCoords(params.lat, params.lng);
509
535
 
510
536
  // Check if user has a profile first
@@ -512,7 +538,7 @@ export default function register(api: any) {
512
538
  if (!profile) {
513
539
  return ok({
514
540
  checked_in: false,
515
- message: "你还没有名片,别人看到你也不知道你是谁。先创建一个名片吧(跟我聊聊你是谁、做什么、想认识什么人)。",
541
+ message: "你还没有名片,别人看到你也不知道你是谁。先创建一个名片吧(告诉我你的名字、emoji、三句话介绍自己)。",
516
542
  });
517
543
  }
518
544
 
@@ -536,7 +562,7 @@ export default function register(api: any) {
536
562
  api.registerTool({
537
563
  name: "antenna_accept",
538
564
  description:
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.",
565
+ "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.",
540
566
  parameters: {
541
567
  type: "object",
542
568
  properties: {
@@ -544,8 +570,8 @@ export default function register(api: any) {
544
570
  channel: { type: "string" },
545
571
  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." },
546
572
  ref: { type: "string", description: "Ref number from scan results (e.g. '1')" },
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)" },
573
+ target_device_id: { type: "string", description: "Device ID (use ref or profile_slug instead when possible)" },
574
+ 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." },
549
575
  contact_info: { type: "string", description: "Optional contact info to share" },
550
576
  },
551
577
  required: ["sender_id", "channel", "chat_id"],
@@ -553,7 +579,7 @@ export default function register(api: any) {
553
579
  async execute(_id: string, params: any) {
554
580
  const cfg = getConfig(api);
555
581
  const supabase = getSupabase(cfg);
556
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
582
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
557
583
 
558
584
  // Resolve ref to device_id — try DB first, then memory fallback
559
585
  let targetId = params.target_device_id;
@@ -562,13 +588,12 @@ export default function register(api: any) {
562
588
  const { data: resolved } = await supabase.rpc("resolve_ref", { p_owner: deviceId, p_ref: params.ref });
563
589
  targetId = resolved || (api as any)._antennaRefMap?.[params.ref];
564
590
  }
565
- // Resolve profile_slug to device_id
591
+ // Resolve profile_slug to device_id via get_profile_by_slug RPC
566
592
  if (!targetId && params.profile_slug) {
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.` });
593
+ const { data: slugProfile } = await supabase.rpc("get_profile_by_slug", { p_slug: params.profile_slug });
594
+ const resolved = Array.isArray(slugProfile) ? slugProfile[0] : slugProfile;
595
+ if (resolved?.device_id) {
596
+ targetId = resolved.device_id;
572
597
  }
573
598
  }
574
599
  if (!targetId) {
@@ -638,7 +663,7 @@ export default function register(api: any) {
638
663
  async execute(_id: string, params: any) {
639
664
  const cfg = getConfig(api);
640
665
  const supabase = getSupabase(cfg);
641
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
666
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
642
667
 
643
668
  const { data, error } = await supabase.rpc("create_bind_token", {
644
669
  p_device_id: deviceId,
@@ -660,6 +685,45 @@ export default function register(api: any) {
660
685
  },
661
686
  });
662
687
 
688
+ // ═══════════════════════════════════════════════════════════════════
689
+ // Tool: antenna_link_account
690
+ // ═══════════════════════════════════════════════════════════════════
691
+ api.registerTool({
692
+ name: "antenna_link_account",
693
+ description:
694
+ "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.",
695
+ parameters: {
696
+ type: "object",
697
+ properties: {
698
+ sender_id: { type: "string", description: "The sender's user ID" },
699
+ channel: { type: "string", description: "The channel name" },
700
+ chat_id: { type: "string", description: "REQUIRED. Pass the chat/channel ID from your message context." },
701
+ api_key: { type: "string", description: "The user's Antenna API key (ant_xxx) from antenna.fyi/me" },
702
+ },
703
+ required: ["sender_id", "channel", "chat_id", "api_key"],
704
+ },
705
+ async execute(_id: string, params: any) {
706
+ const cfg = getConfig(api);
707
+ const supabase = getSupabase(cfg);
708
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
709
+
710
+ const { data, error } = await supabase.rpc("bind_user_id", {
711
+ p_device_id: deviceId,
712
+ p_api_key: params.api_key,
713
+ });
714
+ if (error) return ok({ error: error.message });
715
+
716
+ if (data?.error) {
717
+ return ok(data);
718
+ }
719
+
720
+ return ok({
721
+ ...data,
722
+ message: "账号已关联!现在你可以在 antenna.fyi/me 看到你的完整 profile 和匹配记录了。",
723
+ });
724
+ },
725
+ });
726
+
663
727
  // ═══════════════════════════════════════════════════════════════════
664
728
  // Tool: antenna_discover
665
729
  // ═══════════════════════════════════════════════════════════════════
@@ -679,7 +743,7 @@ export default function register(api: any) {
679
743
  async execute(_id: string, params: any) {
680
744
  const cfg = getConfig(api);
681
745
  const supabase = getSupabase(cfg);
682
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
746
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
683
747
 
684
748
  const { data: globalData } = await supabase.rpc("global_discover", {
685
749
  p_device_id: deviceId, p_limit: 1,
@@ -722,7 +786,16 @@ export default function register(api: any) {
722
786
  } catch { /* best effort */ }
723
787
  }
724
788
 
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 });
789
+ profiles.push({
790
+ ref: ref,
791
+ name: p.display_name || "匿名",
792
+ personal_description: p.line1,
793
+ looking_for: p.line2,
794
+ conversation_style: p.line3,
795
+ more_information: p.matching_context || null,
796
+ profile_slug: p.profile_slug || null,
797
+ match_reason: match_reason,
798
+ });
726
799
  }
727
800
 
728
801
  // Persist refs + log recommendation
@@ -741,13 +814,86 @@ export default function register(api: any) {
741
814
  },
742
815
  });
743
816
 
817
+ // ═══════════════════════════════════════════════════════════════════
818
+ // Tool: antenna_find_people
819
+ // ═══════════════════════════════════════════════════════════════════
820
+ api.registerTool({
821
+ name: "antenna_find_people",
822
+ description:
823
+ "Find 1-3 people by a free-form intent, e.g. '想找一个懂 consumer social 增长的人'. Returns privacy-safe refs; use ref with antenna_accept if the user wants an intro.",
824
+ parameters: {
825
+ type: "object",
826
+ properties: {
827
+ query: { type: "string", description: "Free-form user intent describing the kind of person to find" },
828
+ sender_id: { type: "string", description: "The sender's user ID" },
829
+ channel: { type: "string", description: "The channel name" },
830
+ 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." },
831
+ limit: { type: "number", description: "Maximum profiles to return, 1-3" },
832
+ },
833
+ required: ["query", "sender_id", "channel", "chat_id"],
834
+ },
835
+ async execute(_id: string, params: any) {
836
+ const cfg = getConfig(api);
837
+ const supabase = getSupabase(cfg);
838
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
839
+ const query = String(params.query || "").trim();
840
+ const limit = Math.min(Math.max(Number(params.limit) || 3, 1), 3);
841
+
842
+ if (query.length < 2) {
843
+ return ok({ count: 0, profiles: [], message: "Tell me what kind of person you want to find." });
844
+ }
845
+
846
+ const embedding = await generateEmbeddingForQuery(cfg, query);
847
+ const { data, error } = await supabase.rpc("antenna_intent_search_people", {
848
+ p_device_id: deviceId,
849
+ p_query: query,
850
+ p_query_embedding: embedding ? `[${embedding.join(",")}]` : null,
851
+ p_limit: limit,
852
+ });
853
+ if (error) return ok({ error: error.message });
854
+
855
+ const _refMap: Record<string, string> = {};
856
+ const profiles = (data || []).map((p: any, i: number) => {
857
+ const ref = String(i + 1);
858
+ _refMap[ref] = p.device_id;
859
+ return {
860
+ ref: ref,
861
+ display_name: p.display_name || "匿名",
862
+ profile_slug: p.profile_slug || null,
863
+ personal_description: p.personal_description || null,
864
+ looking_for: p.looking_for || null,
865
+ conversation_style: p.conversation_style || null,
866
+ more_information: p.more_information || null,
867
+ interest_tags: p.interest_tags || [],
868
+ city: p.city || null,
869
+ match_score: typeof p.match_score === "number" ? Math.round(p.match_score * 1000) / 1000 : null,
870
+ recommendation_reason: intentSearchReason(query, p),
871
+ };
872
+ });
873
+
874
+ (api as any)._antennaRefMap = { ...(api as any)._antennaRefMap, ..._refMap };
875
+ if (Object.keys(_refMap).length > 0) {
876
+ try { await supabase.rpc("save_scan_refs", { p_owner: deviceId, p_refs: _refMap }); } catch {}
877
+ }
878
+
879
+ return ok({
880
+ count: profiles.length,
881
+ profiles,
882
+ query,
883
+ message: profiles.length
884
+ ? "Intent search results. Recommend only the best fit(s), then use ref with antenna_accept if the user wants an intro."
885
+ : "No relevant active profiles found for that intent right now.",
886
+ });
887
+ },
888
+ });
889
+
744
890
  // ═══════════════════════════════════════════════════════════════════
745
891
  // Tool: antenna_initial_recommendations
746
892
  // ═══════════════════════════════════════════════════════════════════
747
893
  api.registerTool({
748
894
  name: "antenna_initial_recommendations",
749
895
  description:
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.",
896
+ "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.",
751
897
  parameters: {
752
898
  type: "object",
753
899
  properties: {
@@ -760,51 +906,27 @@ export default function register(api: any) {
760
906
  async execute(_id: string, params: any) {
761
907
  const cfg = getConfig(api);
762
908
  const supabase = getSupabase(cfg);
763
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
764
-
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
- }
909
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
775
910
 
776
- const { data: globalData } = await supabase.rpc("global_discover", {
777
- p_device_id: deviceId, p_limit: 3,
911
+ const { data: results, error } = await supabase.rpc("initial_recommendations", {
912
+ p_device_id: deviceId,
913
+ p_limit: 3,
778
914
  });
779
915
 
780
- const results = globalData || [];
916
+ if (error) return ok({ error: error.message });
781
917
 
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),
918
+ if (!results || results.length === 0) {
919
+ return ok({
920
+ count: 0,
921
+ profiles: [],
922
+ initial: true,
923
+ message: "暂时没有推荐,等有更多人加入!",
798
924
  });
799
- } catch {}
800
-
801
- if (results.length === 0) {
802
- return ok({ count: 0, profiles: [], message: "目前还没有足够的用户来匹配。你是早期用户!" });
803
925
  }
804
926
 
805
927
  const _refMap: Record<string, string> = {};
806
928
 
807
- // Get my profile for match reason
929
+ // Get my profile for match reason generation
808
930
  const { data: myProfile } = await supabase.rpc("get_profile", { p_device_id: deviceId });
809
931
  const myLines = myProfile ? [myProfile.line1, myProfile.line2, myProfile.line3].filter(Boolean).join(". ") : "";
810
932
 
@@ -833,21 +955,21 @@ export default function register(api: any) {
833
955
  } catch { /* best effort */ }
834
956
  }
835
957
 
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
- });
958
+ 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 });
840
959
  }
841
960
 
961
+ // Persist refs + log recommendations
842
962
  (api as any)._antennaRefMap = { ...(api as any)._antennaRefMap, ..._refMap };
843
- try { await supabase.rpc("save_scan_refs", { p_owner: deviceId, p_refs: _refMap }); } catch {}
963
+ try {
964
+ await supabase.rpc("save_scan_refs", { p_owner: deviceId, p_refs: _refMap });
965
+ } catch { /* best effort */ }
844
966
  for (const p of results) {
845
- await supabase.rpc("log_recommendation", { p_device_id: deviceId, p_recommended_id: p.device_id }).catch(() => {});
967
+ await supabase.rpc("log_recommendation", { p_device_id: deviceId, p_recommended_id: (p as any).device_id });
846
968
  }
847
969
 
848
970
  return ok({
849
971
  count: profiles.length, profiles, initial: true,
850
- message: "\uD83C\uDF1F 这是你的首次推荐——跟你最像的人:",
972
+ message: "这是你的首次推荐——基于你的名片,这几个人跟你最匹配。",
851
973
  });
852
974
  },
853
975
  });
@@ -879,11 +1001,11 @@ export default function register(api: any) {
879
1001
  async execute(_id: string, params: any) {
880
1002
  const cfg = getConfig(api);
881
1003
  const supabase = getSupabase(cfg);
882
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1004
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
883
1005
  const { data, error } = await supabase.rpc("create_event", {
884
1006
  p_name: params.name,
885
- p_lat: params.lat || null,
886
- p_lng: params.lng || null,
1007
+ p_lat: params.lat ?? null,
1008
+ p_lng: params.lng ?? null,
887
1009
  p_created_by: deviceId,
888
1010
  p_starts_at: params.starts_at || null,
889
1011
  p_ends_at: params.ends_at || null,
@@ -916,7 +1038,7 @@ export default function register(api: any) {
916
1038
  async execute(_id: string, params: any) {
917
1039
  const cfg = getConfig(api);
918
1040
  const supabase = getSupabase(cfg);
919
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1041
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
920
1042
  const { data, error } = await supabase.rpc("end_event", {
921
1043
  p_code: params.code,
922
1044
  p_device_id: deviceId,
@@ -948,7 +1070,7 @@ export default function register(api: any) {
948
1070
  async execute(_id: string, params: any) {
949
1071
  const cfg = getConfig(api);
950
1072
  const supabase = getSupabase(cfg);
951
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1073
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
952
1074
 
953
1075
  // Profile gate
954
1076
  const { data: profile } = await supabase.rpc("get_profile", { p_device_id: deviceId });
@@ -967,7 +1089,7 @@ export default function register(api: any) {
967
1089
  } catch {}
968
1090
  }
969
1091
 
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 });
1092
+ 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 });
971
1093
  if (error) return ok({ error: error.message });
972
1094
  if (!data?.joined) return ok(data);
973
1095
 
@@ -1030,7 +1152,7 @@ export default function register(api: any) {
1030
1152
  async execute(_id: string, params: any) {
1031
1153
  const cfg = getConfig(api);
1032
1154
  const supabase = getSupabase(cfg);
1033
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1155
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1034
1156
 
1035
1157
  const { data, error } = await supabase.rpc("event_participants_list", { p_code: params.code, p_device_id: deviceId });
1036
1158
  if (error) return ok({ error: error.message });
@@ -1040,7 +1162,20 @@ export default function register(api: any) {
1040
1162
  const profiles = others.map((p, i) => {
1041
1163
  const ref = String(i + 1);
1042
1164
  _refMap[ref] = p.device_id;
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" };
1165
+ return {
1166
+ ref: ref,
1167
+ name: p.display_name || "匿名",
1168
+ personal_description: p.line1,
1169
+ looking_for: p.line2,
1170
+ conversation_style: p.line3,
1171
+ more_information: p.matching_context || null,
1172
+ profile_slug: p.profile_slug || null,
1173
+ checked_in: !!p.checked_in,
1174
+ role: p.role || "participant",
1175
+ status: p.status || "active",
1176
+ application_context: p.application_context || null,
1177
+ source: "event",
1178
+ };
1044
1179
  });
1045
1180
 
1046
1181
  (api as any)._antennaRefMap = { ...(api as any)._antennaRefMap, ..._refMap };
@@ -1070,7 +1205,7 @@ export default function register(api: any) {
1070
1205
  async execute(_id: string, params: any) {
1071
1206
  const cfg = getConfig(api);
1072
1207
  const supabase = getSupabase(cfg);
1073
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1208
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1074
1209
 
1075
1210
  let targetId = params.target_device_id;
1076
1211
  if (!targetId && params.ref) {
@@ -1107,7 +1242,7 @@ export default function register(api: any) {
1107
1242
  async execute(_id: string, params: any) {
1108
1243
  const cfg = getConfig(api);
1109
1244
  const supabase = getSupabase(cfg);
1110
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1245
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1111
1246
  const fuzzy = (params.lat != null && params.lng != null) ? fuzzyCoords(params.lat, params.lng) : { lat: null, lng: null };
1112
1247
  const { data, error } = await supabase.rpc("event_checkin", {
1113
1248
  p_code: params.code,
@@ -1166,7 +1301,7 @@ export default function register(api: any) {
1166
1301
  async execute(_id: string, params: any) {
1167
1302
  const cfg = getConfig(api);
1168
1303
  const supabase = getSupabase(cfg);
1169
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1304
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1170
1305
 
1171
1306
  const { data: result } = await supabase.rpc("get_my_matches_with_profiles", { p_device_id: deviceId });
1172
1307
 
@@ -1181,18 +1316,18 @@ export default function register(api: any) {
1181
1316
  ref: String(i + 1),
1182
1317
  _device_id: m.target_id,
1183
1318
  name: m.name || "匿名",
1184
- personal_description: m.line1, looking_for: m.line2, conversation_style: m.line3,
1185
- profile_slug: m.profile_slug || null,
1319
+ emoji: m.emoji || "👤",
1320
+ line1: m.line1, line2: m.line2, line3: m.line3,
1186
1321
  their_contact: m.their_contact || null,
1187
1322
  you_shared: m.you_shared || null,
1188
1323
  }));
1189
1324
 
1190
1325
  const incomingAccepts = rawIncoming.map((m: any, i: number) => ({
1191
- ref: String(i + 1),
1326
+ ref: String(mutualMatches.length + i + 1),
1192
1327
  _device_id: m.target_id,
1193
1328
  name: m.name || "匿名",
1194
- personal_description: m.line1, looking_for: m.line2, conversation_style: m.line3,
1195
- profile_slug: m.profile_slug || null,
1329
+ emoji: m.emoji || "👤",
1330
+ line1: m.line1, line2: m.line2, line3: m.line3,
1196
1331
  }));
1197
1332
 
1198
1333
  // Clean up follow-up crons for mutual matches
@@ -1206,6 +1341,14 @@ export default function register(api: any) {
1206
1341
  if (incomingAccepts.length > 0) messages.push(`${incomingAccepts.length} 个人想认识你,等你回应`);
1207
1342
  if (messages.length === 0) messages.push("你接受了一些匹配,但对方还没有回应。耐心等等 ⏳");
1208
1343
 
1344
+ // Persist ref map so accept(ref) resolves correctly
1345
+ const _refMap: Record<string, string> = {};
1346
+ for (const m of mutualMatches) _refMap[m.ref] = m._device_id;
1347
+ for (const m of incomingAccepts) _refMap[m.ref] = m._device_id;
1348
+ if (deviceId && Object.keys(_refMap).length > 0) {
1349
+ try { await supabase.rpc("save_scan_refs", { p_owner: deviceId, p_refs: _refMap }); } catch { /* best effort */ }
1350
+ }
1351
+
1209
1352
  return ok({
1210
1353
  mutual_matches: mutualMatches,
1211
1354
  incoming_accepts: incomingAccepts,
@@ -1234,18 +1377,22 @@ export default function register(api: any) {
1234
1377
  lng: { type: "number", description: "New event longitude" },
1235
1378
  starts_at: { type: "string", description: "New start time ISO" },
1236
1379
  ends_at: { type: "string", description: "New end time ISO" },
1380
+ requires_approval: { type: "boolean", description: "Require host approval to join" },
1381
+ screening_questions: { type: "array", items: { type: "string" }, description: "Screening questions for applicants" },
1237
1382
  },
1238
1383
  required: ["code", "sender_id", "channel", "chat_id"],
1239
1384
  },
1240
1385
  async execute(_id: string, params: any) {
1241
1386
  const cfg = getConfig(api);
1242
1387
  const supabase = getSupabase(cfg);
1243
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1388
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1244
1389
  const { data, error } = await supabase.rpc("update_event", {
1245
1390
  p_code: params.code, p_device_id: deviceId,
1246
1391
  p_name: params.name || null, p_description: params.description || null,
1247
- p_og_image: params.og_image || null, p_lat: params.lat || null, p_lng: params.lng || null,
1392
+ p_og_image: params.og_image || null, p_lat: params.lat ?? null, p_lng: params.lng ?? null,
1248
1393
  p_starts_at: params.starts_at || null, p_ends_at: params.ends_at || null,
1394
+ ...(params.requires_approval != null ? { p_requires_approval: params.requires_approval } : {}),
1395
+ ...(params.screening_questions != null ? { p_screening_questions: params.screening_questions } : {}),
1249
1396
  });
1250
1397
  if (error) return ok({ error: error.message });
1251
1398
  return ok(data);
@@ -1272,7 +1419,7 @@ export default function register(api: any) {
1272
1419
  async execute(_id: string, params: any) {
1273
1420
  const cfg = getConfig(api);
1274
1421
  const supabase = getSupabase(cfg);
1275
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1422
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1276
1423
  const { data, error } = await supabase.rpc("approve_participant", {
1277
1424
  p_code: params.code, p_device_id: deviceId, p_target_ref: params.ref,
1278
1425
  });
@@ -1301,7 +1448,7 @@ export default function register(api: any) {
1301
1448
  async execute(_id: string, params: any) {
1302
1449
  const cfg = getConfig(api);
1303
1450
  const supabase = getSupabase(cfg);
1304
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1451
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1305
1452
  const { data, error } = await supabase.rpc("reject_participant", {
1306
1453
  p_code: params.code, p_device_id: deviceId, p_target_ref: params.ref,
1307
1454
  });
@@ -1330,7 +1477,7 @@ export default function register(api: any) {
1330
1477
  async execute(_id: string, params: any) {
1331
1478
  const cfg = getConfig(api);
1332
1479
  const supabase = getSupabase(cfg);
1333
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1480
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1334
1481
  const { data, error } = await supabase.rpc("add_cohost", {
1335
1482
  p_code: params.code, p_device_id: deviceId, p_target_ref: params.ref,
1336
1483
  });
@@ -1344,72 +1491,34 @@ export default function register(api: any) {
1344
1491
  // ═══════════════════════════════════════════════════════════════════
1345
1492
  api.registerTool({
1346
1493
  name: "antenna_event_message",
1347
- description:
1348
- "Send a message to event participants. Only creator or co-host can send. Omit ref to broadcast to all.",
1494
+ description: "Send a message to event participants. Only creator or co-host can send. Omit ref to broadcast to all participants.",
1349
1495
  parameters: {
1350
1496
  type: "object",
1351
1497
  properties: {
1352
1498
  code: { type: "string", description: "Event code" },
1353
- sender_id: { type: "string", description: "The sender's user ID" },
1354
- channel: { type: "string", description: "Channel name" },
1499
+ sender_id: { type: "string" },
1500
+ channel: { type: "string" },
1355
1501
  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." },
1356
- message: { type: "string", description: "Message to send" },
1357
- ref: { type: "string", description: "Ref number of specific participant (omit for broadcast)" },
1502
+ message: { type: "string", description: "Message to send to participants" },
1503
+ ref: { type: "string", description: "Ref number of specific participant (omit to broadcast to all)" },
1358
1504
  },
1359
- required: ["code", "sender_id", "channel", "chat_id", "message"],
1505
+ required: ["code", "sender_id", "channel", "message", "chat_id"],
1360
1506
  },
1361
1507
  async execute(_id: string, params: any) {
1362
1508
  const cfg = getConfig(api);
1363
1509
  const supabase = getSupabase(cfg);
1364
- const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id, api);
1365
- const rpcParams: any = {
1510
+ const deviceId = deriveDeviceId(params.sender_id, params.channel, params.chat_id);
1511
+ const { data, error } = await supabase.rpc("send_event_message", {
1366
1512
  p_code: params.code,
1367
1513
  p_device_id: deviceId,
1368
1514
  p_message: params.message,
1369
- };
1370
- if (params.ref) rpcParams.p_target_ref = params.ref;
1371
- const { data, error } = await supabase.rpc("send_event_message", rpcParams);
1372
- if (error) return ok({ error: error.message });
1373
- return ok(data);
1374
- },
1375
- });
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,
1515
+ ...(params.ref ? { p_target_ref: params.ref } : {}),
1401
1516
  });
1402
1517
  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
- });
1518
+ return ok(data);
1408
1519
  },
1409
1520
  });
1410
1521
 
1411
- // ═══════════════════════════════════════════════════════════════════
1412
- // Service: poll for new matches every 10 minutes → notify instantly
1413
1522
  // ═══════════════════════════════════════════════════════════════════
1414
1523
  const _notifiedMatches = new Set<string>(); // "deviceA→deviceB" already notified
1415
1524
 
@@ -1432,11 +1541,10 @@ export default function register(api: any) {
1432
1541
  async (payload: any) => {
1433
1542
  try {
1434
1543
  const targetDeviceId = payload.new?.device_id_b;
1435
- if (!targetDeviceId || !_knownDeviceIds.has(targetDeviceId)) return;
1544
+ if (!targetDeviceId) return;
1436
1545
 
1437
1546
  const key = `${payload.new.device_id_a}→${targetDeviceId}`;
1438
1547
  if (_notifiedMatches.has(key)) return;
1439
- _notifiedMatches.add(key);
1440
1548
 
1441
1549
  const parts = targetDeviceId.split(":");
1442
1550
  if (parts.length < 2) return;
@@ -1448,6 +1556,7 @@ export default function register(api: any) {
1448
1556
 
1449
1557
  const { data: theirProfile } = await innerSb.rpc("get_profile", { p_device_id: payload.new.device_id_a });
1450
1558
  const name = theirProfile?.display_name || "有人";
1559
+ const emoji = theirProfile?.emoji || "👤";
1451
1560
 
1452
1561
  // Check if mutual
1453
1562
  const { data: matches } = await innerSb.rpc("get_my_matches", { p_device_id: targetDeviceId });
@@ -1458,13 +1567,15 @@ export default function register(api: any) {
1458
1567
  if (myAccept) {
1459
1568
  const contact = payload.new.contact_info_a ? `\n对方的联系方式:${payload.new.contact_info_a}` : "";
1460
1569
  notifyUser(channel, userId,
1461
- `[Antenna] 🎉 双向匹配!${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1462
- logger, api);
1570
+ `[Antenna] 🎉 双向匹配!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1571
+ logger);
1572
+ _notifiedMatches.add(key);
1463
1573
  stopFollowUpCron(targetDeviceId, payload.new.device_id_a, logger);
1464
1574
  } else {
1465
1575
  notifyUser(channel, userId,
1466
- `[Antenna] 📩 ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
1467
- logger, api);
1576
+ `[Antenna] 📩 ${emoji} ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
1577
+ logger);
1578
+ _notifiedMatches.add(key);
1468
1579
  }
1469
1580
  } catch (err: any) {
1470
1581
  logger.warn("Antenna: realtime match handler error:", err.message);
@@ -1501,12 +1612,13 @@ export default function register(api: any) {
1501
1612
  // Get applicant profile
1502
1613
  const { data: applicant } = await epSb.rpc('get_profile', { p_device_id: applicantDeviceId });
1503
1614
  const aName = applicant?.display_name || '某人';
1615
+ const aEmoji = applicant?.emoji || '👤';
1504
1616
 
1505
1617
  const parts = event.created_by.split(':');
1506
1618
  if (parts.length < 2) return;
1507
1619
  notifyUser(parts[0], parts.slice(1).join(':'),
1508
- `[Antenna] 📩 ${aName} 申请加入你的活动「${event.name}」\n\n用 antenna_event_scan --code ${event.code} 查看申请者名片并审批。`,
1509
- logger, api);
1620
+ `[Antenna] 📩 ${aEmoji} ${aName} 申请加入你的活动「${event.name}」\n\n用 antenna_event_scan --code ${event.code} 查看申请者名片并审批。`,
1621
+ logger);
1510
1622
  } catch (err: any) {
1511
1623
  logger.warn('Antenna: event participant INSERT handler error:', err.message);
1512
1624
  }
@@ -1535,11 +1647,11 @@ export default function register(api: any) {
1535
1647
  if (newStatus === 'active') {
1536
1648
  notifyUser(parts[0], parts.slice(1).join(':'),
1537
1649
  `[Antenna] ✅ 你的申请已通过!欢迎加入「${eventName}」\n\n用 antenna_event_scan --code ${event?.code} 查看其他参与者。`,
1538
- logger, api);
1650
+ logger);
1539
1651
  } else if (newStatus === 'rejected') {
1540
1652
  notifyUser(parts[0], parts.slice(1).join(':'),
1541
1653
  `[Antenna] ❌ 你的申请未通过「${eventName}」的审核。`,
1542
- logger, api);
1654
+ logger);
1543
1655
  }
1544
1656
  } catch (err: any) {
1545
1657
  logger.warn('Antenna: event participant UPDATE handler error:', err.message);
@@ -1559,10 +1671,9 @@ export default function register(api: any) {
1559
1671
  const cfg = getConfig(api);
1560
1672
  const supabase = getSupabase(cfg);
1561
1673
 
1562
- // Get all profiles that have been active in last 24h
1674
+ // Get all profiles with valid notification targets
1563
1675
  const { data: activeProfiles } = await supabase
1564
- .rpc("nearby_profiles", { p_lat: 0, p_lng: 0, p_radius_m: 999999999 })
1565
- .select("device_id");
1676
+ .rpc("get_notification_targets", { p_since: "7 days" });
1566
1677
 
1567
1678
  if (!activeProfiles?.length) return;
1568
1679
 
@@ -1592,7 +1703,7 @@ export default function register(api: any) {
1592
1703
 
1593
1704
  for (const match of newMatches) {
1594
1705
  const notifyKey = `${match.device_id_a}→${match.device_id_b}`;
1595
- _notifiedMatches.add(notifyKey);
1706
+ if (_notifiedMatches.has(notifyKey)) continue;
1596
1707
 
1597
1708
  // Is this a new mutual match?
1598
1709
  if (match.device_id_a === deviceId) {
@@ -1600,36 +1711,38 @@ export default function register(api: any) {
1600
1711
  if (reverse) {
1601
1712
  const { data: theirProfile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_b });
1602
1713
  const name = theirProfile?.display_name || "对方";
1714
+ const emoji = theirProfile?.emoji || "👤";
1603
1715
  const contact = reverse.contact_info_a ? `\n对方的联系方式:${reverse.contact_info_a}` : "";
1604
1716
  notifyUser(
1605
1717
  channel, userId,
1606
- `[Antenna] 🎉 双向匹配成功!${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1607
- logger, api,
1718
+ `[Antenna] 🎉 双向匹配成功!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1719
+ logger,
1608
1720
  );
1609
- // Clean up follow-up crons
1721
+ _notifiedMatches.add(notifyKey);
1610
1722
  stopFollowUpCron(deviceId, match.device_id_b, logger);
1611
1723
  }
1612
1724
  } else if (match.device_id_b === deviceId) {
1613
1725
  // Someone new accepted me
1614
1726
  const { data: theirProfile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_a });
1615
1727
  const name = theirProfile?.display_name || "有人";
1728
+ const emoji = theirProfile?.emoji || "👤";
1616
1729
  const iAccepted = myMatches.find((m: any) => m.device_id_b === match.device_id_a);
1617
1730
  if (iAccepted) {
1618
- // I already accepted them → mutual!
1619
1731
  const contact = match.contact_info_a ? `\n对方的联系方式:${match.contact_info_a}` : "";
1620
1732
  notifyUser(
1621
1733
  channel, userId,
1622
- `[Antenna] 🎉 双向匹配成功!${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1623
- logger, api,
1734
+ `[Antenna] 🎉 双向匹配成功!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
1735
+ logger,
1624
1736
  );
1737
+ _notifiedMatches.add(notifyKey);
1625
1738
  stopFollowUpCron(deviceId, match.device_id_a, logger);
1626
1739
  } else {
1627
- // They accepted me but I haven't responded
1628
1740
  notifyUser(
1629
1741
  channel, userId,
1630
- `[Antenna] 📩 ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
1631
- logger, api,
1742
+ `[Antenna] 📩 ${emoji} ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
1743
+ logger,
1632
1744
  );
1745
+ _notifiedMatches.add(notifyKey);
1633
1746
  }
1634
1747
  }
1635
1748
  }
@@ -1640,8 +1753,9 @@ export default function register(api: any) {
1640
1753
  }
1641
1754
  }
1642
1755
 
1643
- // ── Event approval polling ──
1644
- for (const deviceId of _knownDeviceIds) {
1756
+ // ── Event approval polling (use notification targets, not _knownDeviceIds) ──
1757
+ for (const profile of activeProfiles) {
1758
+ const deviceId = profile.device_id;
1645
1759
  try {
1646
1760
  const { data: events } = await supabase.rpc("get_my_event_updates", { p_device_id: deviceId });
1647
1761
  if (!events?.length) continue;
@@ -1650,23 +1764,47 @@ export default function register(api: any) {
1650
1764
  const channel = parts[0];
1651
1765
  const userId = parts.slice(1).join(":");
1652
1766
  for (const ev of events) {
1653
- const key = `event:${ev.event_id}:${ev.status}`;
1767
+ const key = `event:${deviceId}:${ev.event_id}:${ev.status}`;
1654
1768
  if (_notifiedMatches.has(key)) continue;
1655
- _notifiedMatches.add(key);
1656
- if (ev.status === "active" && ev.role !== "creator" && ev.role !== "cohost") {
1769
+ if (ev.status === "active" && ev.role !== "creator" && ev.role !== "cohost" && ev.requires_approval) {
1657
1770
  notifyUser(channel, userId,
1658
1771
  `[Antenna] ✅ 你的申请已通过!欢迎加入「${ev.event_name}」`,
1659
- logger, api,
1772
+ logger,
1660
1773
  );
1774
+ _notifiedMatches.add(key);
1661
1775
  } else if (ev.status === "rejected") {
1662
1776
  notifyUser(channel, userId,
1663
1777
  `[Antenna] ❌ 你的申请未通过「${ev.event_name}」`,
1664
- logger, api,
1778
+ logger,
1665
1779
  );
1780
+ _notifiedMatches.add(key);
1666
1781
  }
1667
1782
  }
1668
1783
  } catch { /* silent */ }
1669
1784
  }
1785
+
1786
+ // ── Event messages polling ──
1787
+ for (const profile of activeProfiles) {
1788
+ const deviceId = profile.device_id;
1789
+ try {
1790
+ const { data: msgs } = await supabase.rpc("get_my_event_messages", { p_device_id: deviceId });
1791
+ if (!msgs?.length) continue;
1792
+ const parts = deviceId.split(":");
1793
+ if (parts.length < 2) continue;
1794
+ const channel = parts[0];
1795
+ const userId = parts.slice(1).join(":");
1796
+ for (const msg of msgs) {
1797
+ const key = `evtmsg:${msg.event_id}:${msg.created_at}`;
1798
+ if (_notifiedMatches.has(key)) continue;
1799
+ const role = msg.sender_role === 'creator' ? '组织者' : '协办';
1800
+ notifyUser(channel, userId,
1801
+ `[Antenna] 📢 来自「${msg.event_name}」${role} ${msg.sender_emoji || ''} ${msg.sender_name}: ${msg.message}`,
1802
+ logger,
1803
+ );
1804
+ _notifiedMatches.add(key);
1805
+ }
1806
+ } catch { /* silent */ }
1807
+ }
1670
1808
  } catch (err: any) {
1671
1809
  logger.warn("Antenna: match poll error:", err.message);
1672
1810
  }
@@ -1686,171 +1824,6 @@ export default function register(api: any) {
1686
1824
  },
1687
1825
  });
1688
1826
 
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
-
1854
1827
  // ═══════════════════════════════════════════════════════════════════
1855
1828
  // Hook: auto-scan when location is received
1856
1829
  // ═══════════════════════════════════════════════════════════════════