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.
- package/index.ts +243 -23
- 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
|
-
|
|
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
|
|
534
|
+
// Service: poll for new matches every 10 minutes → notify instantly
|
|
364
535
|
// ═══════════════════════════════════════════════════════════════════
|
|
365
|
-
const
|
|
536
|
+
const _notifiedMatches = new Set<string>(); // "deviceA→deviceB" 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
|
-
//
|
|
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
|
-
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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
|
|
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
|
|
657
|
+
if (lat == null || lon == null) return {};
|
|
438
658
|
|
|
439
659
|
const isLive = ctx?.LocationIsLive ?? false;
|
|
440
660
|
const locationName = ctx?.LocationName ?? "";
|
|
441
|
-
hint
|
|
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
|
|