antenna-openclaw-plugin 1.2.1 → 1.2.3

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 CHANGED
@@ -47,6 +47,7 @@ let _supabaseClient: SupabaseClient | null = null;
47
47
  let _supabaseUrl: string | null = null;
48
48
  const _lastScanTime = new Map<string, number>();
49
49
  const SCAN_DEBOUNCE_MS = 30_000;
50
+ const _knownDeviceIds = new Set<string>();
50
51
 
51
52
  function getConfig(api: any): AntennaConfig {
52
53
  const cfg = api.config?.plugins?.entries?.antenna?.config ?? {};
@@ -99,7 +100,9 @@ function extractWords(profile: Partial<Profile>): string[] {
99
100
  }
100
101
 
101
102
  function deriveDeviceId(senderId: string, channel: string): string {
102
- return `${channel}:${senderId}`;
103
+ const id = `${channel}:${senderId}`;
104
+ _knownDeviceIds.add(id);
105
+ return id;
103
106
  }
104
107
 
105
108
  /** Wrap result as MCP tool response */
@@ -587,23 +590,19 @@ export default function register(api: any) {
587
590
  const theirLines = [p.line1, p.line2, p.line3].filter(Boolean).join(". ");
588
591
  let match_reason: string | null = null;
589
592
 
590
- // Generate match reason with Gemini Flash
593
+ // Generate match reason via Edge Function (no client-side API key needed)
591
594
  if (myLines && theirLines) {
592
595
  try {
593
- const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
594
- if (apiKey) {
595
- const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, {
596
- method: "POST",
597
- headers: { "Content-Type": "application/json" },
598
- body: JSON.stringify({
599
- contents: [{ parts: [{ text: `You are matching two people. Person A: "${myLines}". Person B: "${theirLines}". Write ONE short sentence (under 20 words) in the SAME LANGUAGE as the profiles explaining why they might click. Be specific, not generic. No fluff.` }] }],
600
- generationConfig: { maxOutputTokens: 60, temperature: 0.7 },
601
- }),
602
- });
603
- if (res.ok) {
604
- const data = await res.json();
605
- match_reason = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || null;
606
- }
596
+ const supabaseUrl = cfg.supabaseUrl || BUILTIN_SUPABASE_URL;
597
+ const supabaseKey = cfg.supabaseKey || BUILTIN_SUPABASE_ANON_KEY;
598
+ const res = await fetch(`${supabaseUrl}/functions/v1/generate-match-reason`, {
599
+ method: "POST",
600
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${supabaseKey}` },
601
+ body: JSON.stringify({ my_lines: myLines, their_lines: theirLines }),
602
+ });
603
+ if (res.ok) {
604
+ const data = await res.json();
605
+ match_reason = data?.reason || null;
607
606
  }
608
607
  } catch { /* best effort */ }
609
608
  }
@@ -849,11 +848,72 @@ export default function register(api: any) {
849
848
  const _notifiedMatches = new Set<string>(); // "deviceA→deviceB" already notified
850
849
 
851
850
  let _pollTimer: ReturnType<typeof setInterval> | null = null;
851
+ let _realtimeChannel: any = null;
852
852
 
853
853
  api.registerService({
854
854
  id: "antenna-match-poller",
855
855
  start: () => {
856
- logger.info("Antenna: match poller started (10 min interval, real-time notify)");
856
+ logger.info("Antenna: match poller started (10 min interval + Supabase Realtime)");
857
+
858
+ // ── Supabase Realtime: instant match notifications ──────────
859
+ try {
860
+ const rtCfg = getConfig(api);
861
+ const rtSupabase = getSupabase(rtCfg);
862
+ _realtimeChannel = rtSupabase
863
+ .channel('antenna-match-notify')
864
+ .on('postgres_changes',
865
+ { event: 'INSERT', schema: 'public', table: 'matches' },
866
+ async (payload: any) => {
867
+ try {
868
+ const targetDeviceId = payload.new?.device_id_b;
869
+ if (!targetDeviceId || !_knownDeviceIds.has(targetDeviceId)) return;
870
+
871
+ const key = `${payload.new.device_id_a}→${targetDeviceId}`;
872
+ if (_notifiedMatches.has(key)) return;
873
+ _notifiedMatches.add(key);
874
+
875
+ const parts = targetDeviceId.split(":");
876
+ if (parts.length < 2) return;
877
+ const channel = parts[0];
878
+ const userId = parts.slice(1).join(":");
879
+
880
+ const innerCfg = getConfig(api);
881
+ const innerSb = getSupabase(innerCfg);
882
+
883
+ const { data: theirProfile } = await innerSb.rpc("get_profile", { p_device_id: payload.new.device_id_a });
884
+ const name = theirProfile?.display_name || "有人";
885
+ const emoji = theirProfile?.emoji || "👤";
886
+
887
+ // Check if mutual
888
+ const { data: matches } = await innerSb.rpc("get_my_matches", { p_device_id: targetDeviceId });
889
+ const myAccept = (matches || []).find(
890
+ (m: any) => m.device_id_a === targetDeviceId && m.device_id_b === payload.new.device_id_a
891
+ );
892
+
893
+ if (myAccept) {
894
+ const contact = payload.new.contact_info_a ? `\n对方的联系方式:${payload.new.contact_info_a}` : "";
895
+ notifyUser(channel, userId,
896
+ `[Antenna] 🎉 双向匹配!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
897
+ logger);
898
+ stopFollowUpCron(targetDeviceId, payload.new.device_id_a, logger);
899
+ } else {
900
+ notifyUser(channel, userId,
901
+ `[Antenna] 📩 ${emoji} ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
902
+ logger);
903
+ }
904
+ } catch (err: any) {
905
+ logger.warn("Antenna: realtime match handler error:", err.message);
906
+ }
907
+ }
908
+ )
909
+ .subscribe((status: string) => {
910
+ logger.info(`Antenna: realtime subscription status: ${status}`);
911
+ });
912
+ } catch (err: any) {
913
+ logger.warn("Antenna: failed to start realtime subscription, falling back to poll only:", err.message);
914
+ }
915
+
916
+ // ── Poll fallback: catch anything Realtime missed ───────────
857
917
  _pollTimer = setInterval(async () => {
858
918
  try {
859
919
  const cfg = getConfig(api);
@@ -948,7 +1008,15 @@ export default function register(api: any) {
948
1008
  },
949
1009
  stop: () => {
950
1010
  if (_pollTimer) clearInterval(_pollTimer);
951
- logger.info("Antenna: match poller stopped");
1011
+ if (_realtimeChannel) {
1012
+ try {
1013
+ const rtCfg = getConfig(api);
1014
+ const rtSupabase = getSupabase(rtCfg);
1015
+ rtSupabase.removeChannel(_realtimeChannel);
1016
+ } catch { /* best effort */ }
1017
+ _realtimeChannel = null;
1018
+ }
1019
+ logger.info("Antenna: match poller + realtime stopped");
952
1020
  },
953
1021
  });
954
1022
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antenna-openclaw-plugin",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Antenna — agent-mediated nearby people discovery for OpenClaw",
5
5
  "openclaw": {
6
6
  "extensions": ["./index.ts"]
@@ -122,6 +122,20 @@ Get today's global recommendation — the person most similar to you worldwide.
122
122
  - If all users have been recommended, returns a message saying "wait for new people"
123
123
  - Use this in the daily cron job, or when user asks "find someone interesting globally"
124
124
 
125
+ ### `antenna_pass`
126
+ Pass/skip a person. They won't be recommended again.
127
+ - `sender_id`, `channel`: from context
128
+ - `ref`: ref number from scan/discover results (e.g. '1')
129
+ - `target_device_id`: device ID (use ref instead when possible)
130
+ - Use when the user says "skip", "pass", "not interested", etc.
131
+
132
+ ### `antenna_checkin`
133
+ Check in at a location — update your position so others can find you when they scan.
134
+ - `lat`, `lng`: coordinates (required)
135
+ - `sender_id`, `channel`: from context
136
+ - `place_name`: optional name of the place
137
+ - Use when the user says "I'm at XX" or wants to be discoverable without scanning others
138
+
125
139
  ## Data Transparency — what Antenna sends
126
140
 
127
141
  Antenna only communicates with Supabase (bcudjloikmpcqwcptuyd.supabase.co) via HTTPS.