antenna-openclaw-plugin 0.3.2 → 0.4.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 +153 -1
  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,90 @@ 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
+ function startFollowUpCron(
123
+ deviceId: string,
124
+ targetDeviceId: string,
125
+ channel: string,
126
+ userId: string,
127
+ targetName: string,
128
+ logger: any,
129
+ ): void {
130
+ const jobId = cronJobId(deviceId, targetDeviceId);
131
+ const key = `${deviceId}→${targetDeviceId}`;
132
+
133
+ // Already tracking this pair
134
+ if (_activeFollowUps.has(key)) return;
135
+
136
+ const message = [
137
+ `[Antenna follow-up] 检查匹配状态:`,
138
+ `运行 antenna_check_matches(sender_id="${userId}", channel="${channel}")。`,
139
+ `如果 ${targetName}(${targetDeviceId})已经 mutual match,`,
140
+ `用 message tool 通知用户:「${targetName} 也接受了你的匹配!」并附上对方的联系方式(如果有)。`,
141
+ `然后运行:openclaw cron remove ${jobId}`,
142
+ `如果还没有 mutual,回复 HEARTBEAT_OK。`,
143
+ ].join(" ");
144
+
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
+ try {
149
+ // Create recurring 15-min job
150
+ execSync(
151
+ `openclaw cron add` +
152
+ ` --name "Antenna: 等待 ${targetName} 回应"` +
153
+ ` --every 15m` +
154
+ ` --session isolated` +
155
+ ` --message ${JSON.stringify(message)}` +
156
+ ` --announce` +
157
+ ` --channel ${channel}` +
158
+ ` --to "${userId}"`,
159
+ { timeout: 10_000, encoding: "utf-8" },
160
+ );
161
+
162
+ _activeFollowUps.set(key, jobId);
163
+ logger.info(`Antenna: follow-up cron created for ${key} (job: ${jobId})`);
164
+
165
+ // Schedule auto-cleanup after 2 hours
166
+ setTimeout(() => {
167
+ try {
168
+ execSync(`openclaw cron remove ${jobId}`, { timeout: 5_000 });
169
+ logger.info(`Antenna: follow-up expired for ${key}`);
170
+ } catch {
171
+ // Job may already be removed
172
+ }
173
+ _activeFollowUps.delete(key);
174
+ }, FOLLOW_UP_MAX_DURATION_MS);
175
+ } catch (err: any) {
176
+ logger.warn(`Antenna: failed to create follow-up cron: ${err.message}`);
177
+ }
178
+ }
179
+
180
+ function stopFollowUpCron(deviceA: string, deviceB: string, logger: any): void {
181
+ const key = `${deviceA}→${deviceB}`;
182
+ const jobId = _activeFollowUps.get(key);
183
+ if (!jobId) return;
184
+
185
+ try {
186
+ execSync(`openclaw cron remove ${jobId}`, { timeout: 5_000 });
187
+ logger.info(`Antenna: follow-up stopped for ${key}`);
188
+ } catch {
189
+ // Already removed
190
+ }
191
+ _activeFollowUps.delete(key);
192
+ }
193
+
109
194
  // ─── Plugin ──────────────────────────────────────────────────────────
110
195
 
111
196
  export default function register(api: any) {
@@ -233,6 +318,53 @@ export default function register(api: any) {
233
318
  },
234
319
  });
235
320
 
321
+ // ═══════════════════════════════════════════════════════════════════
322
+ // Tool: antenna_checkin
323
+ // ═══════════════════════════════════════════════════════════════════
324
+ api.registerTool({
325
+ name: "antenna_checkin",
326
+ description:
327
+ "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).",
328
+ parameters: {
329
+ type: "object",
330
+ properties: {
331
+ lat: { type: "number", description: "Latitude" },
332
+ lng: { type: "number", description: "Longitude" },
333
+ sender_id: { type: "string", description: "The sender's user ID" },
334
+ channel: { type: "string", description: "The channel name" },
335
+ place_name: { type: "string", description: "Optional: name of the place (for confirmation message)" },
336
+ },
337
+ required: ["lat", "lng", "sender_id", "channel"],
338
+ },
339
+ async execute(_id: string, params: any) {
340
+ const cfg = getConfig(api);
341
+ const supabase = getSupabase(cfg);
342
+ const deviceId = deriveDeviceId(params.sender_id, params.channel);
343
+ const fuzzy = fuzzyCoords(params.lat, params.lng);
344
+
345
+ // Check if user has a profile first
346
+ const { data: profile } = await supabase.rpc("get_profile", { p_device_id: deviceId });
347
+ if (!profile) {
348
+ return ok({
349
+ checked_in: false,
350
+ message: "你还没有名片,别人看到你也不知道你是谁。先创建一个名片吧(告诉我你的名字、emoji、三句话介绍自己)。",
351
+ });
352
+ }
353
+
354
+ const { error } = await supabase.rpc("upsert_profile_location", {
355
+ p_device_id: deviceId, p_lng: fuzzy.lng, p_lat: fuzzy.lat,
356
+ });
357
+
358
+ if (error) return ok({ error: error.message });
359
+
360
+ const place = params.place_name ? ` (${params.place_name})` : "";
361
+ return ok({
362
+ checked_in: true,
363
+ message: `已签到${place} 📍 现在附近的人扫描就能看到你了。`,
364
+ });
365
+ },
366
+ });
367
+
236
368
  // ═══════════════════════════════════════════════════════════════════
237
369
  // Tool: antenna_accept
238
370
  // ═══════════════════════════════════════════════════════════════════
@@ -268,6 +400,10 @@ export default function register(api: any) {
268
400
  );
269
401
 
270
402
  if (reverse) {
403
+ // Mutual match! Stop any follow-up cron for this pair
404
+ stopFollowUpCron(deviceId, params.target_device_id, logger);
405
+ stopFollowUpCron(params.target_device_id, deviceId, logger);
406
+
271
407
  return ok({
272
408
  accepted: true, mutual: true,
273
409
  their_contact: reverse.contact_info_a || null,
@@ -277,7 +413,19 @@ export default function register(api: any) {
277
413
  });
278
414
  }
279
415
 
280
- return ok({ accepted: true, mutual: false, message: "已接受。等对方也接受后,你们就可以交换联系方式了。" });
416
+ // Not mutual yet start a follow-up cron (check every 15min for 2h)
417
+ const { data: targetProfile } = await supabase.rpc("get_profile", { p_device_id: params.target_device_id });
418
+ const targetName = targetProfile?.display_name || "对方";
419
+
420
+ startFollowUpCron(
421
+ deviceId, params.target_device_id,
422
+ params.channel, params.sender_id, targetName, logger,
423
+ );
424
+
425
+ return ok({
426
+ accepted: true, mutual: false,
427
+ message: "已接受。我会在接下来 2 小时内每 15 分钟检查一次对方是否回应,有消息第一时间告诉你。",
428
+ });
281
429
  },
282
430
  });
283
431
 
@@ -319,6 +467,10 @@ export default function register(api: any) {
319
467
  (m: any) => m.device_id_a === match.device_id_b
320
468
  );
321
469
  if (reverse) {
470
+ // Clean up follow-up crons for this mutual pair
471
+ stopFollowUpCron(deviceId, match.device_id_b, logger);
472
+ stopFollowUpCron(match.device_id_b, deviceId, logger);
473
+
322
474
  const { data: profile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_b });
323
475
  mutualMatches.push({
324
476
  device_id: match.device_id_b,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antenna-openclaw-plugin",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Antenna — agent-mediated nearby people discovery for OpenClaw",
5
5
  "openclaw": {
6
6
  "extensions": ["./index.ts"]