antenna-fyi 1.2.27 → 1.2.29
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/lib/cli.js +94 -27
- package/lib/core.js +32 -57
- package/lib/hermes-plugin/__init__.py +135 -2
- package/package.json +1 -1
package/lib/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { scan, getProfile, setProfile, accept, checkMatches, checkin, createBindToken, discover, createEvent, endEvent, eventCheckin, joinEvent, eventScan, pass as passUser, uploadEventImage, updateEvent, approveParticipant, rejectParticipant, addCohost, getClient } from "./core.js";
|
|
4
4
|
import { createInterface } from "readline";
|
|
5
|
-
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
5
|
+
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "fs";
|
|
6
6
|
import path from "path";
|
|
7
7
|
import os from "os";
|
|
8
8
|
import { join, dirname, extname } from "path";
|
|
@@ -125,7 +125,7 @@ export async function handleMatches(f) {
|
|
|
125
125
|
for (const m of result.incoming_accepts) {
|
|
126
126
|
console.log(`📩 WANTS TO MEET YOU: ${m.emoji} ${m.name}`);
|
|
127
127
|
if (m.line1) console.log(` ${m.line1}`);
|
|
128
|
-
console.log(` Accept: antenna accept --id ${f.id} --
|
|
128
|
+
console.log(` Accept: antenna accept --id ${f.id} --ref ${m.ref}`);
|
|
129
129
|
console.log();
|
|
130
130
|
}
|
|
131
131
|
}
|
|
@@ -483,6 +483,20 @@ export async function handleWatch(f) {
|
|
|
483
483
|
// Write PID file for health check
|
|
484
484
|
const pidDir = path.join(os.homedir(), '.antenna');
|
|
485
485
|
const pidFile = path.join(pidDir, 'watch.pid');
|
|
486
|
+
|
|
487
|
+
// Check for existing watch process
|
|
488
|
+
if (existsSync(pidFile)) {
|
|
489
|
+
try {
|
|
490
|
+
const existingPid = parseInt(readFileSync(pidFile, 'utf8').trim());
|
|
491
|
+
process.kill(existingPid, 0); // throws if not running
|
|
492
|
+
console.error(`❌ Watch already running (PID ${existingPid}). Kill it first or remove ${pidFile}`);
|
|
493
|
+
process.exit(1);
|
|
494
|
+
} catch (e) {
|
|
495
|
+
if (e.code !== 'ESRCH') { /* process exists */ throw e; }
|
|
496
|
+
// Process not running, stale PID file — continue
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
486
500
|
try {
|
|
487
501
|
if (!existsSync(pidDir)) mkdirSync(pidDir, { recursive: true });
|
|
488
502
|
writeFileSync(pidFile, String(process.pid));
|
|
@@ -492,27 +506,56 @@ export async function handleWatch(f) {
|
|
|
492
506
|
process.on('SIGTERM', () => { try { unlinkSync(pidFile); } catch {} process.exit(0); });
|
|
493
507
|
|
|
494
508
|
const sb = getClient();
|
|
495
|
-
const notified = new Set();
|
|
496
509
|
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
// Verify gateway is running
|
|
510
|
+
// Persist notified set to disk
|
|
511
|
+
const notifiedFile = path.join(pidDir, 'notified.json');
|
|
512
|
+
|
|
513
|
+
function loadNotified() {
|
|
502
514
|
try {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
515
|
+
const data = JSON.parse(readFileSync(notifiedFile, 'utf8'));
|
|
516
|
+
const now = Date.now();
|
|
517
|
+
const TTL = 48 * 60 * 60 * 1000;
|
|
518
|
+
const set = new Set();
|
|
519
|
+
for (const [key, ts] of Object.entries(data)) {
|
|
520
|
+
if (now - ts < TTL) set.add(key);
|
|
521
|
+
}
|
|
522
|
+
return set;
|
|
523
|
+
} catch { return new Set(); }
|
|
524
|
+
}
|
|
507
525
|
|
|
508
|
-
|
|
526
|
+
function saveNotified(set) {
|
|
527
|
+
const now = Date.now();
|
|
528
|
+
const obj = {};
|
|
529
|
+
for (const key of set) obj[key] = now;
|
|
530
|
+
const tmp = notifiedFile + '.tmp';
|
|
531
|
+
writeFileSync(tmp, JSON.stringify(obj));
|
|
532
|
+
renameSync(tmp, notifiedFile);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const notified = loadNotified();
|
|
536
|
+
|
|
537
|
+
// Detect local agent framework for push notifications
|
|
538
|
+
let pushMethod = f.push || null;
|
|
539
|
+
if (!pushMethod) {
|
|
540
|
+
pushMethod = "terminal"; // default: just print
|
|
509
541
|
try {
|
|
510
|
-
execSync("which
|
|
542
|
+
execSync("which openclaw", { stdio: "pipe" });
|
|
543
|
+
// Verify gateway is running
|
|
544
|
+
try {
|
|
545
|
+
execSync("openclaw gateway health", { stdio: "pipe", timeout: 5000 });
|
|
546
|
+
pushMethod = "openclaw";
|
|
547
|
+
} catch { /* gateway not running */ }
|
|
548
|
+
} catch { /* openclaw not installed */ }
|
|
549
|
+
|
|
550
|
+
if (pushMethod === "terminal") {
|
|
511
551
|
try {
|
|
512
|
-
execSync("hermes
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
552
|
+
execSync("which hermes", { stdio: "pipe" });
|
|
553
|
+
try {
|
|
554
|
+
execSync("hermes gateway status", { stdio: "pipe", timeout: 5000 });
|
|
555
|
+
pushMethod = "hermes";
|
|
556
|
+
} catch { /* hermes gateway not running */ }
|
|
557
|
+
} catch { /* hermes not installed */ }
|
|
558
|
+
}
|
|
516
559
|
}
|
|
517
560
|
|
|
518
561
|
// Force stdout blocking mode for non-TTY environments (Hermes exec)
|
|
@@ -580,19 +623,21 @@ export async function handleWatch(f) {
|
|
|
580
623
|
if (initial.mutual_matches?.length) {
|
|
581
624
|
_log(`🎉 You have ${initial.mutual_matches.length} mutual match(es)!`);
|
|
582
625
|
for (const m of initial.mutual_matches) {
|
|
583
|
-
const key = `mutual:${m.
|
|
626
|
+
const key = `mutual:${m._device_id}`;
|
|
584
627
|
notified.add(key);
|
|
585
628
|
_log(` ${m.emoji || "👤"} ${m.name}${m.their_contact ? " — contact: " + m.their_contact : ""}`);
|
|
586
629
|
}
|
|
630
|
+
saveNotified(notified);
|
|
587
631
|
_log();
|
|
588
632
|
}
|
|
589
633
|
if (initial.incoming_accepts?.length) {
|
|
590
634
|
_log(`📩 ${initial.incoming_accepts.length} person(s) want to meet you!`);
|
|
591
635
|
for (const m of initial.incoming_accepts) {
|
|
592
|
-
const key = `incoming:${m.
|
|
636
|
+
const key = `incoming:${m._device_id}`;
|
|
593
637
|
notified.add(key);
|
|
594
638
|
_log(` ${m.emoji || "👤"} ${m.name} — ${m.line1 || ""}`);
|
|
595
639
|
}
|
|
640
|
+
saveNotified(notified);
|
|
596
641
|
_log();
|
|
597
642
|
}
|
|
598
643
|
|
|
@@ -618,26 +663,30 @@ export async function handleWatch(f) {
|
|
|
618
663
|
|
|
619
664
|
// Check if mutual
|
|
620
665
|
const matches = await checkMatches({ device_id: id });
|
|
621
|
-
const isMutual = matches.mutual_matches?.some(m => m.
|
|
666
|
+
const isMutual = matches.mutual_matches?.some(m => m._device_id === row.device_id_a);
|
|
622
667
|
|
|
623
668
|
if (isMutual) {
|
|
624
669
|
const mutualKey = `mutual:${row.device_id_a}`;
|
|
625
670
|
notified.add(mutualKey);
|
|
671
|
+
saveNotified(notified);
|
|
626
672
|
const contact = row.contact_info_a;
|
|
627
673
|
pushNotify(`🎉 MUTUAL MATCH! ${emoji} ${name} also accepted you!${contact ? " Contact: " + contact : ""}`);
|
|
628
674
|
} else {
|
|
629
|
-
|
|
675
|
+
notified.add(key);
|
|
676
|
+
saveNotified(notified);
|
|
677
|
+
pushNotify(`📩 ${emoji} ${name} wants to meet you! Use 'antenna matches --id ${id}' to respond.`);
|
|
630
678
|
}
|
|
631
679
|
}
|
|
632
680
|
|
|
633
681
|
// I accepted someone and they also accepted me
|
|
634
682
|
if (row.device_id_a === id) {
|
|
635
683
|
const matches = await checkMatches({ device_id: id });
|
|
636
|
-
const mutual = matches.mutual_matches?.find(m => m.
|
|
684
|
+
const mutual = matches.mutual_matches?.find(m => m._device_id === row.device_id_b);
|
|
637
685
|
if (mutual) {
|
|
638
686
|
const mutualKey = `mutual:${row.device_id_b}`;
|
|
639
687
|
if (!notified.has(mutualKey)) {
|
|
640
688
|
notified.add(mutualKey);
|
|
689
|
+
saveNotified(notified);
|
|
641
690
|
pushNotify(`🎉 MUTUAL MATCH! ${mutual.emoji || "👤"} ${mutual.name}!${mutual.their_contact ? " Contact: " + mutual.their_contact : ""}`);
|
|
642
691
|
}
|
|
643
692
|
}
|
|
@@ -649,12 +698,28 @@ export async function handleWatch(f) {
|
|
|
649
698
|
)
|
|
650
699
|
.subscribe((status) => {
|
|
651
700
|
if (status === "SUBSCRIBED") {
|
|
701
|
+
retryCount = 0;
|
|
652
702
|
_log("✅ Connected — listening for matches in real-time.");
|
|
653
703
|
} else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
|
|
654
|
-
|
|
704
|
+
retryCount++;
|
|
705
|
+
if (retryCount < MAX_FAST_RETRIES) {
|
|
706
|
+
const delay = Math.min(1000 * Math.pow(2, retryCount), 60000);
|
|
707
|
+
_log(`⚠️ Connection issue (${status}), retry #${retryCount} in ${Math.round(delay/1000)}s...`);
|
|
708
|
+
setTimeout(() => { try { channel.subscribe(); } catch {} }, delay);
|
|
709
|
+
} else if (retryCount === MAX_FAST_RETRIES) {
|
|
710
|
+
_log(`⚠️ Connection unstable after ${MAX_FAST_RETRIES} retries. Switching to ${COOLDOWN_MS/1000}s cooldown. Polling fallback still active.`);
|
|
711
|
+
setTimeout(() => { try { channel.subscribe(); } catch {} }, COOLDOWN_MS);
|
|
712
|
+
} else {
|
|
713
|
+
setTimeout(() => { try { channel.subscribe(); } catch {} }, COOLDOWN_MS);
|
|
714
|
+
}
|
|
655
715
|
}
|
|
656
716
|
});
|
|
657
717
|
|
|
718
|
+
// Reconnection state for matches channel
|
|
719
|
+
let retryCount = 0;
|
|
720
|
+
const MAX_FAST_RETRIES = 20;
|
|
721
|
+
const COOLDOWN_MS = 5 * 60 * 1000; // 5 min
|
|
722
|
+
|
|
658
723
|
// Subscribe to event_participants changes (approval notifications)
|
|
659
724
|
sb
|
|
660
725
|
.channel("antenna-cli-watch-events")
|
|
@@ -704,16 +769,18 @@ export async function handleWatch(f) {
|
|
|
704
769
|
try {
|
|
705
770
|
const result = await checkMatches({ device_id: id });
|
|
706
771
|
for (const m of (result.mutual_matches || [])) {
|
|
707
|
-
const key = `mutual:${m.
|
|
772
|
+
const key = `mutual:${m._device_id}`;
|
|
708
773
|
if (!notified.has(key)) {
|
|
709
774
|
notified.add(key);
|
|
775
|
+
saveNotified(notified);
|
|
710
776
|
pushNotify(`🎉 MUTUAL MATCH! ${m.emoji || "👤"} ${m.name}!${m.their_contact ? " Contact: " + m.their_contact : ""}`);
|
|
711
777
|
}
|
|
712
778
|
}
|
|
713
779
|
for (const m of (result.incoming_accepts || [])) {
|
|
714
|
-
const key = `incoming:${m.
|
|
780
|
+
const key = `incoming:${m._device_id}`;
|
|
715
781
|
if (!notified.has(key)) {
|
|
716
782
|
notified.add(key);
|
|
783
|
+
saveNotified(notified);
|
|
717
784
|
pushNotify(`📩 ${m.emoji || "👤"} ${m.name} wants to meet you!`);
|
|
718
785
|
}
|
|
719
786
|
}
|
|
@@ -741,7 +808,7 @@ Usage:
|
|
|
741
808
|
antenna matches --id telegram:123
|
|
742
809
|
antenna discover --id telegram:123
|
|
743
810
|
antenna event --create --name 'AI Meetup' [--desc '...'] [--og-image 'url'] [--requires-approval] [--screening-questions 'Q1|Q2'] | --join --code abc123 | --scan --code abc123 | --end --code abc123 --id telegram:123 | --upload-image --code abc123 --file /path/to/image.png | --update --code abc123 --name 'New Name' | --approve --code abc123 --ref 1 | --reject --code abc123 --ref 1 | --add-host --code abc123 --ref 1
|
|
744
|
-
antenna watch --id telegram:123 Watch for new matches in real-time (Ctrl+C to stop)
|
|
811
|
+
antenna watch --id telegram:123 [--push hermes|openclaw|terminal] Watch for new matches in real-time (Ctrl+C to stop)
|
|
745
812
|
antenna bind --id telegram:123
|
|
746
813
|
antenna serve Start MCP server (stdio transport)
|
|
747
814
|
antenna setup Interactive profile setup [--id telegram:123]
|
package/lib/core.js
CHANGED
|
@@ -79,20 +79,12 @@ export async function scan({ lat, lng, radius_m = 500, device_id, supabaseUrl, s
|
|
|
79
79
|
lat = loc.lat;
|
|
80
80
|
lng = loc.lng;
|
|
81
81
|
} else {
|
|
82
|
-
return { count: 0, radius_m, profiles: [], message: "
|
|
82
|
+
return { count: 0, radius_m, profiles: [], message: "还没有位置信息。请先用 'antenna checkin' 分享位置,或通过链接分享位置。" };
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
const fuzzy = fuzzyCoord(lat, lng);
|
|
87
87
|
|
|
88
|
-
if (device_id) {
|
|
89
|
-
await sb.rpc("upsert_profile_location", {
|
|
90
|
-
p_device_id: device_id,
|
|
91
|
-
p_lng: fuzzy.lng,
|
|
92
|
-
p_lat: fuzzy.lat,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
88
|
const { data, error } = await sb.rpc("nearby_profiles", {
|
|
97
89
|
p_lat: fuzzy.lat,
|
|
98
90
|
p_lng: fuzzy.lng,
|
|
@@ -323,60 +315,43 @@ export async function checkin({ lat, lng, device_id, supabaseUrl, supabaseKey })
|
|
|
323
315
|
export async function checkMatches({ device_id, supabaseUrl, supabaseKey }) {
|
|
324
316
|
const sb = getClient(supabaseUrl, supabaseKey);
|
|
325
317
|
|
|
326
|
-
|
|
318
|
+
// Single RPC with JOINed profiles — no N+1
|
|
319
|
+
const { data, error } = await sb.rpc("get_my_matches_with_profiles", { p_device_id: device_id });
|
|
327
320
|
if (error) throw new Error(error.message);
|
|
328
321
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
their_contact: reverse.contact_info_a || null,
|
|
354
|
-
you_shared: match.contact_info_a || null,
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Incoming only
|
|
360
|
-
const incomingAccepts = [];
|
|
361
|
-
for (const match of incomingMatches) {
|
|
362
|
-
const iAccepted = myMatches.find((m) => m.device_id_b === match.device_id_a);
|
|
363
|
-
if (!iAccepted) {
|
|
364
|
-
const profile = await getProfile({ device_id: match.device_id_a, supabaseUrl, supabaseKey });
|
|
365
|
-
incomingAccepts.push({
|
|
366
|
-
device_id: match.device_id_a,
|
|
367
|
-
name: profile?.display_name || "匿名",
|
|
368
|
-
emoji: profile?.emoji || "👤",
|
|
369
|
-
line1: profile?.line1,
|
|
370
|
-
line2: profile?.line2,
|
|
371
|
-
line3: profile?.line3,
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
}
|
|
322
|
+
const raw = data || { mutual_matches: [], incoming_accepts: [] };
|
|
323
|
+
|
|
324
|
+
// Add refs and rename target_id to _device_id (internal only)
|
|
325
|
+
const mutualMatches = (raw.mutual_matches || []).map((m, i) => ({
|
|
326
|
+
ref: String(i + 1),
|
|
327
|
+
_device_id: m.target_id,
|
|
328
|
+
name: m.name || "匿名",
|
|
329
|
+
emoji: m.emoji || "👤",
|
|
330
|
+
line1: m.line1,
|
|
331
|
+
line2: m.line2,
|
|
332
|
+
line3: m.line3,
|
|
333
|
+
their_contact: m.their_contact || null,
|
|
334
|
+
you_shared: m.you_shared || null,
|
|
335
|
+
}));
|
|
336
|
+
|
|
337
|
+
const incomingAccepts = (raw.incoming_accepts || []).map((m, i) => ({
|
|
338
|
+
ref: String(i + 1),
|
|
339
|
+
_device_id: m.target_id,
|
|
340
|
+
name: m.name || "匿名",
|
|
341
|
+
emoji: m.emoji || "👤",
|
|
342
|
+
line1: m.line1,
|
|
343
|
+
line2: m.line2,
|
|
344
|
+
line3: m.line3,
|
|
345
|
+
}));
|
|
375
346
|
|
|
376
347
|
const messages = [];
|
|
377
348
|
if (mutualMatches.length > 0) messages.push(`${mutualMatches.length} 个双向匹配!可以交换联系方式了`);
|
|
378
349
|
if (incomingAccepts.length > 0) messages.push(`${incomingAccepts.length} 个人想认识你,等你回应`);
|
|
379
|
-
if (messages.length === 0
|
|
350
|
+
if (messages.length === 0 && mutualMatches.length === 0 && incomingAccepts.length === 0) {
|
|
351
|
+
messages.push("目前没有进行中的匹配。");
|
|
352
|
+
} else if (messages.length === 0) {
|
|
353
|
+
messages.push("你接受了一些匹配,但对方还没有回应。耐心等等 ⏳");
|
|
354
|
+
}
|
|
380
355
|
|
|
381
356
|
return {
|
|
382
357
|
mutual_matches: mutualMatches,
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
"""Antenna — Hermes Agent Plugin
|
|
2
2
|
|
|
3
|
-
Nearby people discovery. Registers
|
|
4
|
-
|
|
3
|
+
Nearby people discovery. Registers tools, a pre_llm_call hook,
|
|
4
|
+
and a background Realtime listener for push notifications.
|
|
5
5
|
|
|
6
6
|
Drop this directory into ~/.hermes/plugins/antenna/
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
9
16
|
from .tools import (
|
|
10
17
|
handle_scan,
|
|
11
18
|
handle_profile,
|
|
@@ -91,6 +98,18 @@ def register(ctx):
|
|
|
91
98
|
|
|
92
99
|
now = time.time()
|
|
93
100
|
|
|
101
|
+
# -1. Read pending notifications from Realtime listener
|
|
102
|
+
_nf = Path.home() / ".antenna" / "pending_notifications.json"
|
|
103
|
+
try:
|
|
104
|
+
if _nf.exists():
|
|
105
|
+
pending = json.loads(_nf.read_text())
|
|
106
|
+
if pending:
|
|
107
|
+
for n in pending:
|
|
108
|
+
hints.append(f"[Antenna] {n['message']}")
|
|
109
|
+
_nf.write_text("[]")
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
94
113
|
# 0. Check for new matches (every 60s)
|
|
95
114
|
if now - _last_match_check > _MATCH_CHECK_INTERVAL and _my_device_ids:
|
|
96
115
|
_last_match_check = now
|
|
@@ -185,4 +204,118 @@ def register(ctx):
|
|
|
185
204
|
|
|
186
205
|
ctx.register_hook("pre_llm_call", on_pre_llm)
|
|
187
206
|
|
|
207
|
+
# ── Background Realtime listener for push notifications ──────
|
|
208
|
+
_notif_dir = Path.home() / ".antenna"
|
|
209
|
+
_notif_file = _notif_dir / "pending_notifications.json"
|
|
210
|
+
|
|
211
|
+
def _append_notification(msg: str):
|
|
212
|
+
"""Thread-safe append to pending notifications file."""
|
|
213
|
+
try:
|
|
214
|
+
_notif_dir.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
notifications = []
|
|
216
|
+
if _notif_file.exists():
|
|
217
|
+
try:
|
|
218
|
+
notifications = json.loads(_notif_file.read_text())
|
|
219
|
+
except Exception:
|
|
220
|
+
notifications = []
|
|
221
|
+
notifications.append({"message": msg, "timestamp": time.time()})
|
|
222
|
+
# Keep max 50
|
|
223
|
+
notifications = notifications[-50:]
|
|
224
|
+
_notif_file.write_text(json.dumps(notifications, ensure_ascii=False))
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
def _realtime_listener():
|
|
229
|
+
"""Background thread: listen to Supabase Realtime for matches + event participants."""
|
|
230
|
+
try:
|
|
231
|
+
sb = _sb()
|
|
232
|
+
# Listen to matches INSERT
|
|
233
|
+
sb.channel("hermes-antenna-matches").on(
|
|
234
|
+
"postgres_changes",
|
|
235
|
+
{"event": "INSERT", "schema": "public", "table": "matches"},
|
|
236
|
+
lambda payload: _handle_match_change(payload),
|
|
237
|
+
).subscribe()
|
|
238
|
+
|
|
239
|
+
# Listen to event_participants INSERT + UPDATE
|
|
240
|
+
sb.channel("hermes-antenna-events").on(
|
|
241
|
+
"postgres_changes",
|
|
242
|
+
{"event": "*", "schema": "public", "table": "event_participants"},
|
|
243
|
+
lambda payload: _handle_event_change(payload),
|
|
244
|
+
).subscribe()
|
|
245
|
+
|
|
246
|
+
# Keep thread alive
|
|
247
|
+
while True:
|
|
248
|
+
time.sleep(60)
|
|
249
|
+
except Exception as e:
|
|
250
|
+
print(f"[Antenna] Realtime listener failed: {e}")
|
|
251
|
+
|
|
252
|
+
def _handle_match_change(payload):
|
|
253
|
+
try:
|
|
254
|
+
row = payload.get("new") or payload.get("record", {})
|
|
255
|
+
target = row.get("device_id_b")
|
|
256
|
+
if not target or target not in _my_device_ids:
|
|
257
|
+
return
|
|
258
|
+
key = f"{row.get('device_id_a')}→{target}"
|
|
259
|
+
if key in _notified_match_keys:
|
|
260
|
+
return
|
|
261
|
+
_notified_match_keys.add(key)
|
|
262
|
+
sb = _sb()
|
|
263
|
+
prof = sb.rpc("get_profile", {"p_device_id": row.get("device_id_a")}).execute()
|
|
264
|
+
p = prof.data or {}
|
|
265
|
+
name = p.get("display_name") or "有人"
|
|
266
|
+
emoji = p.get("emoji") or "👤"
|
|
267
|
+
_append_notification(f"📩 {emoji} {name} 想认识你!用 antenna_check_matches 查看详情。")
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
def _handle_event_change(payload):
|
|
272
|
+
try:
|
|
273
|
+
event_type = payload.get("type") or payload.get("eventType", "")
|
|
274
|
+
row = payload.get("new") or payload.get("record", {})
|
|
275
|
+
old = payload.get("old") or payload.get("old_record", {})
|
|
276
|
+
|
|
277
|
+
if event_type == "INSERT" and row.get("status") == "pending":
|
|
278
|
+
# Someone applied — notify creator
|
|
279
|
+
event_id = row.get("event_id")
|
|
280
|
+
applicant_id = row.get("device_id")
|
|
281
|
+
if not event_id:
|
|
282
|
+
return
|
|
283
|
+
sb = _sb()
|
|
284
|
+
evt = sb.rpc("get_event_by_id", {"p_event_id": event_id}).execute()
|
|
285
|
+
event = evt.data or {}
|
|
286
|
+
if not event.get("found") or event.get("created_by") not in _my_device_ids:
|
|
287
|
+
return
|
|
288
|
+
prof = sb.rpc("get_profile", {"p_device_id": applicant_id}).execute()
|
|
289
|
+
p = prof.data or {}
|
|
290
|
+
name = p.get("display_name") or "某人"
|
|
291
|
+
emoji = p.get("emoji") or "👤"
|
|
292
|
+
_append_notification(f"📩 {emoji} {name} 申请加入你的活动「{event.get('name')}」!用 antenna_event_scan 查看并审批。")
|
|
293
|
+
|
|
294
|
+
elif event_type == "UPDATE":
|
|
295
|
+
device_id = row.get("device_id")
|
|
296
|
+
if device_id not in _my_device_ids:
|
|
297
|
+
return
|
|
298
|
+
old_status = old.get("status")
|
|
299
|
+
new_status = row.get("status")
|
|
300
|
+
if old_status == "pending" and new_status == "active":
|
|
301
|
+
sb = _sb()
|
|
302
|
+
evt = sb.rpc("get_event_by_id", {"p_event_id": row.get("event_id")}).execute()
|
|
303
|
+
event = evt.data or {}
|
|
304
|
+
_append_notification(f"✅ 你的申请已通过!欢迎加入「{event.get('name', '活动')}」")
|
|
305
|
+
elif old_status == "pending" and new_status == "rejected":
|
|
306
|
+
sb = _sb()
|
|
307
|
+
evt = sb.rpc("get_event_by_id", {"p_event_id": row.get("event_id")}).execute()
|
|
308
|
+
event = evt.data or {}
|
|
309
|
+
_append_notification(f"❌ 你的申请未通过「{event.get('name', '活动')}」")
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
312
|
+
|
|
313
|
+
# Start background listener thread
|
|
314
|
+
try:
|
|
315
|
+
t = threading.Thread(target=_realtime_listener, daemon=True, name="antenna-realtime")
|
|
316
|
+
t.start()
|
|
317
|
+
print("[Antenna] Realtime listener started 📡")
|
|
318
|
+
except Exception as e:
|
|
319
|
+
print(f"[Antenna] Failed to start Realtime listener: {e}")
|
|
320
|
+
|
|
188
321
|
print("[Antenna] Plugin loaded 📡")
|