antenna-openclaw-plugin 0.3.2 → 0.5.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 +243 -23
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createClient, SupabaseClient } from "@supabase/supabase-js";
2
+ import { execSync } from "child_process";
2
3
 
3
4
  // ─── Built-in Supabase config (shared backend, zero config) ─────────
4
5
 
@@ -106,6 +107,109 @@ function ok(data: any) {
106
107
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
107
108
  }
108
109
 
110
+ // ─── Cron helpers ────────────────────────────────────────────────────
111
+
112
+ const FOLLOW_UP_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
113
+ const FOLLOW_UP_MAX_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours
114
+ const _activeFollowUps = new Map<string, string>(); // "deviceA→deviceB" → cronJobId
115
+
116
+ function cronJobId(deviceA: string, deviceB: string): string {
117
+ // Stable, filesystem-safe id
118
+ const safe = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_");
119
+ return `antenna-follow-${safe(deviceA)}-${safe(deviceB)}`;
120
+ }
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
+
144
+ function startFollowUpCron(
145
+ deviceId: string,
146
+ targetDeviceId: string,
147
+ channel: string,
148
+ userId: string,
149
+ targetName: string,
150
+ logger: any,
151
+ ): void {
152
+ const jobId = cronJobId(deviceId, targetDeviceId);
153
+ const key = `${deviceId}→${targetDeviceId}`;
154
+
155
+ // Already tracking this pair
156
+ if (_activeFollowUps.has(key)) return;
157
+
158
+ const message = [
159
+ `[Antenna follow-up] 检查匹配状态:`,
160
+ `运行 antenna_check_matches(sender_id="${userId}", channel="${channel}")。`,
161
+ `如果 ${targetName}(${targetDeviceId})已经 mutual match,`,
162
+ `用 message tool 通知用户:「${targetName} 也接受了你的匹配!」并附上对方的联系方式(如果有)。`,
163
+ `然后运行:openclaw cron remove ${jobId}`,
164
+ `如果还没有 mutual,回复 HEARTBEAT_OK。`,
165
+ ].join(" ");
166
+
167
+ try {
168
+ // Create recurring 15-min job
169
+ execSync(
170
+ `openclaw cron add` +
171
+ ` --name "Antenna: 等待 ${targetName} 回应"` +
172
+ ` --every 15m` +
173
+ ` --session isolated` +
174
+ ` --message ${JSON.stringify(message)}` +
175
+ ` --announce` +
176
+ ` --channel ${channel}` +
177
+ ` --to "${userId}"`,
178
+ { timeout: 10_000, encoding: "utf-8" },
179
+ );
180
+
181
+ _activeFollowUps.set(key, jobId);
182
+ logger.info(`Antenna: follow-up cron created for ${key} (job: ${jobId})`);
183
+
184
+ // Schedule auto-cleanup after 2 hours
185
+ setTimeout(() => {
186
+ try {
187
+ execSync(`openclaw cron remove ${jobId}`, { timeout: 5_000 });
188
+ logger.info(`Antenna: follow-up expired for ${key}`);
189
+ } catch {
190
+ // Job may already be removed
191
+ }
192
+ _activeFollowUps.delete(key);
193
+ }, FOLLOW_UP_MAX_DURATION_MS);
194
+ } catch (err: any) {
195
+ logger.warn(`Antenna: failed to create follow-up cron: ${err.message}`);
196
+ }
197
+ }
198
+
199
+ function stopFollowUpCron(deviceA: string, deviceB: string, logger: any): void {
200
+ const key = `${deviceA}→${deviceB}`;
201
+ const jobId = _activeFollowUps.get(key);
202
+ if (!jobId) return;
203
+
204
+ try {
205
+ execSync(`openclaw cron remove ${jobId}`, { timeout: 5_000 });
206
+ logger.info(`Antenna: follow-up stopped for ${key}`);
207
+ } catch {
208
+ // Already removed
209
+ }
210
+ _activeFollowUps.delete(key);
211
+ }
212
+
109
213
  // ─── Plugin ──────────────────────────────────────────────────────────
110
214
 
111
215
  export default function register(api: any) {
@@ -233,6 +337,53 @@ export default function register(api: any) {
233
337
  },
234
338
  });
235
339
 
340
+ // ═══════════════════════════════════════════════════════════════════
341
+ // Tool: antenna_checkin
342
+ // ═══════════════════════════════════════════════════════════════════
343
+ api.registerTool({
344
+ name: "antenna_checkin",
345
+ description:
346
+ "Check in at a location — update your position so others can find you when they scan. Use when the user says 'I'm at XX' or wants to be discoverable without scanning others. Also works with place names (agent should geocode first).",
347
+ parameters: {
348
+ type: "object",
349
+ properties: {
350
+ lat: { type: "number", description: "Latitude" },
351
+ lng: { type: "number", description: "Longitude" },
352
+ sender_id: { type: "string", description: "The sender's user ID" },
353
+ channel: { type: "string", description: "The channel name" },
354
+ place_name: { type: "string", description: "Optional: name of the place (for confirmation message)" },
355
+ },
356
+ required: ["lat", "lng", "sender_id", "channel"],
357
+ },
358
+ async execute(_id: string, params: any) {
359
+ const cfg = getConfig(api);
360
+ const supabase = getSupabase(cfg);
361
+ const deviceId = deriveDeviceId(params.sender_id, params.channel);
362
+ const fuzzy = fuzzyCoords(params.lat, params.lng);
363
+
364
+ // Check if user has a profile first
365
+ const { data: profile } = await supabase.rpc("get_profile", { p_device_id: deviceId });
366
+ if (!profile) {
367
+ return ok({
368
+ checked_in: false,
369
+ message: "你还没有名片,别人看到你也不知道你是谁。先创建一个名片吧(告诉我你的名字、emoji、三句话介绍自己)。",
370
+ });
371
+ }
372
+
373
+ const { error } = await supabase.rpc("upsert_profile_location", {
374
+ p_device_id: deviceId, p_lng: fuzzy.lng, p_lat: fuzzy.lat,
375
+ });
376
+
377
+ if (error) return ok({ error: error.message });
378
+
379
+ const place = params.place_name ? ` (${params.place_name})` : "";
380
+ return ok({
381
+ checked_in: true,
382
+ message: `已签到${place} 📍 现在附近的人扫描就能看到你了。`,
383
+ });
384
+ },
385
+ });
386
+
236
387
  // ═══════════════════════════════════════════════════════════════════
237
388
  // Tool: antenna_accept
238
389
  // ═══════════════════════════════════════════════════════════════════
@@ -268,6 +419,10 @@ export default function register(api: any) {
268
419
  );
269
420
 
270
421
  if (reverse) {
422
+ // Mutual match! Stop any follow-up cron for this pair
423
+ stopFollowUpCron(deviceId, params.target_device_id, logger);
424
+ stopFollowUpCron(params.target_device_id, deviceId, logger);
425
+
271
426
  return ok({
272
427
  accepted: true, mutual: true,
273
428
  their_contact: reverse.contact_info_a || null,
@@ -277,7 +432,19 @@ export default function register(api: any) {
277
432
  });
278
433
  }
279
434
 
280
- return ok({ accepted: true, mutual: false, message: "已接受。等对方也接受后,你们就可以交换联系方式了。" });
435
+ // Not mutual yet start a follow-up cron (check every 15min for 2h)
436
+ const { data: targetProfile } = await supabase.rpc("get_profile", { p_device_id: params.target_device_id });
437
+ const targetName = targetProfile?.display_name || "对方";
438
+
439
+ startFollowUpCron(
440
+ deviceId, params.target_device_id,
441
+ params.channel, params.sender_id, targetName, logger,
442
+ );
443
+
444
+ return ok({
445
+ accepted: true, mutual: false,
446
+ message: "已接受。我会在接下来 2 小时内每 15 分钟检查一次对方是否回应,有消息第一时间告诉你。",
447
+ });
281
448
  },
282
449
  });
283
450
 
@@ -319,6 +486,10 @@ export default function register(api: any) {
319
486
  (m: any) => m.device_id_a === match.device_id_b
320
487
  );
321
488
  if (reverse) {
489
+ // Clean up follow-up crons for this mutual pair
490
+ stopFollowUpCron(deviceId, match.device_id_b, logger);
491
+ stopFollowUpCron(match.device_id_b, deviceId, logger);
492
+
322
493
  const { data: profile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_b });
323
494
  mutualMatches.push({
324
495
  device_id: match.device_id_b,
@@ -360,16 +531,16 @@ export default function register(api: any) {
360
531
  });
361
532
 
362
533
  // ═══════════════════════════════════════════════════════════════════
363
- // Service: poll for new mutual matches every 10 minutes
534
+ // Service: poll for new matches every 10 minutes → notify instantly
364
535
  // ═══════════════════════════════════════════════════════════════════
365
- const _pendingNotifications: Map<string, any[]> = new Map(); // deviceId new mutual matches
536
+ const _notifiedMatches = new Set<string>(); // "deviceAdeviceB" already notified
366
537
 
367
538
  let _pollTimer: ReturnType<typeof setInterval> | null = null;
368
539
 
369
540
  api.registerService({
370
541
  id: "antenna-match-poller",
371
542
  start: () => {
372
- logger.info("Antenna: match poller started (10 min interval)");
543
+ logger.info("Antenna: match poller started (10 min interval, real-time notify)");
373
544
  _pollTimer = setInterval(async () => {
374
545
  try {
375
546
  const cfg = getConfig(api);
@@ -387,15 +558,74 @@ export default function register(api: any) {
387
558
  const { data: matches } = await supabase.rpc("get_my_matches", { p_device_id: deviceId });
388
559
  if (!matches?.length) continue;
389
560
 
390
- // Check for matches created in last 10 min (new since last poll)
561
+ // Find new matches created in last 10 min
391
562
  const newMatches = matches.filter((m: any) => {
392
563
  const created = new Date(m.created_at).getTime();
393
- return Date.now() - created < 10 * 60 * 1000;
564
+ const key = `${m.device_id_a}→${m.device_id_b}`;
565
+ return Date.now() - created < 10 * 60 * 1000 && !_notifiedMatches.has(key);
394
566
  });
395
567
 
396
- if (newMatches.length > 0) {
397
- _pendingNotifications.set(deviceId, newMatches);
398
- logger.info(`Antenna: ${newMatches.length} new match(es) for ${deviceId}`);
568
+ if (newMatches.length === 0) continue;
569
+
570
+ // Parse channel and userId from device_id (format: "channel:userId")
571
+ const parts = deviceId.split(":");
572
+ if (parts.length < 2) continue;
573
+ const channel = parts[0];
574
+ const userId = parts.slice(1).join(":");
575
+
576
+ // Check for mutual matches
577
+ const myMatches = matches.filter((m: any) => m.device_id_a === deviceId);
578
+ const incomingMatches = matches.filter((m: any) => m.device_id_b === deviceId);
579
+
580
+ for (const match of newMatches) {
581
+ const notifyKey = `${match.device_id_a}→${match.device_id_b}`;
582
+ _notifiedMatches.add(notifyKey);
583
+
584
+ // Is this a new mutual match?
585
+ if (match.device_id_a === deviceId) {
586
+ const reverse = incomingMatches.find((m: any) => m.device_id_a === match.device_id_b);
587
+ if (reverse) {
588
+ const { data: theirProfile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_b });
589
+ const name = theirProfile?.display_name || "对方";
590
+ const emoji = theirProfile?.emoji || "👤";
591
+ const contact = reverse.contact_info_a ? `\n对方的联系方式:${reverse.contact_info_a}` : "";
592
+ notifyUser(
593
+ channel, userId,
594
+ `[Antenna] 🎉 双向匹配成功!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
595
+ logger,
596
+ );
597
+ // Clean up follow-up crons
598
+ stopFollowUpCron(deviceId, match.device_id_b, logger);
599
+ }
600
+ } else if (match.device_id_b === deviceId) {
601
+ // Someone new accepted me
602
+ const { data: theirProfile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_a });
603
+ const name = theirProfile?.display_name || "有人";
604
+ const emoji = theirProfile?.emoji || "👤";
605
+ const iAccepted = myMatches.find((m: any) => m.device_id_b === match.device_id_a);
606
+ if (iAccepted) {
607
+ // I already accepted them → mutual!
608
+ const contact = match.contact_info_a ? `\n对方的联系方式:${match.contact_info_a}` : "";
609
+ notifyUser(
610
+ channel, userId,
611
+ `[Antenna] 🎉 双向匹配成功!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
612
+ logger,
613
+ );
614
+ stopFollowUpCron(deviceId, match.device_id_a, logger);
615
+ } else {
616
+ // They accepted me but I haven't responded
617
+ notifyUser(
618
+ channel, userId,
619
+ `[Antenna] 📩 ${emoji} ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
620
+ logger,
621
+ );
622
+ }
623
+ }
624
+ }
625
+
626
+ // Prune old entries from _notifiedMatches (keep last 24h)
627
+ if (_notifiedMatches.size > 5000) {
628
+ _notifiedMatches.clear();
399
629
  }
400
630
  }
401
631
  } catch (err: any) {
@@ -410,7 +640,7 @@ export default function register(api: any) {
410
640
  });
411
641
 
412
642
  // ═══════════════════════════════════════════════════════════════════
413
- // Hook: auto-scan when location is received + inject match notifications
643
+ // Hook: auto-scan when location is received
414
644
  // ═══════════════════════════════════════════════════════════════════
415
645
  api.on(
416
646
  "before_prompt_build",
@@ -419,26 +649,16 @@ export default function register(api: any) {
419
649
  const cfg = getConfig(api);
420
650
  let hint = "";
421
651
 
422
- // --- Check for pending match notifications ---
423
- if (ctx?.senderId && ctx?.channel) {
424
- const deviceId = deriveDeviceId(ctx.senderId, ctx.channel);
425
- const pending = _pendingNotifications.get(deviceId);
426
- if (pending && pending.length > 0) {
427
- _pendingNotifications.delete(deviceId);
428
- hint += `\n\n[Antenna] 🎉 有 ${pending.length} 个新的匹配通知!请调用 antenna_check_matches 查看详情,并告诉用户有人想认识他们。`;
429
- }
430
- }
431
-
432
652
  // --- Auto-scan on location ---
433
- if (cfg.autoScanOnLocation === false) return hint ? { prependContext: hint } : {};
653
+ if (cfg.autoScanOnLocation === false) return {};
434
654
 
435
655
  const lat = ctx?.LocationLat;
436
656
  const lon = ctx?.LocationLon;
437
- if (lat == null || lon == null) return hint ? { prependContext: hint } : {};
657
+ if (lat == null || lon == null) return {};
438
658
 
439
659
  const isLive = ctx?.LocationIsLive ?? false;
440
660
  const locationName = ctx?.LocationName ?? "";
441
- hint += isLive
661
+ hint = isLive
442
662
  ? `\n\n[Antenna] 📡 收到实时位置 (${lat.toFixed(4)}, ${lon.toFixed(4)})${locationName ? ` — ${locationName}` : ""}。请使用 antenna_scan 工具查看附近有谁。参数:lat=${lat}, lng=${lon}, sender_id 和 channel 从消息上下文获取。`
443
663
  : `\n\n[Antenna] 📍 收到位置 (${lat.toFixed(4)}, ${lon.toFixed(4)})${locationName ? ` — ${locationName}` : ""}。请使用 antenna_scan 工具查看附近有谁。参数:lat=${lat}, lng=${lon}, sender_id 和 channel 从消息上下文获取。`;
444
664
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antenna-openclaw-plugin",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "Antenna — agent-mediated nearby people discovery for OpenClaw",
5
5
  "openclaw": {
6
6
  "extensions": ["./index.ts"]