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.
- package/index.ts +126 -25
- 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
|
|
567
|
+
// Service: poll for new matches every 10 minutes → notify instantly
|
|
516
568
|
// ═══════════════════════════════════════════════════════════════════
|
|
517
|
-
const
|
|
569
|
+
const _notifiedMatches = new Set<string>(); // "deviceA→deviceB" 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
|
-
//
|
|
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
|
-
|
|
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
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
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
|
|
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
|
|
690
|
+
if (lat == null || lon == null) return {};
|
|
590
691
|
|
|
591
692
|
const isLive = ctx?.LocationIsLive ?? false;
|
|
592
693
|
const locationName = ctx?.LocationName ?? "";
|
|
593
|
-
hint
|
|
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
|
|