antenna-openclaw-plugin 0.1.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/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # Antenna — OpenClaw Plugin
2
+
3
+ Agent-mediated nearby people discovery. Your agent helps you find and connect with people around you.
4
+
5
+ ## How it works
6
+
7
+ 1. Share your location in Telegram or WhatsApp (or tell your agent where you are)
8
+ 2. Agent scans for nearby people via Supabase + PostGIS
9
+ 3. Agent shows you matches with reasons why you might click
10
+ 4. Accept a match → if mutual, agents facilitate introductions
11
+ 5. Everything expires in 24 hours
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ # From the plugin directory
17
+ openclaw plugins install -l ./plugin
18
+
19
+ # Or copy to extensions
20
+ cp -r ./plugin ~/.openclaw/extensions/antenna
21
+ ```
22
+
23
+ ## Configure
24
+
25
+ Add to `~/.openclaw/openclaw.json`:
26
+
27
+ ```json5
28
+ {
29
+ plugins: {
30
+ entries: {
31
+ antenna: {
32
+ enabled: true,
33
+ config: {
34
+ supabaseUrl: "https://your-project.supabase.co",
35
+ supabaseKey: "your-service-role-key", // NOT the anon key
36
+ defaultRadiusM: 500,
37
+ matchExpiryHours: 24,
38
+ maxMatches: 5,
39
+ autoScanOnLocation: true
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ Then restart the gateway:
48
+
49
+ ```bash
50
+ openclaw gateway restart
51
+ ```
52
+
53
+ ## Supabase setup
54
+
55
+ Run the migrations in order:
56
+
57
+ ```bash
58
+ cd supabase
59
+ supabase db push
60
+ ```
61
+
62
+ Or manually apply:
63
+ 1. `migrations/20260325060000_upgrade_schema.sql` — PostGIS + profiles + nearby_profiles()
64
+ 2. `migrations/20260325140000_rls_and_cron.sql` — RLS + auto-cleanup
65
+ 3. `migrations/20260330200000_plugin_constraints.sql` — unique constraints for upsert
66
+
67
+ ## Architecture
68
+
69
+ ```
70
+ Plugin (index.ts)
71
+ ├── antenna_scan tool — query nearby people
72
+ ├── antenna_profile tool — view/update name card
73
+ ├── antenna_accept tool — accept a match
74
+ └── before_prompt_build — auto-inject scan hint on location messages
75
+
76
+ Skill (SKILL.md)
77
+ └── teaches agent how to present results, guide profile setup, handle matches
78
+
79
+ Supabase
80
+ ├── profiles table — name cards + GPS (PostGIS geography)
81
+ ├── matches table — match results (24h expiry)
82
+ ├── nearby_profiles() — PostGIS spatial query
83
+ └── pg_cron cleanup — hourly expired match cleanup
84
+ ```
85
+
86
+ ## Supported platforms
87
+
88
+ Location auto-detection (OpenClaw parses the coordinates):
89
+ - ✅ Telegram (live + static)
90
+ - ✅ WhatsApp (live + static)
91
+ - ✅ Matrix (static)
92
+
93
+ Manual location (user tells agent where they are):
94
+ - ✅ Any platform (Discord, Slack, Signal, iMessage, etc.)
package/index.ts ADDED
@@ -0,0 +1,576 @@
1
+ import { createClient, SupabaseClient } from "@supabase/supabase-js";
2
+
3
+ // ─── Built-in Supabase config (shared backend, zero config) ─────────
4
+
5
+ const BUILTIN_SUPABASE_URL = "https://bcudjloikmpcqwcptuyd.supabase.co";
6
+ const BUILTIN_SUPABASE_ANON_KEY =
7
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJjdWRqbG9pa21wY3F3Y3B0dXlkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzQ0MTg1NDgsImV4cCI6MjA4OTk5NDU0OH0.FaoC3QfpfHP1npNGjRchJAoAp2PdZtQe_WhP-t-GN1o";
8
+
9
+ // ─── Types ───────────────────────────────────────────────────────────
10
+
11
+ interface AntennaConfig {
12
+ supabaseUrl?: string;
13
+ supabaseKey?: string;
14
+ defaultRadiusM?: number;
15
+ matchExpiryHours?: number;
16
+ maxMatches?: number;
17
+ autoScanOnLocation?: boolean;
18
+ }
19
+
20
+ interface Profile {
21
+ id?: string;
22
+ device_id: string;
23
+ display_name: string | null;
24
+ line1: string | null;
25
+ line2: string | null;
26
+ line3: string | null;
27
+ emoji: string | null;
28
+ visible: boolean;
29
+ last_seen_at?: string;
30
+ }
31
+
32
+ interface MatchResult {
33
+ device_id: string;
34
+ display_name: string | null;
35
+ emoji: string | null;
36
+ line1: string | null;
37
+ line2: string | null;
38
+ line3: string | null;
39
+ score: number;
40
+ reason: string;
41
+ }
42
+
43
+ // ─── Helpers ─────────────────────────────────────────────────────────
44
+
45
+ // Cached Supabase client (singleton per config)
46
+ let _supabaseClient: SupabaseClient | null = null;
47
+ let _supabaseUrl: string | null = null;
48
+
49
+ // Rate limiting: track last scan time per device_id
50
+ const _lastScanTime = new Map<string, number>();
51
+ const SCAN_DEBOUNCE_MS = 30_000; // 30 seconds
52
+
53
+ function getConfig(api: any): AntennaConfig {
54
+ const cfg = api.config?.plugins?.entries?.antenna?.config ?? {};
55
+ return {
56
+ supabaseUrl: cfg.supabaseUrl || BUILTIN_SUPABASE_URL,
57
+ supabaseKey: cfg.supabaseKey || BUILTIN_SUPABASE_ANON_KEY,
58
+ defaultRadiusM: cfg.defaultRadiusM ?? 500,
59
+ matchExpiryHours: cfg.matchExpiryHours ?? 24,
60
+ maxMatches: cfg.maxMatches ?? 5,
61
+ autoScanOnLocation: cfg.autoScanOnLocation ?? true,
62
+ };
63
+ }
64
+
65
+ function getSupabase(cfg: AntennaConfig): SupabaseClient {
66
+ const url = cfg.supabaseUrl!;
67
+ if (_supabaseClient && _supabaseUrl === url) {
68
+ return _supabaseClient;
69
+ }
70
+ _supabaseClient = createClient(url, cfg.supabaseKey!);
71
+ _supabaseUrl = url;
72
+ return _supabaseClient;
73
+ }
74
+
75
+ function isRateLimited(deviceId: string): boolean {
76
+ const now = Date.now();
77
+ const last = _lastScanTime.get(deviceId);
78
+ if (last && now - last < SCAN_DEBOUNCE_MS) {
79
+ return true;
80
+ }
81
+ _lastScanTime.set(deviceId, now);
82
+ if (_lastScanTime.size > 1000) {
83
+ for (const [k, v] of _lastScanTime) {
84
+ if (now - v > SCAN_DEBOUNCE_MS * 2) _lastScanTime.delete(k);
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * Snap coordinates to ~150m precision (geohash-like rounding).
92
+ * lat: round to 3 decimal places (~111m)
93
+ * lng: round to 3 decimal places (~85-111m depending on latitude)
94
+ */
95
+ function fuzzyCoords(lat: number, lng: number): { lat: number; lng: number } {
96
+ return {
97
+ lat: Math.round(lat * 1000) / 1000,
98
+ lng: Math.round(lng * 1000) / 1000,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Extract keywords from profile lines.
104
+ * TODO: Replace with LLM-based matching for better Chinese support.
105
+ * Current approach: split on punctuation/whitespace, keep tokens > 1 char.
106
+ * Works OK for English and simple Chinese phrases, but can't handle
107
+ * semantic similarity (e.g. "跑步" vs "慢跑").
108
+ */
109
+ function extractWords(profile: Partial<Profile>): string[] {
110
+ const text = [profile.line1, profile.line2, profile.line3]
111
+ .filter(Boolean)
112
+ .join(" ")
113
+ .toLowerCase();
114
+ return text
115
+ .split(/[\s,,。.!!??、;;::]+/)
116
+ .filter((w) => w.length > 1);
117
+ }
118
+
119
+ /**
120
+ * Generate a stable device_id from senderId + channel.
121
+ * This maps a chat user to a unique Antenna identity.
122
+ */
123
+ function deriveDeviceId(senderId: string, channel: string): string {
124
+ return `${channel}:${senderId}`;
125
+ }
126
+
127
+ // ─── Plugin ──────────────────────────────────────────────────────────
128
+
129
+ export default function register(api: any) {
130
+ const logger = api.logger;
131
+
132
+ // ═══════════════════════════════════════════════════════════════════
133
+ // Tool: antenna_scan — scan nearby people from a location
134
+ // ═══════════════════════════════════════════════════════════════════
135
+ api.registerTool({
136
+ name: "antenna_scan",
137
+ description:
138
+ "Scan for nearby people at a given location. Returns matched profiles with reasons. Use when the user shares their location or asks 'who is nearby'.",
139
+ parameters: {
140
+ type: "object",
141
+ properties: {
142
+ lat: { type: "number", description: "Latitude" },
143
+ lng: { type: "number", description: "Longitude" },
144
+ radius_m: {
145
+ type: "number",
146
+ description: "Search radius in meters (default: 500)",
147
+ },
148
+ sender_id: {
149
+ type: "string",
150
+ description: "The sender's user ID (from message context)",
151
+ },
152
+ channel: {
153
+ type: "string",
154
+ description: "The channel name (telegram, whatsapp, etc.)",
155
+ },
156
+ },
157
+ required: ["lat", "lng", "sender_id", "channel"],
158
+ },
159
+ handler: async (params: {
160
+ lat: number;
161
+ lng: number;
162
+ radius_m?: number;
163
+ sender_id: string;
164
+ channel: string;
165
+ }) => {
166
+ const cfg = getConfig(api);
167
+ const supabase = getSupabase(cfg);
168
+ const deviceId = deriveDeviceId(params.sender_id, params.channel);
169
+ const radius = params.radius_m ?? cfg.defaultRadiusM ?? 500;
170
+ const maxMatches = cfg.maxMatches ?? 5;
171
+
172
+ // Rate limit: skip if scanned less than 30s ago
173
+ if (isRateLimited(deviceId)) {
174
+ return {
175
+ matches: [],
176
+ message: "刚刚才扫描过,稍等一会儿再试。",
177
+ rate_limited: true,
178
+ };
179
+ }
180
+
181
+ // Fuzzy coordinates for privacy (~150m precision)
182
+ const fuzzy = fuzzyCoords(params.lat, params.lng);
183
+
184
+ // Update my location using PostGIS RPC
185
+ const { error: upsertErr } = await supabase.rpc(
186
+ "upsert_profile_location",
187
+ {
188
+ p_device_id: deviceId,
189
+ p_lng: fuzzy.lng,
190
+ p_lat: fuzzy.lat,
191
+ }
192
+ );
193
+
194
+ if (upsertErr) {
195
+ logger.warn("Antenna: upsert_profile_location failed:", upsertErr.message);
196
+ // Fallback: upsert without location
197
+ await supabase.from("profiles").upsert(
198
+ {
199
+ device_id: deviceId,
200
+ last_seen_at: new Date().toISOString(),
201
+ visible: true,
202
+ },
203
+ { onConflict: "device_id" }
204
+ );
205
+ }
206
+
207
+ // Query nearby (use original coords for better accuracy in query)
208
+ const { data: nearby, error } = await supabase.rpc("nearby_profiles", {
209
+ p_lat: fuzzy.lat,
210
+ p_lng: fuzzy.lng,
211
+ p_radius_m: radius,
212
+ });
213
+
214
+ if (error) {
215
+ return { error: error.message };
216
+ }
217
+
218
+ const others = (nearby ?? []).filter(
219
+ (p: Profile) => p.device_id !== deviceId
220
+ );
221
+
222
+ if (others.length === 0) {
223
+ return {
224
+ matches: [],
225
+ message: `在 ${radius}m 范围内没有发现其他人。试试扩大范围?`,
226
+ };
227
+ }
228
+
229
+ // Get my profile for matching via RPC
230
+ const { data: myProfile } = await supabase.rpc("get_profile", {
231
+ p_device_id: deviceId,
232
+ });
233
+
234
+ // Score matches
235
+ const myWords = myProfile ? extractWords(myProfile) : [];
236
+ const scored: MatchResult[] = others.map((p: Profile) => {
237
+ const theirWords = extractWords(p);
238
+ const overlap = myWords.filter((w: string) => theirWords.includes(w));
239
+ const score =
240
+ myWords.length > 0
241
+ ? Math.min(overlap.length / myWords.length, 1)
242
+ : 0;
243
+ const reason =
244
+ overlap.length > 0
245
+ ? `你们都提到了 ${overlap.slice(0, 3).join("、")}——可能聊得来`
246
+ : `${p.display_name || p.emoji || "TA"} 就在附近`;
247
+ return {
248
+ device_id: p.device_id,
249
+ display_name: p.display_name,
250
+ emoji: p.emoji,
251
+ line1: p.line1,
252
+ line2: p.line2,
253
+ line3: p.line3,
254
+ score,
255
+ reason,
256
+ };
257
+ });
258
+
259
+ scored.sort((a, b) => b.score - a.score);
260
+ const topMatches = scored.slice(0, maxMatches);
261
+
262
+ // Store matches
263
+ const expiryHours = cfg.matchExpiryHours ?? 24;
264
+
265
+ // Store matches via RPC (SECURITY DEFINER, works with anon key)
266
+ for (const m of topMatches) {
267
+ await supabase.rpc("upsert_match", {
268
+ p_device_id_a: deviceId,
269
+ p_device_id_b: m.device_id,
270
+ p_reason: m.reason,
271
+ p_score: m.score,
272
+ p_status: "pending",
273
+ p_expires_hours: expiryHours,
274
+ });
275
+ }
276
+
277
+ return {
278
+ matches: topMatches.map((m) => ({
279
+ emoji: m.emoji || "👤",
280
+ name: m.display_name || "匿名",
281
+ line1: m.line1,
282
+ line2: m.line2,
283
+ line3: m.line3,
284
+ score: m.score,
285
+ reason: m.reason,
286
+ })),
287
+ total_nearby: others.length,
288
+ radius_m: radius,
289
+ };
290
+ },
291
+ });
292
+
293
+ // ═══════════════════════════════════════════════════════════════════
294
+ // Tool: antenna_profile — view or update my profile (name card)
295
+ // ═══════════════════════════════════════════════════════════════════
296
+ api.registerTool({
297
+ name: "antenna_profile",
298
+ description:
299
+ "View or update the user's Antenna profile (name card). The profile has a display name, emoji, and three lines describing who they are.",
300
+ parameters: {
301
+ type: "object",
302
+ properties: {
303
+ action: {
304
+ type: "string",
305
+ enum: ["get", "set"],
306
+ description: "'get' to view profile, 'set' to update it",
307
+ },
308
+ sender_id: { type: "string", description: "The sender's user ID" },
309
+ channel: { type: "string", description: "The channel name" },
310
+ display_name: { type: "string", description: "Display name" },
311
+ emoji: { type: "string", description: "Profile emoji" },
312
+ line1: {
313
+ type: "string",
314
+ description: "First line (who you are / what you do)",
315
+ },
316
+ line2: {
317
+ type: "string",
318
+ description: "Second line (what you're into)",
319
+ },
320
+ line3: {
321
+ type: "string",
322
+ description: "Third line (what you're looking for)",
323
+ },
324
+ visible: {
325
+ type: "boolean",
326
+ description: "Whether to be visible to others",
327
+ },
328
+ },
329
+ required: ["action", "sender_id", "channel"],
330
+ },
331
+ handler: async (params: {
332
+ action: string;
333
+ sender_id: string;
334
+ channel: string;
335
+ display_name?: string;
336
+ emoji?: string;
337
+ line1?: string;
338
+ line2?: string;
339
+ line3?: string;
340
+ visible?: boolean;
341
+ }) => {
342
+ const cfg = getConfig(api);
343
+ const supabase = getSupabase(cfg);
344
+ const deviceId = deriveDeviceId(params.sender_id, params.channel);
345
+
346
+ if (params.action === "get") {
347
+ const { data, error } = await supabase.rpc("get_profile", {
348
+ p_device_id: deviceId,
349
+ });
350
+
351
+ if (error || !data) {
352
+ return {
353
+ exists: false,
354
+ message:
355
+ "你还没有名片。告诉我你的名字、一个 emoji、和三句话介绍自己,我帮你创建。",
356
+ };
357
+ }
358
+
359
+ return {
360
+ exists: true,
361
+ profile: {
362
+ display_name: data.display_name,
363
+ emoji: data.emoji,
364
+ line1: data.line1,
365
+ line2: data.line2,
366
+ line3: data.line3,
367
+ visible: data.visible,
368
+ },
369
+ };
370
+ }
371
+
372
+ // action === 'set' — use RPC for write (SECURITY DEFINER)
373
+ const { data, error } = await supabase.rpc("upsert_profile", {
374
+ p_device_id: deviceId,
375
+ p_display_name: params.display_name ?? null,
376
+ p_emoji: params.emoji ?? null,
377
+ p_line1: params.line1 ?? null,
378
+ p_line2: params.line2 ?? null,
379
+ p_line3: params.line3 ?? null,
380
+ p_visible: params.visible ?? true,
381
+ });
382
+
383
+ if (error) {
384
+ return { error: error.message };
385
+ }
386
+
387
+ return {
388
+ updated: true,
389
+ profile: {
390
+ display_name: data.display_name,
391
+ emoji: data.emoji,
392
+ line1: data.line1,
393
+ line2: data.line2,
394
+ line3: data.line3,
395
+ visible: data.visible,
396
+ },
397
+ };
398
+ },
399
+ });
400
+
401
+ // ═══════════════════════════════════════════════════════════════════
402
+ // Tool: antenna_accept — accept a match and optionally share contact
403
+ // ═══════════════════════════════════════════════════════════════════
404
+ api.registerTool({
405
+ name: "antenna_accept",
406
+ description:
407
+ "Accept a match. Optionally share contact info (WeChat, Telegram, phone, etc). If both sides accept, they can exchange contact info through their agents.",
408
+ parameters: {
409
+ type: "object",
410
+ properties: {
411
+ sender_id: { type: "string" },
412
+ channel: { type: "string" },
413
+ target_device_id: {
414
+ type: "string",
415
+ description: "The device_id of the person to accept",
416
+ },
417
+ contact_info: {
418
+ type: "string",
419
+ description:
420
+ "Optional contact info to share (e.g. 'WeChat: yi_xxx' or 'Telegram: @yi')",
421
+ },
422
+ },
423
+ required: ["sender_id", "channel", "target_device_id"],
424
+ },
425
+ handler: async (params: {
426
+ sender_id: string;
427
+ channel: string;
428
+ target_device_id: string;
429
+ contact_info?: string;
430
+ }) => {
431
+ const cfg = getConfig(api);
432
+ const supabase = getSupabase(cfg);
433
+ const deviceId = deriveDeviceId(params.sender_id, params.channel);
434
+
435
+ // Update match status + optional contact info via RPC
436
+ const { error } = await supabase.rpc("upsert_match", {
437
+ p_device_id_a: deviceId,
438
+ p_device_id_b: params.target_device_id,
439
+ p_status: "accepted",
440
+ p_contact_info: params.contact_info ?? null,
441
+ });
442
+
443
+ if (error) {
444
+ return { error: error.message };
445
+ }
446
+
447
+ // Check if mutual match via RPC
448
+ const { data: myMatches } = await supabase.rpc("get_my_matches", {
449
+ p_device_id: deviceId,
450
+ });
451
+
452
+ const reverse = (myMatches || []).find(
453
+ (m: any) => m.device_id_a === params.target_device_id && m.device_id_b === deviceId
454
+ );
455
+
456
+ if (reverse) {
457
+ // Mutual match! Return the other person's contact info if they shared it
458
+ return {
459
+ accepted: true,
460
+ mutual: true,
461
+ their_contact: reverse.contact_info_a || null,
462
+ message: reverse.contact_info_a
463
+ ? `双方都接受了!对方分享的联系方式:${reverse.contact_info_a}`
464
+ : "双方都接受了!但对方还没有分享联系方式,等 TA 分享后会通知你。",
465
+ };
466
+ }
467
+
468
+ return {
469
+ accepted: true,
470
+ mutual: false,
471
+ message: "已接受。等对方也接受后,你们就可以交换联系方式了。",
472
+ };
473
+ },
474
+ });
475
+
476
+ // ═══════════════════════════════════════════════════════════════════
477
+ // Tool: antenna_check_matches — check for mutual matches / new contact info
478
+ // ═══════════════════════════════════════════════════════════════════
479
+ api.registerTool({
480
+ name: "antenna_check_matches",
481
+ description:
482
+ "Check for any mutual matches or new contact info shared by matched people. Use periodically or when the user asks about match status.",
483
+ parameters: {
484
+ type: "object",
485
+ properties: {
486
+ sender_id: { type: "string" },
487
+ channel: { type: "string" },
488
+ },
489
+ required: ["sender_id", "channel"],
490
+ },
491
+ handler: async (params: { sender_id: string; channel: string }) => {
492
+ const cfg = getConfig(api);
493
+ const supabase = getSupabase(cfg);
494
+ const deviceId = deriveDeviceId(params.sender_id, params.channel);
495
+
496
+ // Find my accepted matches via RPC
497
+ const { data: allMatches } = await supabase.rpc("get_my_matches", {
498
+ p_device_id: deviceId,
499
+ });
500
+
501
+ const myMatches = (allMatches || []).filter(
502
+ (m: any) => m.device_id_a === deviceId
503
+ );
504
+
505
+ if (myMatches.length === 0) {
506
+ return { mutual_matches: [], message: "目前没有进行中的匹配。" };
507
+ }
508
+
509
+ // Check which ones are mutual
510
+ const mutualMatches = [];
511
+ for (const match of myMatches) {
512
+ const reverse = (allMatches || []).find(
513
+ (m: any) => m.device_id_a === match.device_id_b && m.device_id_b === deviceId
514
+ );
515
+
516
+ if (reverse) {
517
+ // Get their profile via RPC
518
+ const { data: profile } = await supabase.rpc("get_profile", {
519
+ p_device_id: match.device_id_b,
520
+ });
521
+
522
+ mutualMatches.push({
523
+ name: profile?.display_name || "匿名",
524
+ emoji: profile?.emoji || "👤",
525
+ their_contact: reverse.contact_info_a || null,
526
+ you_shared: match.contact_info_a || null,
527
+ });
528
+ }
529
+ }
530
+
531
+ if (mutualMatches.length === 0) {
532
+ return {
533
+ mutual_matches: [],
534
+ message: "你接受了一些匹配,但对方还没有回应。耐心等等 ⏳",
535
+ };
536
+ }
537
+
538
+ return { mutual_matches: mutualMatches };
539
+ },
540
+ });
541
+
542
+ // ═══════════════════════════════════════════════════════════════════
543
+ // Hook: auto-scan when location is received
544
+ // ═══════════════════════════════════════════════════════════════════
545
+ api.on(
546
+ "before_prompt_build",
547
+ (event: any, ctx: any) => {
548
+ try {
549
+ const cfg = getConfig(api);
550
+ if (cfg.autoScanOnLocation === false) return {};
551
+
552
+ // Check if the inbound message has location context
553
+ const lat = ctx?.LocationLat;
554
+ const lon = ctx?.LocationLon;
555
+ if (lat == null || lon == null) return {};
556
+
557
+ // Inject a hint so the agent knows to use antenna_scan
558
+ const isLive = ctx?.LocationIsLive ?? false;
559
+ const locationName = ctx?.LocationName ?? "";
560
+ const hint = isLive
561
+ ? `\n\n[Antenna] 📡 收到实时位置 (${lat.toFixed(4)}, ${lon.toFixed(4)})${locationName ? ` — ${locationName}` : ""}。请使用 antenna_scan 工具查看附近有谁。参数:lat=${lat}, lng=${lon}, sender_id 和 channel 从消息上下文获取。`
562
+ : `\n\n[Antenna] 📍 收到位置 (${lat.toFixed(4)}, ${lon.toFixed(4)})${locationName ? ` — ${locationName}` : ""}。请使用 antenna_scan 工具查看附近有谁。参数:lat=${lat}, lng=${lon}, sender_id 和 channel 从消息上下文获取。`;
563
+
564
+ return {
565
+ prependContext: hint,
566
+ };
567
+ } catch {
568
+ // Plugin not configured — silently skip
569
+ return {};
570
+ }
571
+ },
572
+ { priority: 5 }
573
+ );
574
+
575
+ logger.info("Antenna plugin loaded 📡");
576
+ }
@@ -0,0 +1,48 @@
1
+ {
2
+ "id": "antenna",
3
+ "name": "Antenna",
4
+ "description": "Agent-mediated nearby people discovery. Receives location from Telegram/WhatsApp, matches nearby people via Supabase + PostGIS, and pushes results back through the agent.",
5
+ "version": "0.1.0",
6
+ "skills": ["./skills"],
7
+ "configSchema": {
8
+ "type": "object",
9
+ "additionalProperties": false,
10
+ "properties": {
11
+ "supabaseUrl": {
12
+ "type": "string",
13
+ "description": "Supabase project URL"
14
+ },
15
+ "supabaseKey": {
16
+ "type": "string",
17
+ "description": "Supabase service role key (not anon key)"
18
+ },
19
+ "defaultRadiusM": {
20
+ "type": "number",
21
+ "description": "Default search radius in meters",
22
+ "default": 500
23
+ },
24
+ "matchExpiryHours": {
25
+ "type": "number",
26
+ "description": "Hours before a match expires",
27
+ "default": 24
28
+ },
29
+ "maxMatches": {
30
+ "type": "number",
31
+ "description": "Maximum matches to return per scan",
32
+ "default": 5
33
+ },
34
+ "autoScanOnLocation": {
35
+ "type": "boolean",
36
+ "description": "Automatically scan for nearby people when a location message is received",
37
+ "default": true
38
+ }
39
+ },
40
+ "required": []
41
+ },
42
+ "uiHints": {
43
+ "supabaseUrl": { "label": "Supabase URL", "placeholder": "https://xxx.supabase.co" },
44
+ "supabaseKey": { "label": "Supabase Service Role Key", "sensitive": true },
45
+ "defaultRadiusM": { "label": "Default Radius (m)", "placeholder": "500" },
46
+ "autoScanOnLocation": { "label": "Auto-scan on Location" }
47
+ }
48
+ }
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "antenna-openclaw-plugin",
3
+ "version": "0.1.0",
4
+ "description": "Antenna — agent-mediated nearby people discovery for OpenClaw",
5
+ "openclaw": {
6
+ "extensions": ["./index.ts"]
7
+ },
8
+ "dependencies": {
9
+ "@supabase/supabase-js": "^2.49.0"
10
+ }
11
+ }
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: antenna
3
+ description: "Nearby people discovery via Antenna. Use when a user shares location, asks who's nearby, wants to set up their profile card, or interacts with match results. Handles location-based social discovery through the antenna_scan, antenna_profile, antenna_accept, and antenna_check_matches tools."
4
+ metadata: { "openclaw": { "always": true } }
5
+ ---
6
+
7
+ # Antenna — Nearby People Discovery
8
+
9
+ You have access to the Antenna plugin tools for location-based social discovery.
10
+
11
+ ## When to use
12
+
13
+ - User shares a location (Telegram live location, WhatsApp pin, or tells you where they are)
14
+ - User asks "附近有谁" / "who's nearby" / "周围有什么人"
15
+ - User wants to set up or edit their profile card (名片)
16
+ - User accepts or skips a match
17
+ - User asks about match status or wants to exchange contact info
18
+
19
+ ## Tools
20
+
21
+ ### `antenna_scan`
22
+ Scan for nearby people. Use when you receive a location.
23
+ - `lat`, `lng`: coordinates (from `LocationLat`/`LocationLon` context, or geocoded from user input)
24
+ - `radius_m`: search radius (default 500m)
25
+ - `sender_id`: the user's id from message context
26
+ - `channel`: the channel name (telegram, whatsapp, discord, etc.)
27
+
28
+ ### `antenna_profile`
29
+ View or update the user's name card.
30
+ - `action`: "get" or "set"
31
+ - `sender_id`, `channel`: from context
32
+ - For "set": `display_name`, `emoji`, `line1`, `line2`, `line3`, `visible`
33
+
34
+ The name card has:
35
+ - **emoji**: a single emoji that represents them
36
+ - **display_name**: how they want to be called
37
+ - **line1**: who they are / what they do
38
+ - **line2**: what they're into
39
+ - **line3**: what they're looking for right now
40
+
41
+ ### `antenna_accept`
42
+ Accept a match after the user sees results. Can optionally include contact info to share.
43
+ - `sender_id`, `channel`, `target_device_id`
44
+ - `contact_info` (optional): e.g. "WeChat: yi_xxx" or "Telegram: @yi"
45
+
46
+ ### `antenna_check_matches`
47
+ Check for mutual matches and contact info updates.
48
+ - `sender_id`, `channel`
49
+ - Returns mutual matches with any contact info the other person shared
50
+
51
+ ## Behavior guidelines
52
+
53
+ ### First-time user
54
+ If the user doesn't have a profile yet, guide them to create one BEFORE scanning:
55
+ 1. Ask for a name, an emoji, and three short lines about themselves
56
+ 2. Use `antenna_profile` action="set" to save it
57
+ 3. Then proceed to scan
58
+
59
+ ### Showing results
60
+ Present matches conversationally, not as a data dump:
61
+ - Lead with the emoji and name
62
+ - Show their three lines
63
+ - Include the match reason naturally
64
+ - Ask if they want to accept any match
65
+
66
+ Example:
67
+ > 📡 附近发现 3 个人:
68
+ >
69
+ > 🎸 **小林** — 吉他手,喜欢后摇和 shoegaze,在找人一起 jam
70
+ > → 你们都提到了音乐和后摇——可能聊得来
71
+ >
72
+ > 🏃 **Alex** — 跑步爱好者,每周三晚朝阳公园
73
+ > → 就在附近
74
+ >
75
+ > 想跟谁打个招呼?
76
+
77
+ ### Accepting & contact exchange
78
+ When the user wants to accept a match:
79
+ 1. Call `antenna_accept` with the target's device_id
80
+ 2. Ask: "想分享你的联系方式吗?比如微信号、Telegram、手机号"
81
+ 3. If user shares, call `antenna_accept` again with `contact_info`
82
+ 4. If mutual match, tell the user the other person's contact info (if they shared)
83
+ 5. If not mutual yet, tell the user to wait
84
+
85
+ ### Checking match status
86
+ Use `antenna_check_matches` when:
87
+ - User asks "有人回复我吗" / "匹配状态怎么样"
88
+ - Periodically during conversation if the user has pending matches
89
+
90
+ ### Location sources
91
+ - **Telegram/WhatsApp location**: context will have `LocationLat`, `LocationLon` — use directly
92
+ - **User says a place name**: geocode it first (use web_search or a geocoding service), then call antenna_scan
93
+ - **Live location**: note that it's real-time, tell the user you'll check for new people
94
+
95
+ ### Privacy
96
+ - Never reveal exact coordinates to other users
97
+ - Never share someone's device_id with another user
98
+ - Only show the profile info (name, emoji, three lines)
99
+ - Contact info is only shared when the user explicitly agrees
100
+ - All matches expire in 24 hours
101
+
102
+ ### 24-hour rule
103
+ Everything is ephemeral:
104
+ - Match results expire in 24h
105
+ - Contact info shared through matches expires with the match
106
+ - If neither side acts, the match disappears
107
+ - This is by design — "用完即走"