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 +94 -0
- package/index.ts +576 -0
- package/openclaw.plugin.json +48 -0
- package/package.json +11 -0
- package/skills/antenna/SKILL.md +107 -0
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 — "用完即走"
|