antenna-openclaw-plugin 0.3.1 → 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.
- package/index.ts +153 -1
- package/package.json +1 -1
- package/skills/antenna/SKILL.md +7 -4
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
|
-
|
|
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
package/skills/antenna/SKILL.md
CHANGED
|
@@ -139,10 +139,13 @@ Check for mutual matches and contact info updates.
|
|
|
139
139
|
### Accepting & contact exchange
|
|
140
140
|
When the user wants to accept a match:
|
|
141
141
|
1. Call `antenna_accept` with the target's device_id
|
|
142
|
-
2.
|
|
143
|
-
3.
|
|
144
|
-
4.
|
|
145
|
-
5. If
|
|
142
|
+
2. **立刻问**:"想分享什么联系方式给对方?微信号、Telegram、手机号、Instagram……随便哪个都行"
|
|
143
|
+
3. 用户给了联系方式 → call `antenna_accept` again with `contact_info`
|
|
144
|
+
4. 用户不想分享 → "也行,先 accept 着,以后想分享再说"
|
|
145
|
+
5. If mutual match, tell the user the other person's contact info (if they shared)
|
|
146
|
+
6. If not mutual yet, tell the user: "已发出,等对方回应"
|
|
147
|
+
|
|
148
|
+
**不要跳过第 2 步。** 联系方式是最终目标——不然 accept 了也没用,两个人找不到对方。
|
|
146
149
|
|
|
147
150
|
### Checking match status
|
|
148
151
|
Use `antenna_check_matches` when:
|