antenna-openclaw-plugin 0.4.0 → 0.6.0

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.
Files changed (2) hide show
  1. package/index.ts +126 -25
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -119,6 +119,28 @@ function cronJobId(deviceA: string, deviceB: string): string {
119
119
  return `antenna-follow-${safe(deviceA)}-${safe(deviceB)}`;
120
120
  }
121
121
 
122
+ /** Send a real-time notification to a user via openclaw agent --deliver */
123
+ function notifyUser(
124
+ channel: string,
125
+ userId: string,
126
+ message: string,
127
+ logger: any,
128
+ ): void {
129
+ try {
130
+ execSync(
131
+ `openclaw agent` +
132
+ ` --message ${JSON.stringify(message)}` +
133
+ ` --deliver` +
134
+ ` --reply-channel ${channel}` +
135
+ ` --reply-to "${userId}"`,
136
+ { timeout: 30_000, encoding: "utf-8" },
137
+ );
138
+ logger.info(`Antenna: notified ${channel}:${userId}`);
139
+ } catch (err: any) {
140
+ logger.warn(`Antenna: notify failed for ${channel}:${userId}: ${err.message}`);
141
+ }
142
+ }
143
+
122
144
  function startFollowUpCron(
123
145
  deviceId: string,
124
146
  targetDeviceId: string,
@@ -142,9 +164,6 @@ function startFollowUpCron(
142
164
  `如果还没有 mutual,回复 HEARTBEAT_OK。`,
143
165
  ].join(" ");
144
166
 
145
- // Schedule: expire after 2h using --at for auto-cleanup
146
- const expiresAt = new Date(Date.now() + FOLLOW_UP_MAX_DURATION_MS).toISOString();
147
-
148
167
  try {
149
168
  // Create recurring 15-min job
150
169
  execSync(
@@ -429,6 +448,39 @@ export default function register(api: any) {
429
448
  },
430
449
  });
431
450
 
451
+ // ═══════════════════════════════════════════════════════════════════
452
+ // Tool: antenna_bind
453
+ // ═══════════════════════════════════════════════════════════════════
454
+ api.registerTool({
455
+ name: "antenna_bind",
456
+ description:
457
+ "Generate a GPS binding link. Send this URL to the user so they can share their phone's location via the web browser at antenna.fyi.",
458
+ parameters: {
459
+ type: "object",
460
+ properties: {
461
+ sender_id: { type: "string", description: "The sender's user ID" },
462
+ channel: { type: "string", description: "The channel name" },
463
+ },
464
+ required: ["sender_id", "channel"],
465
+ },
466
+ async execute(_id: string, params: any) {
467
+ const cfg = getConfig(api);
468
+ const supabase = getSupabase(cfg);
469
+ const deviceId = deriveDeviceId(params.sender_id, params.channel);
470
+
471
+ const { data, error } = await supabase.rpc("create_bind_token", { p_device_id: deviceId });
472
+ if (error) return ok({ error: error.message });
473
+
474
+ const token = data?.token;
475
+ const baseUrl = "https://www.antenna.fyi";
476
+ return ok({
477
+ token,
478
+ url: `${baseUrl}/locate?token=${token}`,
479
+ message: "发送这个链接给用户,在手机浏览器打开即可共享位置。",
480
+ });
481
+ },
482
+ });
483
+
432
484
  // ═══════════════════════════════════════════════════════════════════
433
485
  // Tool: antenna_check_matches
434
486
  // ═══════════════════════════════════════════════════════════════════
@@ -512,16 +564,16 @@ export default function register(api: any) {
512
564
  });
513
565
 
514
566
  // ═══════════════════════════════════════════════════════════════════
515
- // Service: poll for new mutual matches every 10 minutes
567
+ // Service: poll for new matches every 10 minutes → notify instantly
516
568
  // ═══════════════════════════════════════════════════════════════════
517
- const _pendingNotifications: Map<string, any[]> = new Map(); // deviceId new mutual matches
569
+ const _notifiedMatches = new Set<string>(); // "deviceAdeviceB" already notified
518
570
 
519
571
  let _pollTimer: ReturnType<typeof setInterval> | null = null;
520
572
 
521
573
  api.registerService({
522
574
  id: "antenna-match-poller",
523
575
  start: () => {
524
- logger.info("Antenna: match poller started (10 min interval)");
576
+ logger.info("Antenna: match poller started (10 min interval, real-time notify)");
525
577
  _pollTimer = setInterval(async () => {
526
578
  try {
527
579
  const cfg = getConfig(api);
@@ -539,15 +591,74 @@ export default function register(api: any) {
539
591
  const { data: matches } = await supabase.rpc("get_my_matches", { p_device_id: deviceId });
540
592
  if (!matches?.length) continue;
541
593
 
542
- // Check for matches created in last 10 min (new since last poll)
594
+ // Find new matches created in last 10 min
543
595
  const newMatches = matches.filter((m: any) => {
544
596
  const created = new Date(m.created_at).getTime();
545
- return Date.now() - created < 10 * 60 * 1000;
597
+ const key = `${m.device_id_a}→${m.device_id_b}`;
598
+ return Date.now() - created < 10 * 60 * 1000 && !_notifiedMatches.has(key);
546
599
  });
547
600
 
548
- if (newMatches.length > 0) {
549
- _pendingNotifications.set(deviceId, newMatches);
550
- logger.info(`Antenna: ${newMatches.length} new match(es) for ${deviceId}`);
601
+ if (newMatches.length === 0) continue;
602
+
603
+ // Parse channel and userId from device_id (format: "channel:userId")
604
+ const parts = deviceId.split(":");
605
+ if (parts.length < 2) continue;
606
+ const channel = parts[0];
607
+ const userId = parts.slice(1).join(":");
608
+
609
+ // Check for mutual matches
610
+ const myMatches = matches.filter((m: any) => m.device_id_a === deviceId);
611
+ const incomingMatches = matches.filter((m: any) => m.device_id_b === deviceId);
612
+
613
+ for (const match of newMatches) {
614
+ const notifyKey = `${match.device_id_a}→${match.device_id_b}`;
615
+ _notifiedMatches.add(notifyKey);
616
+
617
+ // Is this a new mutual match?
618
+ if (match.device_id_a === deviceId) {
619
+ const reverse = incomingMatches.find((m: any) => m.device_id_a === match.device_id_b);
620
+ if (reverse) {
621
+ const { data: theirProfile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_b });
622
+ const name = theirProfile?.display_name || "对方";
623
+ const emoji = theirProfile?.emoji || "👤";
624
+ const contact = reverse.contact_info_a ? `\n对方的联系方式:${reverse.contact_info_a}` : "";
625
+ notifyUser(
626
+ channel, userId,
627
+ `[Antenna] 🎉 双向匹配成功!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
628
+ logger,
629
+ );
630
+ // Clean up follow-up crons
631
+ stopFollowUpCron(deviceId, match.device_id_b, logger);
632
+ }
633
+ } else if (match.device_id_b === deviceId) {
634
+ // Someone new accepted me
635
+ const { data: theirProfile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_a });
636
+ const name = theirProfile?.display_name || "有人";
637
+ const emoji = theirProfile?.emoji || "👤";
638
+ const iAccepted = myMatches.find((m: any) => m.device_id_b === match.device_id_a);
639
+ if (iAccepted) {
640
+ // I already accepted them → mutual!
641
+ const contact = match.contact_info_a ? `\n对方的联系方式:${match.contact_info_a}` : "";
642
+ notifyUser(
643
+ channel, userId,
644
+ `[Antenna] 🎉 双向匹配成功!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
645
+ logger,
646
+ );
647
+ stopFollowUpCron(deviceId, match.device_id_a, logger);
648
+ } else {
649
+ // They accepted me but I haven't responded
650
+ notifyUser(
651
+ channel, userId,
652
+ `[Antenna] 📩 ${emoji} ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
653
+ logger,
654
+ );
655
+ }
656
+ }
657
+ }
658
+
659
+ // Prune old entries from _notifiedMatches (keep last 24h)
660
+ if (_notifiedMatches.size > 5000) {
661
+ _notifiedMatches.clear();
551
662
  }
552
663
  }
553
664
  } catch (err: any) {
@@ -562,7 +673,7 @@ export default function register(api: any) {
562
673
  });
563
674
 
564
675
  // ═══════════════════════════════════════════════════════════════════
565
- // Hook: auto-scan when location is received + inject match notifications
676
+ // Hook: auto-scan when location is received
566
677
  // ═══════════════════════════════════════════════════════════════════
567
678
  api.on(
568
679
  "before_prompt_build",
@@ -571,26 +682,16 @@ export default function register(api: any) {
571
682
  const cfg = getConfig(api);
572
683
  let hint = "";
573
684
 
574
- // --- Check for pending match notifications ---
575
- if (ctx?.senderId && ctx?.channel) {
576
- const deviceId = deriveDeviceId(ctx.senderId, ctx.channel);
577
- const pending = _pendingNotifications.get(deviceId);
578
- if (pending && pending.length > 0) {
579
- _pendingNotifications.delete(deviceId);
580
- hint += `\n\n[Antenna] 🎉 有 ${pending.length} 个新的匹配通知!请调用 antenna_check_matches 查看详情,并告诉用户有人想认识他们。`;
581
- }
582
- }
583
-
584
685
  // --- Auto-scan on location ---
585
- if (cfg.autoScanOnLocation === false) return hint ? { prependContext: hint } : {};
686
+ if (cfg.autoScanOnLocation === false) return {};
586
687
 
587
688
  const lat = ctx?.LocationLat;
588
689
  const lon = ctx?.LocationLon;
589
- if (lat == null || lon == null) return hint ? { prependContext: hint } : {};
690
+ if (lat == null || lon == null) return {};
590
691
 
591
692
  const isLive = ctx?.LocationIsLive ?? false;
592
693
  const locationName = ctx?.LocationName ?? "";
593
- hint += isLive
694
+ hint = isLive
594
695
  ? `\n\n[Antenna] 📡 收到实时位置 (${lat.toFixed(4)}, ${lon.toFixed(4)})${locationName ? ` — ${locationName}` : ""}。请使用 antenna_scan 工具查看附近有谁。参数:lat=${lat}, lng=${lon}, sender_id 和 channel 从消息上下文获取。`
595
696
  : `\n\n[Antenna] 📍 收到位置 (${lat.toFixed(4)}, ${lon.toFixed(4)})${locationName ? ` — ${locationName}` : ""}。请使用 antenna_scan 工具查看附近有谁。参数:lat=${lat}, lng=${lon}, sender_id 和 channel 从消息上下文获取。`;
596
697
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antenna-openclaw-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Antenna — agent-mediated nearby people discovery for OpenClaw",
5
5
  "openclaw": {
6
6
  "extensions": ["./index.ts"]