antenna-fyi 0.5.2 → 0.6.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 +32 -0
- package/bin/antenna.js +3 -0
- package/lib/cli.js +59 -9
- package/lib/hermes-plugin/__init__.py +69 -0
- package/lib/hermes-plugin/plugin.yaml +7 -0
- package/lib/hermes-plugin/schemas.py +117 -0
- package/lib/hermes-plugin/tools.py +244 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,38 @@ This starts a stdio-based MCP server with tools:
|
|
|
49
49
|
- `antenna_accept` — Accept a match
|
|
50
50
|
- `antenna_check_matches` — Check match status
|
|
51
51
|
|
|
52
|
+
## Hermes Agent Integration
|
|
53
|
+
|
|
54
|
+
### Option 1: MCP Server (recommended)
|
|
55
|
+
|
|
56
|
+
Add to `~/.hermes/config.yaml`:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
mcp_servers:
|
|
60
|
+
antenna:
|
|
61
|
+
command: "antenna"
|
|
62
|
+
args: ["serve"]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Hermes will auto-discover `mcp_antenna_scan`, `mcp_antenna_profile`, etc.
|
|
66
|
+
|
|
67
|
+
### Option 2: Plugin (full auto-scan)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
antenna install-hermes
|
|
71
|
+
cd ~/.hermes/hermes-agent && uv pip install supabase
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Restarts Hermes. The plugin registers tools + `pre_llm_call` hook for auto location detection.
|
|
75
|
+
|
|
76
|
+
### Option 3: Skill only
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
antenna install-skill
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Copies SKILL.md to `~/.hermes/skills/antenna/`.
|
|
83
|
+
|
|
52
84
|
## OpenClaw Integration
|
|
53
85
|
|
|
54
86
|
### Install Skill (recommended)
|
package/bin/antenna.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
handleStatus,
|
|
12
12
|
handleInstallSkill,
|
|
13
13
|
handleInstallPlugin,
|
|
14
|
+
handleInstallHermesPlugin,
|
|
14
15
|
printHelp,
|
|
15
16
|
} from "../lib/cli.js";
|
|
16
17
|
|
|
@@ -41,6 +42,8 @@ async function main() {
|
|
|
41
42
|
return handleInstallSkill();
|
|
42
43
|
case "install-plugin":
|
|
43
44
|
return handleInstallPlugin();
|
|
45
|
+
case "install-hermes":
|
|
46
|
+
return handleInstallHermesPlugin();
|
|
44
47
|
case "help":
|
|
45
48
|
default:
|
|
46
49
|
return printHelp();
|
package/lib/cli.js
CHANGED
|
@@ -162,16 +162,36 @@ export async function handleStatus(f) {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
export function handleInstallSkill() {
|
|
165
|
-
const skillDir = join(homedir(), ".openclaw", "skills", "antenna");
|
|
166
165
|
const skillSrc = join(__dirname, "..", "skill", "SKILL.md");
|
|
166
|
+
let installed = 0;
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
// OpenClaw
|
|
169
|
+
const openclawDir = join(homedir(), ".openclaw", "skills", "antenna");
|
|
170
|
+
if (existsSync(join(homedir(), ".openclaw"))) {
|
|
171
|
+
if (!existsSync(openclawDir)) mkdirSync(openclawDir, { recursive: true });
|
|
172
|
+
copyFileSync(skillSrc, join(openclawDir, "SKILL.md"));
|
|
173
|
+
console.log("✅ SKILL.md installed to ~/.openclaw/skills/antenna/");
|
|
174
|
+
installed++;
|
|
170
175
|
}
|
|
171
176
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
177
|
+
// Hermes
|
|
178
|
+
const hermesDir = join(homedir(), ".hermes", "skills", "antenna");
|
|
179
|
+
if (existsSync(join(homedir(), ".hermes"))) {
|
|
180
|
+
if (!existsSync(hermesDir)) mkdirSync(hermesDir, { recursive: true });
|
|
181
|
+
copyFileSync(skillSrc, join(hermesDir, "SKILL.md"));
|
|
182
|
+
console.log("✅ SKILL.md installed to ~/.hermes/skills/antenna/");
|
|
183
|
+
installed++;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (installed === 0) {
|
|
187
|
+
// Neither found, default to OpenClaw path
|
|
188
|
+
if (!existsSync(openclawDir)) mkdirSync(openclawDir, { recursive: true });
|
|
189
|
+
copyFileSync(skillSrc, join(openclawDir, "SKILL.md"));
|
|
190
|
+
console.log("✅ SKILL.md installed to ~/.openclaw/skills/antenna/");
|
|
191
|
+
console.log(" (Neither ~/.openclaw nor ~/.hermes detected — defaulted to OpenClaw)");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log(" Restart your agent to pick it up.");
|
|
175
195
|
}
|
|
176
196
|
|
|
177
197
|
export function handleInstallPlugin() {
|
|
@@ -189,13 +209,42 @@ export function handleInstallPlugin() {
|
|
|
189
209
|
}
|
|
190
210
|
}
|
|
191
211
|
|
|
192
|
-
console.log("\n✅ Plugin template
|
|
212
|
+
console.log("\n✅ OpenClaw Plugin template copied to current directory.");
|
|
193
213
|
console.log(" Next steps:");
|
|
194
214
|
console.log(" 1. Run: npm install");
|
|
195
215
|
console.log(" 2. Run: openclaw plugins install .");
|
|
196
216
|
console.log();
|
|
197
217
|
}
|
|
198
218
|
|
|
219
|
+
export function handleInstallHermesPlugin() {
|
|
220
|
+
const templateDir = join(__dirname, "hermes-plugin");
|
|
221
|
+
const targetDir = join(homedir(), ".hermes", "plugins", "antenna");
|
|
222
|
+
const files = ["plugin.yaml", "__init__.py", "schemas.py", "tools.py"];
|
|
223
|
+
|
|
224
|
+
if (!existsSync(join(homedir(), ".hermes"))) {
|
|
225
|
+
console.error("❌ ~/.hermes not found. Is Hermes Agent installed?");
|
|
226
|
+
console.error(" Install: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
|
|
231
|
+
|
|
232
|
+
for (const file of files) {
|
|
233
|
+
const src = join(templateDir, file);
|
|
234
|
+
const dest = join(targetDir, file);
|
|
235
|
+
if (existsSync(src)) {
|
|
236
|
+
copyFileSync(src, dest);
|
|
237
|
+
console.log(`📄 ${file}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
console.log("\n✅ Hermes Plugin installed to ~/.hermes/plugins/antenna/");
|
|
242
|
+
console.log(" Note: requires supabase-py. Run:");
|
|
243
|
+
console.log(" cd ~/.hermes/hermes-agent && uv pip install supabase");
|
|
244
|
+
console.log(" Then restart Hermes.");
|
|
245
|
+
console.log();
|
|
246
|
+
}
|
|
247
|
+
|
|
199
248
|
export function printHelp() {
|
|
200
249
|
console.log(`📡 Antenna — nearby people discovery
|
|
201
250
|
|
|
@@ -208,8 +257,9 @@ Usage:
|
|
|
208
257
|
antenna serve Start MCP server (stdio transport)
|
|
209
258
|
antenna setup Interactive profile setup [--id telegram:123]
|
|
210
259
|
antenna status Show config & status [--id telegram:123]
|
|
211
|
-
antenna install-skill Install SKILL.md
|
|
212
|
-
antenna install-plugin Copy plugin template to
|
|
260
|
+
antenna install-skill Install SKILL.md (detects OpenClaw + Hermes)
|
|
261
|
+
antenna install-plugin Copy OpenClaw plugin template to cwd
|
|
262
|
+
antenna install-hermes Install Hermes plugin to ~/.hermes/plugins/antenna/
|
|
213
263
|
antenna help Show this help
|
|
214
264
|
|
|
215
265
|
Environment:
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Antenna — Hermes Agent Plugin
|
|
2
|
+
|
|
3
|
+
Nearby people discovery. Registers 5 tools and a pre_llm_call hook
|
|
4
|
+
that auto-detects location data and prompts the agent to scan.
|
|
5
|
+
|
|
6
|
+
Drop this directory into ~/.hermes/plugins/antenna/
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .tools import (
|
|
10
|
+
handle_scan,
|
|
11
|
+
handle_profile,
|
|
12
|
+
handle_accept,
|
|
13
|
+
handle_checkin,
|
|
14
|
+
handle_check_matches,
|
|
15
|
+
SCAN_SCHEMA,
|
|
16
|
+
PROFILE_SCHEMA,
|
|
17
|
+
ACCEPT_SCHEMA,
|
|
18
|
+
CHECKIN_SCHEMA,
|
|
19
|
+
CHECK_MATCHES_SCHEMA,
|
|
20
|
+
)
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register(ctx):
|
|
25
|
+
# ── Tools ─────────────────────────────────────────────────────
|
|
26
|
+
ctx.register_tool("antenna_scan", SCAN_SCHEMA, handle_scan)
|
|
27
|
+
ctx.register_tool("antenna_profile", PROFILE_SCHEMA, handle_profile)
|
|
28
|
+
ctx.register_tool("antenna_accept", ACCEPT_SCHEMA, handle_accept)
|
|
29
|
+
ctx.register_tool("antenna_checkin", CHECKIN_SCHEMA, handle_checkin)
|
|
30
|
+
ctx.register_tool("antenna_check_matches", CHECK_MATCHES_SCHEMA, handle_check_matches)
|
|
31
|
+
|
|
32
|
+
# ── Hook: auto-detect location in messages ────────────────────
|
|
33
|
+
def on_pre_llm(messages, **kwargs):
|
|
34
|
+
"""Check last user message for location data and inject scan hint."""
|
|
35
|
+
if not messages:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
last_msg = messages[-1] if isinstance(messages[-1], dict) else {}
|
|
39
|
+
content = last_msg.get("content", "")
|
|
40
|
+
if not isinstance(content, str):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
# Pattern 1: Hermes gateway may serialize location as text
|
|
44
|
+
# e.g. "📍 Location shared: 39.9908, 116.4872"
|
|
45
|
+
loc_match = re.search(
|
|
46
|
+
r"[Ll]ocation.*?(-?\d+\.?\d*)[,\s]+(-?\d+\.?\d*)", content
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Pattern 2: raw coordinates in message
|
|
50
|
+
if not loc_match:
|
|
51
|
+
loc_match = re.search(
|
|
52
|
+
r"(-?\d{1,3}\.\d{3,})[,\s]+(-?\d{1,3}\.\d{3,})", content
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if loc_match:
|
|
56
|
+
lat, lng = loc_match.group(1), loc_match.group(2)
|
|
57
|
+
return {
|
|
58
|
+
"context": (
|
|
59
|
+
f"\n\n[Antenna] 📍 检测到位置 ({lat}, {lng})。"
|
|
60
|
+
f"请使用 antenna_scan 工具查看附近有谁。"
|
|
61
|
+
f"参数:lat={lat}, lng={lng},sender_id 和 channel 从消息上下文获取。"
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
ctx.register_hook("pre_llm_call", on_pre_llm)
|
|
68
|
+
|
|
69
|
+
print("[Antenna] Plugin loaded 📡")
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
name: antenna
|
|
2
|
+
version: "0.5.2"
|
|
3
|
+
description: |
|
|
4
|
+
Nearby people discovery — scan for people around you, set up your profile card,
|
|
5
|
+
accept matches, and check match status. Uses Supabase as shared backend.
|
|
6
|
+
Triggers automatically when location data is detected.
|
|
7
|
+
requires_env: []
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Antenna tool schemas — what the LLM sees."""
|
|
2
|
+
|
|
3
|
+
SCAN_SCHEMA = {
|
|
4
|
+
"name": "antenna_scan",
|
|
5
|
+
"description": (
|
|
6
|
+
"Scan for nearby people at a given location. Returns raw profile cards "
|
|
7
|
+
"of nearby people — the agent should read these cards and decide who to "
|
|
8
|
+
"recommend based on its understanding of the user."
|
|
9
|
+
),
|
|
10
|
+
"parameters": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"properties": {
|
|
13
|
+
"lat": {"type": "number", "description": "Latitude"},
|
|
14
|
+
"lng": {"type": "number", "description": "Longitude"},
|
|
15
|
+
"radius_m": {
|
|
16
|
+
"type": "number",
|
|
17
|
+
"description": "Search radius in meters (default: 500)",
|
|
18
|
+
},
|
|
19
|
+
"sender_id": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"description": "The sender's user ID (from message context)",
|
|
22
|
+
},
|
|
23
|
+
"channel": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Platform name (telegram, discord, etc.)",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
"required": ["lat", "lng", "sender_id", "channel"],
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
PROFILE_SCHEMA = {
|
|
33
|
+
"name": "antenna_profile",
|
|
34
|
+
"description": (
|
|
35
|
+
"View or update the user's Antenna profile (name card). "
|
|
36
|
+
"The profile has a display name, emoji, and three lines."
|
|
37
|
+
),
|
|
38
|
+
"parameters": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"action": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"enum": ["get", "set"],
|
|
44
|
+
"description": "'get' to view, 'set' to update",
|
|
45
|
+
},
|
|
46
|
+
"sender_id": {"type": "string"},
|
|
47
|
+
"channel": {"type": "string"},
|
|
48
|
+
"display_name": {"type": "string", "description": "Display name"},
|
|
49
|
+
"emoji": {"type": "string", "description": "Profile emoji"},
|
|
50
|
+
"line1": {"type": "string", "description": "Who you are / what you do"},
|
|
51
|
+
"line2": {"type": "string", "description": "What you're into"},
|
|
52
|
+
"line3": {"type": "string", "description": "What you're looking for"},
|
|
53
|
+
"visible": {"type": "boolean", "description": "Visible to others"},
|
|
54
|
+
},
|
|
55
|
+
"required": ["action", "sender_id", "channel"],
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
ACCEPT_SCHEMA = {
|
|
60
|
+
"name": "antenna_accept",
|
|
61
|
+
"description": (
|
|
62
|
+
"Accept a match. Optionally share contact info. "
|
|
63
|
+
"If both sides accept, they can exchange contact info."
|
|
64
|
+
),
|
|
65
|
+
"parameters": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"properties": {
|
|
68
|
+
"sender_id": {"type": "string"},
|
|
69
|
+
"channel": {"type": "string"},
|
|
70
|
+
"target_device_id": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"description": "Device ID of the person to accept",
|
|
73
|
+
},
|
|
74
|
+
"contact_info": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"description": "Contact info to share (e.g. 'WeChat: yi')",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
"required": ["sender_id", "channel", "target_device_id"],
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
CHECKIN_SCHEMA = {
|
|
84
|
+
"name": "antenna_checkin",
|
|
85
|
+
"description": (
|
|
86
|
+
"Check in at a location — update your position so others can find you."
|
|
87
|
+
),
|
|
88
|
+
"parameters": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"properties": {
|
|
91
|
+
"lat": {"type": "number", "description": "Latitude"},
|
|
92
|
+
"lng": {"type": "number", "description": "Longitude"},
|
|
93
|
+
"sender_id": {"type": "string"},
|
|
94
|
+
"channel": {"type": "string"},
|
|
95
|
+
"place_name": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"description": "Name of the place (optional)",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
"required": ["lat", "lng", "sender_id", "channel"],
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
CHECK_MATCHES_SCHEMA = {
|
|
105
|
+
"name": "antenna_check_matches",
|
|
106
|
+
"description": (
|
|
107
|
+
"Check for mutual matches and incoming accepts."
|
|
108
|
+
),
|
|
109
|
+
"parameters": {
|
|
110
|
+
"type": "object",
|
|
111
|
+
"properties": {
|
|
112
|
+
"sender_id": {"type": "string"},
|
|
113
|
+
"channel": {"type": "string"},
|
|
114
|
+
},
|
|
115
|
+
"required": ["sender_id", "channel"],
|
|
116
|
+
},
|
|
117
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Antenna tool handlers — what runs when called.
|
|
2
|
+
|
|
3
|
+
Uses the Supabase REST API via supabase-py. Falls back to the built-in
|
|
4
|
+
shared backend if no env vars are set.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from supabase import create_client
|
|
14
|
+
except ImportError:
|
|
15
|
+
create_client = None # Will fail at runtime with helpful message
|
|
16
|
+
|
|
17
|
+
# ─── Config ───────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
BUILTIN_URL = "https://bcudjloikmpcqwcptuyd.supabase.co"
|
|
20
|
+
BUILTIN_KEY = (
|
|
21
|
+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
|
|
22
|
+
"eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJjdWRqbG9pa21wY3F3Y3B0dXlkIiwi"
|
|
23
|
+
"cm9sZSI6ImFub24iLCJpYXQiOjE3NzQ0MTg1NDgsImV4cCI6MjA4OTk5NDU0OH0."
|
|
24
|
+
"FaoC3QfpfHP1npNGjRchJAoAp2PdZtQe_WhP-t-GN1o"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_client = None
|
|
28
|
+
_client_url = None
|
|
29
|
+
_last_scan: dict[str, float] = {}
|
|
30
|
+
SCAN_DEBOUNCE_S = 30
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_url():
|
|
34
|
+
return os.environ.get("ANTENNA_SUPABASE_URL") or os.environ.get("ANTENNA_URL") or BUILTIN_URL
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_key():
|
|
38
|
+
return os.environ.get("ANTENNA_SUPABASE_KEY") or os.environ.get("ANTENNA_KEY") or BUILTIN_KEY
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _sb():
|
|
42
|
+
global _client, _client_url
|
|
43
|
+
if create_client is None:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
"supabase-py not installed. Run: pip install supabase"
|
|
46
|
+
)
|
|
47
|
+
url = _get_url()
|
|
48
|
+
if _client is None or _client_url != url:
|
|
49
|
+
_client = create_client(url, _get_key())
|
|
50
|
+
_client_url = url
|
|
51
|
+
return _client
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _device_id(sender_id: str, channel: str) -> str:
|
|
55
|
+
return f"{channel}:{sender_id}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fuzzy(lat: float, lng: float) -> tuple[float, float]:
|
|
59
|
+
return round(lat * 1000) / 1000, round(lng * 1000) / 1000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ok(data) -> str:
|
|
63
|
+
return json.dumps(data, ensure_ascii=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ─── Handlers ─────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def handle_scan(params: dict) -> str:
|
|
69
|
+
sb = _sb()
|
|
70
|
+
did = _device_id(params["sender_id"], params["channel"])
|
|
71
|
+
radius = params.get("radius_m", 500)
|
|
72
|
+
|
|
73
|
+
# Rate limit
|
|
74
|
+
now = time.time()
|
|
75
|
+
if did in _last_scan and now - _last_scan[did] < SCAN_DEBOUNCE_S:
|
|
76
|
+
return _ok({"nearby": [], "message": "刚刚才扫描过,稍等一会儿再试。", "rate_limited": True})
|
|
77
|
+
_last_scan[did] = now
|
|
78
|
+
|
|
79
|
+
flat, flng = _fuzzy(params["lat"], params["lng"])
|
|
80
|
+
|
|
81
|
+
# Update own location
|
|
82
|
+
sb.rpc("upsert_profile_location", {
|
|
83
|
+
"p_device_id": did, "p_lng": flng, "p_lat": flat,
|
|
84
|
+
}).execute()
|
|
85
|
+
|
|
86
|
+
# Query nearby
|
|
87
|
+
resp = sb.rpc("nearby_profiles", {
|
|
88
|
+
"p_lat": flat, "p_lng": flng, "p_radius_m": radius,
|
|
89
|
+
}).execute()
|
|
90
|
+
|
|
91
|
+
others = [p for p in (resp.data or []) if p.get("device_id") != did]
|
|
92
|
+
|
|
93
|
+
if not others:
|
|
94
|
+
return _ok({"nearby": [], "message": f"在 {radius}m 范围内没有发现其他人。"})
|
|
95
|
+
|
|
96
|
+
return _ok({
|
|
97
|
+
"nearby": [
|
|
98
|
+
{
|
|
99
|
+
"device_id": p.get("device_id"),
|
|
100
|
+
"emoji": p.get("emoji") or "👤",
|
|
101
|
+
"name": p.get("display_name") or "匿名",
|
|
102
|
+
"line1": p.get("line1"),
|
|
103
|
+
"line2": p.get("line2"),
|
|
104
|
+
"line3": p.get("line3"),
|
|
105
|
+
}
|
|
106
|
+
for p in others
|
|
107
|
+
],
|
|
108
|
+
"total": len(others),
|
|
109
|
+
"radius_m": radius,
|
|
110
|
+
"instruction": "根据你对用户的了解,判断哪些人值得推荐,为每个推荐写一句个性化的匹配理由。",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def handle_profile(params: dict) -> str:
|
|
115
|
+
sb = _sb()
|
|
116
|
+
did = _device_id(params["sender_id"], params["channel"])
|
|
117
|
+
|
|
118
|
+
if params["action"] == "get":
|
|
119
|
+
resp = sb.rpc("get_profile", {"p_device_id": did}).execute()
|
|
120
|
+
if not resp.data:
|
|
121
|
+
return _ok({"exists": False, "message": "你还没有名片。告诉我你的名字、emoji、三句话,我帮你创建。"})
|
|
122
|
+
return _ok({"exists": True, "profile": resp.data})
|
|
123
|
+
|
|
124
|
+
# set
|
|
125
|
+
resp = sb.rpc("upsert_profile", {
|
|
126
|
+
"p_device_id": did,
|
|
127
|
+
"p_display_name": params.get("display_name"),
|
|
128
|
+
"p_emoji": params.get("emoji"),
|
|
129
|
+
"p_line1": params.get("line1"),
|
|
130
|
+
"p_line2": params.get("line2"),
|
|
131
|
+
"p_line3": params.get("line3"),
|
|
132
|
+
"p_visible": params.get("visible", True),
|
|
133
|
+
}).execute()
|
|
134
|
+
|
|
135
|
+
if resp.data:
|
|
136
|
+
return _ok({"updated": True, "profile": resp.data})
|
|
137
|
+
return _ok({"error": "upsert_profile failed"})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def handle_accept(params: dict) -> str:
|
|
141
|
+
sb = _sb()
|
|
142
|
+
did = _device_id(params["sender_id"], params["channel"])
|
|
143
|
+
target = params["target_device_id"]
|
|
144
|
+
|
|
145
|
+
sb.rpc("upsert_match", {
|
|
146
|
+
"p_device_id_a": did,
|
|
147
|
+
"p_device_id_b": target,
|
|
148
|
+
"p_status": "accepted",
|
|
149
|
+
"p_contact_info": params.get("contact_info"),
|
|
150
|
+
}).execute()
|
|
151
|
+
|
|
152
|
+
# Check mutual
|
|
153
|
+
resp = sb.rpc("get_my_matches", {"p_device_id": did}).execute()
|
|
154
|
+
matches = resp.data or []
|
|
155
|
+
reverse = next(
|
|
156
|
+
(m for m in matches if m.get("device_id_a") == target and m.get("device_id_b") == did),
|
|
157
|
+
None,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if reverse:
|
|
161
|
+
contact = reverse.get("contact_info_a")
|
|
162
|
+
msg = f"双方都接受了!对方分享的联系方式:{contact}" if contact else "双方都接受了!但对方还没有分享联系方式。"
|
|
163
|
+
return _ok({"accepted": True, "mutual": True, "their_contact": contact, "message": msg})
|
|
164
|
+
|
|
165
|
+
return _ok({
|
|
166
|
+
"accepted": True,
|
|
167
|
+
"mutual": False,
|
|
168
|
+
"message": "已接受。等对方也接受后,你们就可以交换联系方式了。",
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def handle_checkin(params: dict) -> str:
|
|
173
|
+
sb = _sb()
|
|
174
|
+
did = _device_id(params["sender_id"], params["channel"])
|
|
175
|
+
flat, flng = _fuzzy(params["lat"], params["lng"])
|
|
176
|
+
|
|
177
|
+
# Check profile exists
|
|
178
|
+
prof = sb.rpc("get_profile", {"p_device_id": did}).execute()
|
|
179
|
+
if not prof.data:
|
|
180
|
+
return _ok({"checked_in": False, "message": "你还没有名片,先创建一个吧。"})
|
|
181
|
+
|
|
182
|
+
sb.rpc("upsert_profile_location", {
|
|
183
|
+
"p_device_id": did, "p_lng": flng, "p_lat": flat,
|
|
184
|
+
}).execute()
|
|
185
|
+
|
|
186
|
+
place = f" ({params['place_name']})" if params.get("place_name") else ""
|
|
187
|
+
return _ok({"checked_in": True, "message": f"已签到{place} 📍 现在附近的人扫描就能看到你了。"})
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def handle_check_matches(params: dict) -> str:
|
|
191
|
+
sb = _sb()
|
|
192
|
+
did = _device_id(params["sender_id"], params["channel"])
|
|
193
|
+
|
|
194
|
+
resp = sb.rpc("get_my_matches", {"p_device_id": did}).execute()
|
|
195
|
+
all_matches = resp.data or []
|
|
196
|
+
|
|
197
|
+
if not all_matches:
|
|
198
|
+
return _ok({"mutual_matches": [], "incoming_accepts": [], "message": "目前没有进行中的匹配。"})
|
|
199
|
+
|
|
200
|
+
my = [m for m in all_matches if m.get("device_id_a") == did]
|
|
201
|
+
incoming = [m for m in all_matches if m.get("device_id_b") == did]
|
|
202
|
+
|
|
203
|
+
# Mutual
|
|
204
|
+
mutual = []
|
|
205
|
+
for m in my:
|
|
206
|
+
rev = next((i for i in incoming if i.get("device_id_a") == m.get("device_id_b")), None)
|
|
207
|
+
if rev:
|
|
208
|
+
prof = sb.rpc("get_profile", {"p_device_id": m["device_id_b"]}).execute()
|
|
209
|
+
p = prof.data or {}
|
|
210
|
+
mutual.append({
|
|
211
|
+
"device_id": m["device_id_b"],
|
|
212
|
+
"name": p.get("display_name") or "匿名",
|
|
213
|
+
"emoji": p.get("emoji") or "👤",
|
|
214
|
+
"their_contact": rev.get("contact_info_a"),
|
|
215
|
+
"you_shared": m.get("contact_info_a"),
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
# Incoming only
|
|
219
|
+
inc_only = []
|
|
220
|
+
for m in incoming:
|
|
221
|
+
already = next((x for x in my if x.get("device_id_b") == m.get("device_id_a")), None)
|
|
222
|
+
if not already:
|
|
223
|
+
prof = sb.rpc("get_profile", {"p_device_id": m["device_id_a"]}).execute()
|
|
224
|
+
p = prof.data or {}
|
|
225
|
+
inc_only.append({
|
|
226
|
+
"device_id": m["device_id_a"],
|
|
227
|
+
"name": p.get("display_name") or "匿名",
|
|
228
|
+
"emoji": p.get("emoji") or "👤",
|
|
229
|
+
"line1": p.get("line1"),
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
msgs = []
|
|
233
|
+
if mutual:
|
|
234
|
+
msgs.append(f"{len(mutual)} 个双向匹配!可以交换联系方式了")
|
|
235
|
+
if inc_only:
|
|
236
|
+
msgs.append(f"{len(inc_only)} 个人想认识你,等你回应")
|
|
237
|
+
if not msgs:
|
|
238
|
+
msgs.append("你接受了一些匹配,但对方还没有回应。耐心等等 ⏳")
|
|
239
|
+
|
|
240
|
+
return _ok({
|
|
241
|
+
"mutual_matches": mutual,
|
|
242
|
+
"incoming_accepts": inc_only,
|
|
243
|
+
"message": ";".join(msgs),
|
|
244
|
+
})
|