Coverage for integrations / social / encounter_api.py: 88.1%

328 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2HevolveSocial - Encounter Icebreaker Blueprint (PR-A — DB-backed). 

3 

4Physical-world P2P encounter flow: two nearby Nunba users both set 

5'discoverable' on Hevolve Android, their phones do autonomous BLE 

6sighting correlation via close-range RSSI + dwell + compass alignment, 

7each user swipes like/dislike on the other's diffusion-styled avatar 

8(NO real photo, NO camera, NO upload), and a mutual-like fires the 

9encounter_icebreaker_agent to draft a warm opener for user approval. 

10 

11Full design: Claude-memory/project_encounter_icebreaker.md 

12Seeded agent goal: integrations.agent_engine.goal_seeding 

13 .SEED_BOOTSTRAP_GOALS[slug='encounter_icebreaker_agent'] 

14 

15Persistence (v39 — see migrations.py): 

16 * discoverable_prefs per-user toggle state + rotating pubkey 

17 * encounter_sightings ephemeral pre-match swipe state (24h TTL) 

18 * encounters (extended) durable post-match record with 

19 context_type='ble', lat/lng map pin, 

20 payload JSON for per-side icebreaker state. 

21 Mutual matches upsert into the canonical `encounters` table so they 

22 participate in the same bond_level / is_mutual_aware progression as 

23 community / post / region encounters — single graph, single SwarmCanvas 

24 surface, no parallel match concept. 

25 

26Endpoints all mounted at /api/social/encounter/* (JWT-auth required): 

27 

28 POST /discoverable enable/disable broadcast + TTL + age gate 

29 GET /discoverable current state + remaining TTL + toggle count 

30 POST /sighting phone reports a BLE sighting; returns swipe card 

31 POST /swipe like/dislike decision (signed event) 

32 GET /matches list of MUTUAL matches (one-sided never leaks) 

33 GET /map-pins post-match encounter pins the user kept visible 

34 POST /icebreaker/approve send approved draft (agent integration: PR-C) 

35 POST /icebreaker/decline reject draft; agent learns from reason 

36 POST /register-pubkey phone registers current rotating pubkey 

37 GET /topics WAMP topic constants 

38 

39Invariants enforced server-side (the blocking privacy gates): 

40 

41 1. One-sided likes are write-only from the liker's perspective — no 

42 endpoint returns them. The likee never learns they were liked 

43 unless THEY also swiped like within the match window. 

44 2. A match row is created only when BOTH sightings are within 

45 ENCOUNTER_MATCH_WINDOW_SEC of each other AND both swiped 'like'. 

46 3. Location is captured at match time from the sightings, never 

47 from the user's current reported location (prevents forged pins). 

48 4. Discoverable default OFF, auto-expires after ENCOUNTER_DISCOVERABLE 

49 _TTL_SEC, max ENCOUNTER_DISCOVERABLE_MAX_TOGGLES_24H per day. 

50 5. 18+ age claim required at toggle time. 

51 6. All pubkeys are rotating (scheme rotates every 

52 ENCOUNTER_PUBKEY_ROTATION_SEC on the phone); server stores only 

53 the rotating value, never the user's master identity. 

54""" 

55from __future__ import annotations 

56 

57import hashlib 

58import logging 

59import secrets 

60from datetime import datetime, timedelta 

61from typing import Any, Optional 

62 

63from flask import Blueprint, g, jsonify, request 

64from sqlalchemy.orm.attributes import flag_modified 

65 

66from core.constants import ( 

67 ENCOUNTER_DISCOVERABLE_MAX_TOGGLES_24H, 

68 ENCOUNTER_DISCOVERABLE_TTL_SEC, 

69 ENCOUNTER_DRAFT_MAX_CHARS, 

70 ENCOUNTER_MATCH_WINDOW_SEC, 

71 ENCOUNTER_SIGHTING_EXPIRES_SEC, 

72 ENCOUNTER_TOPIC_ICEBREAKER, 

73 ENCOUNTER_TOPIC_MATCH, 

74 ENCOUNTER_TOPIC_SIGHTING, 

75 ENCOUNTER_TOPIC_SWIPE, 

76) 

77 

78from .auth import require_auth 

79from .models import ( 

80 DiscoverablePref, 

81 Encounter, 

82 EncounterSighting, 

83) 

84 

85logger = logging.getLogger('hevolve_social') 

86 

87encounter_bp = Blueprint('encounter', __name__, url_prefix='/api/social') 

88 

89 

90# ────────────────────────────────────────────────────────────────────── 

91# Tiny helpers — response shape mirrors the rest of /api/social/*. 

92# ────────────────────────────────────────────────────────────────────── 

93 

94def _ok(data: Any = None, meta: Any = None, status: int = 200): 

95 r: dict[str, Any] = {'success': True} 

96 if data is not None: 

97 r['data'] = data 

98 if meta is not None: 

99 r['meta'] = meta 

100 return jsonify(r), status 

101 

102 

103def _err(msg: str, status: int = 400): 

104 return jsonify({'success': False, 'error': msg}), status 

105 

106 

107def _json() -> dict[str, Any]: 

108 return request.get_json(force=True, silent=True) or {} 

109 

110 

111def _now_dt() -> datetime: 

112 return datetime.utcnow() 

113 

114 

115def _user_id() -> Optional[str]: 

116 """Resolve current user_id as string (matches schema's String(64)). 

117 

118 Auth populates `g.user` with an id that may be int (legacy local 

119 test fixtures) or str (real DB rows). Coerce to str so DB queries 

120 match the FK column type. Returns None if not authenticated. 

121 """ 

122 user = getattr(g, 'user', None) 

123 if user is None: 

124 return None 

125 uid = getattr(user, 'id', None) 

126 if uid is None and isinstance(user, dict): 

127 uid = user.get('id') 

128 return str(uid) if uid is not None else None 

129 

130 

131def _new_id(prefix: str) -> str: 

132 """16-byte urlsafe id. Not a secret — used as a handle only.""" 

133 return f"{prefix}_{secrets.token_urlsafe(12)}" 

134 

135 

136def _ble_pair_context_id(sighting_a_id: str, sighting_b_id: str) -> str: 

137 """Deterministic context_id for the BLE match's encounters row. 

138 

139 Hashes the two sighting ids sorted so (A,B) and (B,A) collapse to 

140 the same row. sha1 is fine — this is just a discriminator, not a 

141 secret. Truncate to 32 chars to fit in encounters.context_id 

142 (VARCHAR(64)). 

143 """ 

144 combined = ":".join(sorted([sighting_a_id, sighting_b_id])) 

145 return hashlib.sha1(combined.encode('utf-8')).hexdigest()[:32] 

146 

147 

148def _icebreaker_payload_init() -> dict[str, Any]: 

149 """Initial JSON payload for a fresh BLE encounters row.""" 

150 return { 

151 'icebreaker_a': { 

152 'status': 'pending', 'text': None, 

153 'sent_at': None, 'decline_reason': None, 

154 }, 

155 'icebreaker_b': { 

156 'status': 'pending', 'text': None, 

157 'sent_at': None, 'decline_reason': None, 

158 }, 

159 'map_pin_visible': True, 

160 } 

161 

162 

163def _icebreaker_side_for(match: Encounter, uid: str) -> Optional[str]: 

164 """Return 'a' or 'b' for which side of the match `uid` represents.""" 

165 if match.user_a_id == uid: 

166 return 'a' 

167 if match.user_b_id == uid: 

168 return 'b' 

169 return None 

170 

171 

172# ────────────────────────────────────────────────────────────────────── 

173# WAMP publishers — routed via core.peer_link.message_bus, same path 

174# chat_messages.publish_new uses (chat_messages.py:282). Lazy import 

175# + best-effort: a missing message_bus must never break the request. 

176# 

177# Topic format: f"{ENCOUNTER_TOPIC_*}.{user_id}" — per-user suffix 

178# matches the chat-sync convention (chat_messages.py:300) and the 

179# wamp_router subscription-authorization pattern (wamp_router.py — every 

180# topic ending with `.<viewer_uid>` is delivered only to that viewer). 

181# ────────────────────────────────────────────────────────────────────── 

182 

183 

184def _publish_to_topic(topic: str, payload: dict) -> None: 

185 """Publish to a single WAMP topic via message_bus. Silent on failure 

186 (missing peer_link is not a request failure).""" 

187 try: 

188 from core.peer_link.message_bus import get_message_bus 

189 except ImportError: 

190 logger.debug('encounter_api: message_bus unavailable for %s', topic) 

191 return 

192 try: 

193 bus = get_message_bus() 

194 bus.publish(topic, payload) 

195 except Exception as exc: # noqa: BLE001 

196 logger.debug('encounter_api: publish %s failed: %s', topic, exc) 

197 

198 

199def _publish_match(match: Encounter) -> None: 

200 """Fire encounter.match WAMP for both participants. One per 

201 user_id — the wamp_router authorization layer confines each 

202 topic to its named viewer.""" 

203 payload = _match_to_dict(match) 

204 for uid in (match.user_a_id, match.user_b_id): 

205 if uid: 

206 _publish_to_topic(f"{ENCOUNTER_TOPIC_MATCH}.{uid}", payload) 

207 

208 

209def _publish_icebreaker(match: Encounter, side: str, status: str) -> None: 

210 """Fire encounter.icebreaker WAMP for both participants when a side 

211 flips to sent / declined. The likee learns about a sent draft; 

212 the agent on either device learns about a decline (so it can 

213 update its memory_graph learn-from-decline signal).""" 

214 p = match.payload or {} 

215 payload = { 

216 'match_id': match.id, 

217 'side': side, 

218 'status': status, 

219 'icebreaker_a': p.get('icebreaker_a') or {}, 

220 'icebreaker_b': p.get('icebreaker_b') or {}, 

221 } 

222 for uid in (match.user_a_id, match.user_b_id): 

223 if uid: 

224 _publish_to_topic( 

225 f"{ENCOUNTER_TOPIC_ICEBREAKER}.{uid}", payload, 

226 ) 

227 

228 

229# ────────────────────────────────────────────────────────────────────── 

230# Test hook — kept for fixture compatibility; each test creates a fresh 

231# in-memory SQLite engine so this is now a no-op. Preserved so the 

232# existing `app` fixture's `encounter_api.ENCOUNTER_STORE.clear()` call 

233# does not need to change shape during the v39 swap. 

234# ────────────────────────────────────────────────────────────────────── 

235 

236class _NoopStore: 

237 """Vestige of the pre-v39 in-memory store. All persistence now 

238 lives in the DB; tests get a fresh engine per fixture.""" 

239 

240 def clear(self) -> None: # noqa: D401 — kept for fixture parity 

241 return None 

242 

243 

244ENCOUNTER_STORE = _NoopStore() 

245 

246 

247# ────────────────────────────────────────────────────────────────────── 

248# /discoverable — toggle the BLE broadcast + age + TTL state. 

249# ────────────────────────────────────────────────────────────────────── 

250 

251@encounter_bp.route('/encounter/discoverable', methods=['GET']) 

252@require_auth 

253def get_discoverable(): 

254 uid = _user_id() 

255 if uid is None: 

256 return _err('unauthenticated', 401) 

257 pref = g.db.query(DiscoverablePref).filter_by(user_id=uid).first() 

258 now = _now_dt() 

259 if not pref: 

260 return _ok({ 

261 'enabled': False, 

262 'expires_at': None, 

263 'remaining_sec': 0, 

264 'toggle_count_24h': 0, 

265 'age_claim_18': False, 

266 'face_visible': False, 

267 'avatar_style': 'studio_ghibli', 

268 'vibe_tags': [], 

269 }) 

270 remaining = ( 

271 max(0, int((pref.expires_at - now).total_seconds())) 

272 if pref.expires_at else 0 

273 ) 

274 still_on = bool(pref.enabled) and remaining > 0 

275 return _ok({ 

276 'enabled': still_on, 

277 'expires_at': pref.expires_at.isoformat() if pref.expires_at else None, 

278 'remaining_sec': remaining, 

279 'toggle_count_24h': pref.toggle_count_24h or 0, 

280 'age_claim_18': bool(pref.age_claim_18), 

281 'face_visible': bool(pref.face_visible), 

282 'avatar_style': pref.avatar_style or 'studio_ghibli', 

283 'vibe_tags': pref.vibe_tags or [], 

284 }) 

285 

286 

287@encounter_bp.route('/encounter/discoverable', methods=['POST']) 

288@require_auth 

289def set_discoverable(): 

290 """Turn discoverable on/off. Server-side invariants: 

291 * 18+ age claim required to enable 

292 * TTL capped at ENCOUNTER_DISCOVERABLE_TTL_SEC 

293 * Max ENCOUNTER_DISCOVERABLE_MAX_TOGGLES_24H toggles per 24h 

294 """ 

295 uid = _user_id() 

296 if uid is None: 

297 return _err('unauthenticated', 401) 

298 body = _json() 

299 enable = bool(body.get('enabled', False)) 

300 ttl = int(body.get('ttl_sec', ENCOUNTER_DISCOVERABLE_TTL_SEC)) 

301 if ttl <= 0 or ttl > ENCOUNTER_DISCOVERABLE_TTL_SEC: 

302 ttl = ENCOUNTER_DISCOVERABLE_TTL_SEC 

303 age_claim = bool(body.get('age_claim_18', False)) 

304 face_visible = bool(body.get('face_visible', False)) 

305 avatar_style = str(body.get('avatar_style', 'studio_ghibli'))[:64] 

306 vibe_tags = body.get('vibe_tags', []) or [] 

307 if not isinstance(vibe_tags, list): 

308 return _err('vibe_tags must be a list of strings') 

309 vibe_tags = [str(t)[:40] for t in vibe_tags[:10]] 

310 

311 now = _now_dt() 

312 pref = g.db.query(DiscoverablePref).filter_by(user_id=uid).first() 

313 if pref is None: 

314 pref = DiscoverablePref( 

315 user_id=uid, 

316 toggle_window_start=now, 

317 toggle_count_24h=0, 

318 ) 

319 g.db.add(pref) 

320 

321 # 24h sliding toggle-count window. 

322 if pref.toggle_window_start and \ 

323 (now - pref.toggle_window_start).total_seconds() > 24 * 3600: 

324 pref.toggle_window_start = now 

325 pref.toggle_count_24h = 0 

326 if (pref.toggle_count_24h or 0) >= ENCOUNTER_DISCOVERABLE_MAX_TOGGLES_24H: 

327 return _err( 

328 f'toggle limit reached ' 

329 f'({ENCOUNTER_DISCOVERABLE_MAX_TOGGLES_24H} per 24h)', 

330 429, 

331 ) 

332 if enable and not age_claim: 

333 return _err('age_claim_18 must be true to enable discoverable', 403) 

334 

335 pref.enabled = enable 

336 pref.enabled_at = now if enable else pref.enabled_at 

337 pref.expires_at = (now + timedelta(seconds=ttl)) if enable else None 

338 pref.age_claim_18 = age_claim 

339 pref.face_visible = face_visible 

340 pref.avatar_style = avatar_style 

341 pref.vibe_tags = vibe_tags 

342 pref.toggle_count_24h = (pref.toggle_count_24h or 0) + 1 

343 pref.last_toggle_at = now 

344 g.db.commit() 

345 

346 return _ok({ 

347 'enabled': enable, 

348 'expires_at': pref.expires_at.isoformat() if pref.expires_at else None, 

349 'remaining_sec': ttl if enable else 0, 

350 }) 

351 

352 

353# ────────────────────────────────────────────────────────────────────── 

354# /sighting — phone reports a BLE sighting. Returns swipe-card payload 

355# if the peer is still discoverable and opted in; returns 404 otherwise 

356# (the likee's phone would simply never produce a card for a non- 

357# discoverable peer — no leak surface). 

358# ────────────────────────────────────────────────────────────────────── 

359 

360@encounter_bp.route('/encounter/sighting', methods=['POST']) 

361@require_auth 

362def report_sighting(): 

363 uid = _user_id() 

364 if uid is None: 

365 return _err('unauthenticated', 401) 

366 body = _json() 

367 peer_pubkey = str(body.get('peer_pubkey', '')).strip().lower() 

368 rssi_peak = int(body.get('rssi_peak', 0)) 

369 dwell_sec = int(body.get('dwell_sec', 0)) 

370 lat = body.get('lat') 

371 lng = body.get('lng') 

372 if not peer_pubkey or len(peer_pubkey) < 16: 

373 return _err('peer_pubkey required (hex, >=16 chars)') 

374 

375 now = _now_dt() 

376 # Resolve peer via the rotating-pubkey reverse index on 

377 # discoverable_prefs.current_pubkey. If the peer hasn't registered 

378 # this pubkey OR isn't discoverable OR has expired → 404 with a 

379 # neutral message. No information is leaked about which case it is. 

380 peer_pref = g.db.query(DiscoverablePref).filter_by( 

381 current_pubkey=peer_pubkey, 

382 ).first() 

383 if not peer_pref: 

384 return _err('peer not discoverable', 404) 

385 peer_uid = peer_pref.user_id 

386 if peer_uid == uid: 

387 return _err('self-sighting rejected') 

388 if ( 

389 not peer_pref.enabled 

390 or not peer_pref.expires_at 

391 or peer_pref.expires_at < now 

392 ): 

393 return _err('peer not discoverable', 404) 

394 

395 sighting = EncounterSighting( 

396 id=_new_id('sight'), 

397 owner_user_id=uid, 

398 peer_user_id=peer_uid, 

399 peer_pubkey=peer_pubkey, 

400 rssi_peak=rssi_peak, 

401 dwell_sec=dwell_sec, 

402 lat=float(lat) if lat is not None else None, 

403 lng=float(lng) if lng is not None else None, 

404 sighted_at=now, 

405 swipe_decision='pending', 

406 expires_at=now + timedelta(seconds=ENCOUNTER_SIGHTING_EXPIRES_SEC), 

407 ) 

408 g.db.add(sighting) 

409 g.db.commit() 

410 

411 return _ok({ 

412 'sighting_id': sighting.id, 

413 'peer_anon_id': peer_pubkey[:12], 

414 'avatar_style': peer_pref.avatar_style or 'studio_ghibli', 

415 'vibe_tags': peer_pref.vibe_tags or [], 

416 'face_visible': bool(peer_pref.face_visible), 

417 'expires_at': sighting.expires_at.isoformat(), 

418 }) 

419 

420 

421# ────────────────────────────────────────────────────────────────────── 

422# /swipe — like/dislike decision. Server checks for a mutual like 

423# within ENCOUNTER_MATCH_WINDOW_SEC and, if found, upserts an encounters 

424# row with context_type='ble'. ONE-SIDED LIKES NEVER LEAK — they live 

425# only on the liker's sighting row as swipe_decision='like' with no 

426# peer-side visibility. The response shape is symmetric: even when no 

427# match, the client gets the same fields aside from the match_id. 

428# ────────────────────────────────────────────────────────────────────── 

429 

430@encounter_bp.route('/encounter/swipe', methods=['POST']) 

431@require_auth 

432def swipe(): 

433 uid = _user_id() 

434 if uid is None: 

435 return _err('unauthenticated', 401) 

436 body = _json() 

437 sighting_id = str(body.get('sighting_id', '')) 

438 decision = str(body.get('decision', '')).lower() 

439 if decision not in {'like', 'dislike'}: 

440 return _err("decision must be 'like' or 'dislike'") 

441 if not sighting_id: 

442 return _err('sighting_id required') 

443 

444 now = _now_dt() 

445 sighting = g.db.query(EncounterSighting).filter_by(id=sighting_id).first() 

446 if not sighting or sighting.owner_user_id != uid: 

447 return _err('sighting not found', 404) 

448 if sighting.expires_at and sighting.expires_at < now: 

449 return _err('sighting expired', 410) 

450 if sighting.swipe_decision != 'pending': 

451 return _err('already swiped', 409) 

452 

453 sighting.swipe_decision = decision 

454 matched_id: Optional[str] = None 

455 

456 if decision == 'like': 

457 peer_uid = sighting.peer_user_id 

458 # Reciprocal-like check within match window. 

459 window_start = sighting.sighted_at - timedelta( 

460 seconds=ENCOUNTER_MATCH_WINDOW_SEC, 

461 ) 

462 window_end = sighting.sighted_at + timedelta( 

463 seconds=ENCOUNTER_MATCH_WINDOW_SEC, 

464 ) 

465 reciprocal = g.db.query(EncounterSighting).filter( 

466 EncounterSighting.id != sighting_id, 

467 EncounterSighting.owner_user_id == peer_uid, 

468 EncounterSighting.peer_user_id == uid, 

469 EncounterSighting.swipe_decision == 'like', 

470 EncounterSighting.sighted_at >= window_start, 

471 EncounterSighting.sighted_at <= window_end, 

472 ).first() 

473 if reciprocal is not None: 

474 # Mutual match — write canonical encounters row. Pin at 

475 # the midpoint of the two sighting locations (if both 

476 # reported lat/lng). Canonical (user_a, user_b) ordering 

477 # so we never double-create rows for (A,B) and (B,A). 

478 a_uid, b_uid = sorted((uid, peer_uid)) 

479 la, lb = sighting.lat, reciprocal.lat 

480 lat = ((la + lb) / 2) if (la is not None and lb is not None) else None 

481 lga, lgb = sighting.lng, reciprocal.lng 

482 lng = ((lga + lgb) / 2) if (lga is not None and lgb is not None) else None 

483 ctx_id = _ble_pair_context_id(sighting.id, reciprocal.id) 

484 existing = g.db.query(Encounter).filter_by( 

485 user_a_id=a_uid, 

486 user_b_id=b_uid, 

487 context_type='ble', 

488 context_id=ctx_id, 

489 ).first() 

490 if existing is not None: 

491 # Re-swipe inside the same match-window: bind the 

492 # already-persisted Encounter so the publisher below 

493 # has a row to broadcast. Without this assignment 

494 # `match` is unbound and `_publish_match(match)` raises 

495 # UnboundLocalError on the duplicate-mutual-like path. 

496 match = existing 

497 else: 

498 match = Encounter( 

499 id=_new_id('match'), 

500 user_a_id=a_uid, 

501 user_b_id=b_uid, 

502 context_type='ble', 

503 context_id=ctx_id, 

504 encounter_count=1, 

505 is_mutual_aware=True, 

506 bond_level=1, 

507 lat=lat, 

508 lng=lng, 

509 payload=_icebreaker_payload_init(), 

510 first_at=now, 

511 latest_at=now, 

512 ) 

513 g.db.add(match) 

514 matched_id = match.id 

515 

516 g.db.commit() 

517 if matched_id is not None: 

518 # Live notification to BOTH matched users — the SPA / RN 

519 # subscribers open the icebreaker draft modal on receipt. 

520 # Best-effort: a missing message_bus must not fail the swipe. 

521 _publish_match(match) 

522 return _ok({ 

523 'sighting_id': sighting_id, 

524 'decision': decision, 

525 # Matched flag tells the liker's CLIENT that *something* 

526 # happened, but the response is SYMMETRIC: even when there's 

527 # no mutual, this shape is identical aside from 'match_id'. 

528 # We do NOT include any signal about whether the peer swiped 

529 # dislike — only the positive-match signal is surfaced, and 

530 # only to the two matched parties. 

531 'match_id': matched_id, 

532 }) 

533 

534 

535# ────────────────────────────────────────────────────────────────────── 

536# /matches — list mutual matches the user is part of. 

537# Reads the canonical `encounters` table filtered to context_type='ble'. 

538# ────────────────────────────────────────────────────────────────────── 

539 

540def _match_to_dict(match: Encounter) -> dict[str, Any]: 

541 p = match.payload or {} 

542 matched_at = match.first_at 

543 return { 

544 'id': match.id, 

545 'user_a': match.user_a_id, 

546 'user_b': match.user_b_id, 

547 'lat': match.lat, 

548 'lng': match.lng, 

549 'matched_at': matched_at.timestamp() if matched_at else None, 

550 'icebreaker_a_status': (p.get('icebreaker_a') or {}).get( 

551 'status', 'pending', 

552 ), 

553 'icebreaker_b_status': (p.get('icebreaker_b') or {}).get( 

554 'status', 'pending', 

555 ), 

556 'map_pin_visible': bool(p.get('map_pin_visible', True)), 

557 } 

558 

559 

560@encounter_bp.route('/encounter/matches', methods=['GET']) 

561@require_auth 

562def list_matches(): 

563 uid = _user_id() 

564 if uid is None: 

565 return _err('unauthenticated', 401) 

566 rows = g.db.query(Encounter).filter( 

567 Encounter.context_type == 'ble', 

568 ((Encounter.user_a_id == uid) | (Encounter.user_b_id == uid)), 

569 ).order_by(Encounter.latest_at.desc()).all() 

570 matches = [_match_to_dict(r) for r in rows] 

571 return _ok({'matches': matches, 'count': len(matches)}) 

572 

573 

574@encounter_bp.route('/encounter/map-pins', methods=['GET']) 

575@require_auth 

576def map_pins(): 

577 uid = _user_id() 

578 if uid is None: 

579 return _err('unauthenticated', 401) 

580 rows = g.db.query(Encounter).filter( 

581 Encounter.context_type == 'ble', 

582 ((Encounter.user_a_id == uid) | (Encounter.user_b_id == uid)), 

583 Encounter.lat.isnot(None), 

584 Encounter.lng.isnot(None), 

585 ).all() 

586 pins = [] 

587 for r in rows: 

588 p = r.payload or {} 

589 if not bool(p.get('map_pin_visible', True)): 

590 continue 

591 pins.append({ 

592 'match_id': r.id, 

593 'lat': r.lat, 

594 'lng': r.lng, 

595 'matched_at': r.first_at.timestamp() if r.first_at else None, 

596 }) 

597 return _ok({'pins': pins, 'count': len(pins)}) 

598 

599 

600# ────────────────────────────────────────────────────────────────────── 

601# /icebreaker — approve or decline a draft. The actual agent that 

602# PRODUCES the draft lives in integrations/agent_engine/ and lands in 

603# PR-C; until then, these endpoints accept user-supplied draft text so 

604# the RN/React UI can be integration-tested end-to-end. 

605# Per-side state lives in the encounter row's `payload` JSON column. 

606# ────────────────────────────────────────────────────────────────────── 

607 

608# /icebreaker/draft — generate a candidate opener for the match without 

609# sending it. Wraps icebreaker_service.draft_icebreaker, which is 

610# also the entry point the seeded encounter_icebreaker_agent uses 

611# server-side when the match WAMP topic fires. This endpoint exists 

612# so the SPA can request a fresh draft on demand (e.g., user taps 

613# "regenerate" before approving). Returns the same shape the agent 

614# publishes on com.hevolve.encounter.icebreaker. 

615 

616def _has_cloud_drafting_consent(user_id: str) -> bool: 

617 """Lookup helper for icebreaker_service.draft_icebreaker. 

618 

619 True iff the user has an active UserConsent row for cloud- 

620 capability scoped to the encounter_icebreaker feature (or '*'). 

621 Used only when the HARTOS process is in central topology — the 

622 service ignores the check on flat / regional (user-trusted edge). 

623 """ 

624 from .models import UserConsent 

625 try: 

626 row = g.db.query(UserConsent).filter_by( 

627 user_id=user_id, 

628 consent_type='cloud_capability', 

629 granted=True, 

630 ).filter( 

631 UserConsent.scope.in_(['*', 'encounter_icebreaker']), 

632 ).filter(UserConsent.revoked_at.is_(None)).first() 

633 return row is not None 

634 except Exception: # noqa: BLE001 

635 return False 

636 

637 

638@encounter_bp.route('/encounter/icebreaker/draft', methods=['POST']) 

639@require_auth 

640def icebreaker_draft(): 

641 uid = _user_id() 

642 if uid is None: 

643 return _err('unauthenticated', 401) 

644 body = _json() 

645 match_id = str(body.get('match_id', '')) 

646 if not match_id: 

647 return _err('match_id required') 

648 from .icebreaker_service import draft_icebreaker 

649 try: 

650 out = draft_icebreaker( 

651 match_id, uid, g.db, 

652 cloud_consent_check=_has_cloud_drafting_consent, 

653 ) 

654 except PermissionError as pe: 

655 return _err(str(pe), 403) 

656 except ValueError as ve: 

657 return _err(str(ve), 404) 

658 except Exception as exc: # noqa: BLE001 

659 logger.warning( 

660 'encounter.icebreaker draft failed match=%s uid=%s: %s', 

661 match_id, uid, exc, 

662 ) 

663 return _err('draft_failed', 500) 

664 return _ok(out) 

665 

666 

667@encounter_bp.route('/encounter/icebreaker/approve', methods=['POST']) 

668@require_auth 

669def icebreaker_approve(): 

670 uid = _user_id() 

671 if uid is None: 

672 return _err('unauthenticated', 401) 

673 body = _json() 

674 match_id = str(body.get('match_id', '')) 

675 text_val = str(body.get('text', '')).strip() 

676 if not match_id: 

677 return _err('match_id required') 

678 if not text_val: 

679 return _err('text required') 

680 if len(text_val) > ENCOUNTER_DRAFT_MAX_CHARS: 

681 return _err( 

682 f'text exceeds {ENCOUNTER_DRAFT_MAX_CHARS} chars', 413, 

683 ) 

684 

685 match = g.db.query(Encounter).filter_by( 

686 id=match_id, context_type='ble', 

687 ).first() 

688 side = _icebreaker_side_for(match, uid) if match else None 

689 if match is None or side is None: 

690 return _err('match not found', 404) 

691 

692 payload = dict(match.payload or _icebreaker_payload_init()) 

693 side_state = dict(payload.get(f'icebreaker_{side}') or {}) 

694 if side_state.get('status') in {'sent', 'declined'}: 

695 return _err( 

696 f"icebreaker already {side_state.get('status')}", 409, 

697 ) 

698 side_state['status'] = 'sent' 

699 side_state['text'] = text_val 

700 side_state['sent_at'] = _now_dt().timestamp() 

701 payload[f'icebreaker_{side}'] = side_state 

702 match.payload = payload 

703 match.latest_at = _now_dt() 

704 # SQLAlchemy doesn't auto-detect mutation of mutable JSON dict 

705 # values; flag the column dirty so the change persists. 

706 flag_modified(match, 'payload') 

707 g.db.commit() 

708 _publish_icebreaker(match, side, 'sent') 

709 

710 logger.info( 

711 'encounter.icebreaker sent side=%s match=%s len=%d', 

712 side, match_id, len(text_val), 

713 ) 

714 return _ok({'match_id': match_id, 'status': 'sent'}) 

715 

716 

717@encounter_bp.route('/encounter/icebreaker/decline', methods=['POST']) 

718@require_auth 

719def icebreaker_decline(): 

720 uid = _user_id() 

721 if uid is None: 

722 return _err('unauthenticated', 401) 

723 body = _json() 

724 match_id = str(body.get('match_id', '')) 

725 reason = str(body.get('reason', ''))[:400] 

726 if not match_id: 

727 return _err('match_id required') 

728 

729 match = g.db.query(Encounter).filter_by( 

730 id=match_id, context_type='ble', 

731 ).first() 

732 side = _icebreaker_side_for(match, uid) if match else None 

733 if match is None or side is None: 

734 return _err('match not found', 404) 

735 

736 payload = dict(match.payload or _icebreaker_payload_init()) 

737 side_state = dict(payload.get(f'icebreaker_{side}') or {}) 

738 side_state['status'] = 'declined' 

739 side_state['decline_reason'] = reason 

740 payload[f'icebreaker_{side}'] = side_state 

741 match.payload = payload 

742 match.latest_at = _now_dt() 

743 flag_modified(match, 'payload') 

744 g.db.commit() 

745 _publish_icebreaker(match, side, 'declined') 

746 

747 return _ok({'match_id': match_id, 'status': 'declined'}) 

748 

749 

750# ────────────────────────────────────────────────────────────────────── 

751# /register-pubkey — phone registers its current rotating pubkey after 

752# it rotates (every ENCOUNTER_PUBKEY_ROTATION_SEC). This is the 

753# reverse-index sightings query against. Self-scoped to the 

754# authenticated user — peers cannot look up the user's pubkey via this 

755# endpoint. 

756# ────────────────────────────────────────────────────────────────────── 

757 

758@encounter_bp.route('/encounter/register-pubkey', methods=['POST']) 

759@require_auth 

760def register_pubkey(): 

761 uid = _user_id() 

762 if uid is None: 

763 return _err('unauthenticated', 401) 

764 body = _json() 

765 pk = str(body.get('pubkey', '')).strip().lower() 

766 if not pk or len(pk) < 16 or len(pk) > 128: 

767 return _err('pubkey hex 16..128 chars') 

768 now = _now_dt() 

769 pref = g.db.query(DiscoverablePref).filter_by(user_id=uid).first() 

770 if pref is None: 

771 pref = DiscoverablePref( 

772 user_id=uid, 

773 current_pubkey=pk, 

774 pubkey_registered_at=now, 

775 toggle_window_start=now, 

776 ) 

777 g.db.add(pref) 

778 else: 

779 pref.current_pubkey = pk 

780 pref.pubkey_registered_at = now 

781 g.db.commit() 

782 return _ok({'registered_at': now.timestamp()}) 

783 

784 

785# ────────────────────────────────────────────────────────────────────── 

786# Topic constants re-exported for clients that need them (Nunba's 

787# crossbarWorker + RN subscription manager). Single-source import. 

788# ────────────────────────────────────────────────────────────────────── 

789 

790WAMP_TOPICS = { 

791 'sighting': ENCOUNTER_TOPIC_SIGHTING, 

792 'swipe': ENCOUNTER_TOPIC_SWIPE, 

793 'match': ENCOUNTER_TOPIC_MATCH, 

794 'icebreaker': ENCOUNTER_TOPIC_ICEBREAKER, 

795} 

796 

797 

798@encounter_bp.route('/encounter/topics', methods=['GET']) 

799@require_auth 

800def list_topics(): 

801 return _ok({'topics': WAMP_TOPICS})