nomadweb/backend/app/services/social_store.py
eric 5525d04744 Seed match likes/chats for new joiners and split likes received tab.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:30:25 -05:00

869 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Social graph — PocketBase backed (profiles, swipes, matches, DMs)."""
from __future__ import annotations
import json
import secrets
import time
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any
from app.data.social_profiles import CANDIDATE_PROFILES, MATCH_INTENTS
from app.services.pb_client import pb, pb_quote
from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb
LIKE_ACTIONS = frozenset({"like", "right", "superlike"})
FREE_SWIPE_DAILY_LIMIT = 25
VIP_SWIPE_LIMIT = 9999
_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json"
_j: dict[str, Any] = {}
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _today_key() -> str:
return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d")
def _pair_key(a: str, b: str) -> str:
return "|".join(sorted([a, b]))
def _profile_looking_for(record: dict) -> list[str]:
raw = record.get("lookingFor") or record.get("looking_for") or []
cleaned = [x for x in raw if x in MATCH_INTENTS]
return cleaned or ["friends", "explore"]
def _peer_profile(user_id: str) -> dict | None:
for p in CANDIDATE_PROFILES:
if p.get("userId") == user_id:
return p
if use_pb():
row = safe_first("profiles", filter=f"userId={q(user_id)}")
if row:
return {
"id": f"user-{user_id}",
"userId": user_id,
"name": row.get("name", "游民"),
"location": row.get("location", "全球"),
"bio": row.get("bio", ""),
"photo": row.get("photo", "🧑‍💻"),
"tags": row.get("tags") or [],
}
prof = (_jload().get("profiles") or {}).get(user_id)
if prof:
return {"id": f"user-{user_id}", "userId": user_id, **prof}
return None
# ── VIP / membership ───────────────────────────────────────────────────
def is_vip(user_id: str) -> bool:
if use_pb():
row = safe_first("memberships", filter=f"userId={q(user_id)}")
if not row:
return False
return int(row.get("expiresAt") or 0) > int(time.time())
_jload()
m = (_j.get("memberships") or {}).get(user_id)
return bool(m and (m.get("expires_at") or 0) > int(time.time()))
def membership_expires_at(user_id: str) -> int:
if use_pb():
row = safe_first("memberships", filter=f"userId={q(user_id)}")
return int((row or {}).get("expiresAt") or 0)
_jload()
return int(((_j.get("memberships") or {}).get(user_id) or {}).get("expires_at") or 0)
def ensure_membership(user_id: str, days: int = 365) -> None:
exp = int(time.time()) + days * 86400
if use_pb():
row = safe_first("memberships", filter=f"userId={q(user_id)}")
payload = {"userId": user_id, "plan": "vip", "expiresAt": exp}
if row:
safe_update("memberships", row["id"], payload)
else:
safe_create("memberships", payload)
return
_jload()
_j.setdefault("memberships", {})[user_id] = {"plan": "vip", "expires_at": exp, "updated_at": _now_iso()}
_jsave()
# ── Profiles ───────────────────────────────────────────────────────────
def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict:
if use_pb():
row = safe_first("profiles", filter=f"userId={q(user_id)}")
if row:
return row
return safe_create(
"profiles",
{
"userId": user_id,
"name": name,
"location": extra.get("location", "全球"),
"citySlug": extra.get("citySlug", ""),
"gender": extra.get("gender", ""),
"single": extra.get("single", ""),
"bio": extra.get("bio", ""),
"photo": extra.get("photo", "🧑‍💻"),
"tags": extra.get("tags", []),
"lookingFor": extra.get("lookingFor", ["friends", "explore"]),
},
) or {}
_jload()
profiles = _j.setdefault("profiles", {})
if user_id not in profiles:
profiles[user_id] = {
"userId": user_id,
"name": name,
"location": extra.get("location", "全球"),
"citySlug": extra.get("citySlug", ""),
"gender": extra.get("gender", ""),
"single": extra.get("single", ""),
"bio": extra.get("bio", ""),
"photo": extra.get("photo", "🧑‍💻"),
"tags": extra.get("tags", []),
"lookingFor": extra.get("lookingFor", ["friends", "explore"]),
"createdAt": _now_iso(),
}
_jsave()
return profiles[user_id]
def join_member(user_id: str, name: str, payload: dict) -> dict:
profile = get_or_create_profile(
user_id,
name,
location=payload.get("city", "全球"),
citySlug=payload.get("citySlug", ""),
gender=payload.get("gender", ""),
single=payload.get("single", ""),
bio=payload.get("bio", ""),
lookingFor=payload.get("lookingFor", ["friends", "explore"]),
photo=payload.get("photo", "🧑‍💻"),
)
try:
seed_demo_social_activity(user_id)
except Exception:
pass
return profile
def get_public_profile(user_id: str) -> dict | None:
if use_pb():
row = safe_first("profiles", filter=f"userId={q(user_id)}")
if not row:
return None
return {**row, "vip": is_vip(user_id)}
_jload()
p = (_j.get("profiles") or {}).get(user_id)
return {**p, "vip": is_vip(user_id)} if p else None
# ── Candidates / swipes ────────────────────────────────────────────────
def list_candidates(
user_id: str,
intent: str = "friends",
city: str = "",
gender: str = "",
single: str = "",
exclude_swiped: bool = True,
) -> list[dict]:
swiped_ids: set[str] = set()
if exclude_swiped:
if use_pb():
for s in safe_list("swipes", filter=f"userId={q(user_id)}"):
swiped_ids.add(s.get("profileId", ""))
else:
_jload()
for s in _j.get("swipes") or []:
if s.get("userId") == user_id:
swiped_ids.add(s.get("profileId", ""))
pool = list(CANDIDATE_PROFILES)
if use_pb():
for row in safe_list("profiles"):
uid = row.get("userId", "")
if uid and uid != user_id:
pool.append({
"id": f"user-{uid}",
"userId": uid,
"name": row.get("name", "游民"),
"location": row.get("location", "全球"),
"citySlug": row.get("citySlug", ""),
"gender": row.get("gender", ""),
"single": row.get("single", ""),
"bio": row.get("bio", ""),
"photo": row.get("photo", "🧑‍💻"),
"tags": row.get("tags") or [],
"lookingFor": _profile_looking_for(row),
})
else:
_jload()
for uid, prof in (_j.get("profiles") or {}).items():
if uid != user_id:
pool.append({
"id": f"user-{uid}",
"userId": uid,
"name": prof.get("name", "游民"),
"location": prof.get("location", "全球"),
"citySlug": prof.get("citySlug", ""),
"gender": prof.get("gender", ""),
"single": prof.get("single", ""),
"bio": prof.get("bio", ""),
"photo": prof.get("photo", "🧑‍💻"),
"tags": prof.get("tags", []),
"lookingFor": _profile_looking_for(prof),
})
results = []
for p in pool:
if p.get("userId") == user_id or p["id"] in swiped_ids:
continue
if intent and intent not in _profile_looking_for(p):
continue
if city and city not in (p.get("location") or "") and city != p.get("citySlug"):
continue
if gender and is_vip(user_id) and p.get("gender") != gender:
continue
if single and is_vip(user_id) and p.get("single") != single:
continue
results.append({**p, "intent": intent})
return results
def swipe_count_today(user_id: str) -> int:
today = _today_key()
if use_pb():
return sum(
1 for s in safe_list("swipes", filter=f"userId={q(user_id)}")
if (s.get("swipeDate") or "") == today
)
_jload()
return sum(1 for s in _j.get("swipes") or [] if s.get("userId") == user_id and s.get("date") == today)
def get_quota(user_id: str) -> dict:
vip = is_vip(user_id)
limit = VIP_SWIPE_LIMIT if vip else FREE_SWIPE_DAILY_LIMIT
used = swipe_count_today(user_id)
return {"vip": vip, "limit": limit, "used": used, "remaining": max(0, limit - used)}
def _find_profile(profile_id: str) -> dict | None:
for p in CANDIDATE_PROFILES:
if p["id"] == profile_id:
return p
if profile_id.startswith("user-"):
uid = profile_id[5:]
return _peer_profile(uid)
return None
def _has_reciprocal_like(target_user_id: str, my_profile_id: str) -> bool:
if use_pb():
rows = safe_list("swipes", filter=f"userId={q(target_user_id)} && profileId={q(my_profile_id)}")
return any(r.get("action") in LIKE_ACTIONS for r in rows)
_jload()
for s in _j.get("swipes") or []:
if s.get("userId") == target_user_id and s.get("profileId") == my_profile_id:
if s.get("action") in LIKE_ACTIONS:
return True
return False
def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", match: bool = False) -> dict:
pk = _pair_key(user_a, user_b)
if use_pb():
row = safe_first("conversations", filter=f"pairKey={q(pk)}")
if row:
return {"id": row["id"], **row}
created = safe_create(
"conversations",
{
"pairKey": pk,
"userAId": user_a,
"userBId": user_b,
"intent": intent,
"lastMessagePreview": "",
"readState": {user_a: _now_iso(), user_b: _now_iso()},
},
)
return {"id": created["id"], **created} if created else {"id": "", "pairKey": pk}
_jload()
for c in _j.get("conversations") or []:
if c.get("pairKey") == pk:
return c
conv = {
"id": secrets.token_hex(8),
"pairKey": pk,
"type": "match" if match else "direct",
"userAId": user_a,
"userBId": user_b,
"intent": intent,
"lastMessageAt": _now_iso(),
"lastMessagePreview": "",
"readState": {user_a: _now_iso(), user_b: _now_iso()},
}
_j.setdefault("conversations", []).append(conv)
_jsave()
return conv
def _ensure_match(user_id: str, profile: dict, intent: str) -> dict | None:
target_uid = profile.get("userId")
if not target_uid:
return None
pk = _pair_key(user_id, target_uid)
if use_pb():
row = safe_first("match_connections", filter=f"pairKey={q(pk)}")
if row:
return row
conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True)
created = safe_create(
"match_connections",
{
"pairKey": pk,
"userAId": user_id,
"userBId": target_uid,
"profileBId": profile.get("id", ""),
"intent": intent,
"conversationId": conv.get("id", ""),
"matchedAt": _now_iso(),
},
)
return created
_jload()
for m in _j.get("matches") or []:
if m.get("pairKey") == pk:
return m
conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True)
match = {
"id": secrets.token_hex(6),
"pairKey": pk,
"userAId": user_id,
"userBId": target_uid,
"profileBId": profile.get("id"),
"intent": intent,
"conversationId": conv["id"],
"matchedAt": _now_iso(),
}
_j.setdefault("matches", []).append(match)
_jsave()
return match
def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict:
if action not in LIKE_ACTIONS and action != "dislike":
action = "dislike"
if action == "superlike" and not is_vip(user_id):
return {"ok": False, "error": "vip_required"}
qdata = get_quota(user_id)
if not qdata["vip"] and qdata["remaining"] <= 0 and action in LIKE_ACTIONS:
return {"ok": False, "error": "quota_exceeded"}
if use_pb():
dup = safe_first(
"swipes",
filter=f"userId={q(user_id)} && profileId={q(profile_id)}",
)
if dup:
return {"ok": False, "error": "duplicate"}
else:
_jload()
for s in _j.get("swipes") or []:
if s.get("userId") == user_id and s.get("profileId") == profile_id:
return {"ok": False, "error": "duplicate"}
profile = _find_profile(profile_id)
if not profile:
return {"ok": False, "error": "not_found"}
swipe_payload = {
"userId": user_id,
"profileId": profile_id,
"action": action,
"intent": intent,
"swipeDate": _today_key(),
}
if use_pb():
safe_create("swipes", swipe_payload)
else:
_jload()
_j.setdefault("swipes", []).append({**swipe_payload, "id": secrets.token_hex(6), "date": _today_key(), "createdAt": _now_iso()})
_jsave()
matched = None
if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id):
matched = _ensure_match(user_id, profile, intent)
if matched:
from app.services.notify import notify_user
my_prof = get_public_profile(user_id) or {}
peer_name = profile.get("name", "游民")
conv_id = matched.get("conversationId", "")
link = f"/chat/{conv_id}" if conv_id else "/chat"
notify_user(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link, "match")
peer_uid = profile.get("userId")
if peer_uid:
notify_user(
peer_uid,
"匹配成功 🎉",
f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧",
link,
"match",
)
return {"ok": True, "matched": bool(matched), "match": matched}
def undo_last_swipe(user_id: str) -> dict:
if use_pb():
rows = safe_list("swipes", filter=f"userId={q(user_id)}")
rows.sort(key=lambda r: r.get("created", ""), reverse=True)
if not rows:
return {"ok": False, "error": "nothing_to_undo"}
last = rows[0]
try:
pb.delete_record("swipes", last["id"])
except Exception:
pass
return {"ok": True, "removed": last}
_jload()
swipes = _j.get("swipes") or []
for i in range(len(swipes) - 1, -1, -1):
if swipes[i].get("userId") == user_id:
removed = swipes.pop(i)
_jsave()
return {"ok": True, "removed": removed}
return {"ok": False, "error": "nothing_to_undo"}
def list_likes_received(user_id: str) -> list[dict]:
my_pid = f"user-{user_id}"
likers = []
if use_pb():
for s in safe_list("swipes", filter=f"profileId={q(my_pid)}"):
if s.get("action") not in LIKE_ACTIONS:
continue
p = _peer_profile(s.get("userId", ""))
if p:
likers.append(p)
return likers
_jload()
for s in _j.get("swipes") or []:
if s.get("profileId") == my_pid and s.get("action") in LIKE_ACTIONS:
p = _peer_profile(s.get("userId", ""))
if p:
likers.append(p)
return likers
def list_likes(user_id: str) -> list[dict]:
out = []
if use_pb():
for s in safe_list("swipes", filter=f"userId={q(user_id)}"):
if s.get("action") in LIKE_ACTIONS:
p = _find_profile(s.get("profileId", ""))
if p:
out.append(p)
return out
_jload()
for s in _j.get("swipes") or []:
if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS:
p = _find_profile(s.get("profileId", ""))
if p:
out.append(p)
return out
def list_mutual(user_id: str) -> list[dict]:
items = []
if use_pb():
rows = safe_list("match_connections", filter=f"userAId={q(user_id)} || userBId={q(user_id)}")
for m in rows:
peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"]
items.append({**m, "peer": _peer_profile(peer_id), "conversationId": m.get("conversationId")})
return items
_jload()
for m in _j.get("matches") or []:
if user_id in (m.get("userAId"), m.get("userBId")):
peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"]
items.append({**m, "peer": _peer_profile(peer_id), "conversationId": m.get("conversationId")})
return items
def list_conversations(user_id: str) -> list[dict]:
items = []
if use_pb():
rows = safe_list("conversations", filter=f"userAId={q(user_id)} || userBId={q(user_id)}")
rows.sort(key=lambda c: c.get("lastMessageAt") or c.get("created", ""), reverse=True)
for c in rows:
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
read_at = (c.get("readState") or {}).get(user_id, "")
unread = 1 if (c.get("lastMessageAt") or "") > read_at else 0
items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread})
return items
_jload()
for c in _j.get("conversations") or []:
if user_id not in (c.get("userAId"), c.get("userBId")):
continue
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
read_at = (c.get("readState") or {}).get(user_id, "")
unread = 1 if c.get("lastMessageAt", "") > read_at else 0
items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread})
items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True)
return items
def get_conversation(conv_id: str, user_id: str) -> dict | None:
if use_pb():
row = safe_first("conversations", filter=f"id={q(conv_id)}")
if not row or user_id not in (row.get("userAId"), row.get("userBId")):
return None
peer_id = row["userBId"] if row["userAId"] == user_id else row["userAId"]
return {**row, "peer": _peer_profile(peer_id)}
_jload()
for c in _j.get("conversations") or []:
if c["id"] == conv_id and user_id in (c.get("userAId"), c.get("userBId")):
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
return {**c, "peer": _peer_profile(peer_id)}
return None
def list_messages(conv_id: str, user_id: str, limit: int = 50) -> list[dict]:
conv = get_conversation(conv_id, user_id)
if not conv:
return []
if use_pb():
msgs = safe_list("messages", filter=f"conversationId={q(conv_id)}")
msgs.sort(key=lambda m: m.get("created", ""))
read_state = dict(conv.get("readState") or {})
read_state[user_id] = _now_iso()
safe_update("conversations", conv_id, {"readState": read_state})
return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]]
_jload()
msgs = [m for m in _j.get("messages") or [] if m.get("conversationId") == conv_id]
msgs.sort(key=lambda x: x.get("createdAt", ""))
for c in _j.get("conversations") or []:
if c["id"] == conv_id:
c.setdefault("readState", {})[user_id] = _now_iso()
_jsave()
break
return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]]
def send_message(conv_id: str, user_id: str, body: str) -> dict | None:
conv = get_conversation(conv_id, user_id)
if not conv or not body.strip():
return None
body = body.strip()[:2000]
if use_pb():
msg = safe_create(
"messages",
{"conversationId": conv_id, "senderId": user_id, "body": body},
)
if not msg:
return None
read_state = dict(conv.get("readState") or {})
read_state[user_id] = _now_iso()
safe_update(
"conversations",
conv_id,
{"lastMessageAt": _now_iso(), "lastMessagePreview": body[:80], "readState": read_state},
)
return {**msg, "mine": True, "createdAt": _now_iso()}
_jload()
msg = {
"id": secrets.token_hex(8),
"conversationId": conv_id,
"senderId": user_id,
"body": body,
"createdAt": _now_iso(),
}
_j.setdefault("messages", []).append(msg)
for c in _j.get("conversations") or []:
if c["id"] == conv_id:
c["lastMessageAt"] = _now_iso()
c["lastMessagePreview"] = body[:80]
break
_jsave()
return {**msg, "mine": True}
def grant_ebook(user_id: str) -> None:
if not user_id:
return
if use_pb():
existing = safe_first("ebook_entitlements", filter=f"userId={q(user_id)}")
if existing:
safe_update("ebook_entitlements", existing["id"], {"active": True})
else:
safe_create("ebook_entitlements", {"userId": user_id, "active": True, "product": "nomad-code"})
return
_jload()
_j.setdefault("ebook_entitlements", {})[user_id] = {"active": True, "product": "nomad-code", "updated_at": _now_iso()}
_jsave()
def has_ebook(user_id: str) -> bool:
if not user_id:
return False
if use_pb():
row = safe_first("ebook_entitlements", filter=f"userId={q(user_id)} && active=true")
return bool(row)
_jload()
ent = (_j.get("ebook_entitlements") or {}).get(user_id) or {}
return bool(ent.get("active"))
def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool = False) -> dict:
order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}"
status = "paid" if dev_auto_pay else "pending"
order = {
"orderId": order_id,
"userId": user_id,
"payType": pay_type,
"amount": amount,
"status": status,
}
if use_pb():
safe_create("orders", order)
else:
_jload()
_j.setdefault("orders", {})[order_id] = {**order, "id": order_id, "createdAt": _now_iso()}
_jsave()
if dev_auto_pay:
_fulfill_order(user_id, pay_type)
return {"id": order_id, **order, "createdAt": _now_iso()}
def _fulfill_order(user_id: str, pay_type: str) -> None:
if pay_type == "ebook":
grant_ebook(user_id)
else:
ensure_membership(user_id)
def mark_order_paid(order_id: str) -> dict | None:
if use_pb():
row = safe_first("orders", filter=f"orderId={q(order_id)}")
if not row:
return None
safe_update("orders", row["id"], {"status": "paid"})
_fulfill_order(row.get("userId", ""), row.get("payType") or "join")
return {**row, "status": "paid"}
_jload()
order = (_j.get("orders") or {}).get(order_id)
if not order:
return None
order["status"] = "paid"
_fulfill_order(order.get("userId", ""), order.get("payType") or "join")
_jsave()
return order
def seed_demo_social_activity(user_id: str) -> None:
"""Give a real user a few incoming likes + one chat so Connect feels alive."""
if not user_id:
return
my_pid = f"user-{user_id}"
peers = [p for p in CANDIDATE_PROFILES if p.get("userId")][:4]
if not peers:
return
buddy = peers[0]
buddy_uid = buddy["userId"]
already = False
if use_pb():
already = bool(safe_first("swipes", filter=f"userId={q(buddy_uid)} && profileId={q(my_pid)}"))
else:
_jload()
already = bool((_j.get("demo_seeded") or {}).get(user_id)) or any(
s.get("userId") == buddy_uid and s.get("profileId") == my_pid for s in (_j.get("swipes") or [])
)
if already:
return
# Incoming likes from candidates toward this user
for p in peers[:3]:
peer_uid = p["userId"]
if use_pb():
if not safe_first("swipes", filter=f"userId={q(peer_uid)} && profileId={q(my_pid)}"):
safe_create(
"swipes",
{
"userId": peer_uid,
"profileId": my_pid,
"action": "like",
"intent": "friends",
"swipeDate": _today_key(),
},
)
else:
_jload()
swipes = _j.setdefault("swipes", [])
if not any(s.get("userId") == peer_uid and s.get("profileId") == my_pid for s in swipes):
swipes.append(
{
"id": secrets.token_hex(6),
"userId": peer_uid,
"profileId": my_pid,
"action": "like",
"intent": "friends",
"date": _today_key(),
"createdAt": _now_iso(),
}
)
# Mutual match + messages with first peer
buddy_pid = buddy["id"]
if use_pb():
if not safe_first("swipes", filter=f"userId={q(user_id)} && profileId={q(buddy_pid)}"):
safe_create(
"swipes",
{
"userId": user_id,
"profileId": buddy_pid,
"action": "like",
"intent": "friends",
"swipeDate": _today_key(),
},
)
else:
_jload()
swipes = _j.setdefault("swipes", [])
if not any(s.get("userId") == user_id and s.get("profileId") == buddy_pid for s in swipes):
swipes.append(
{
"id": secrets.token_hex(6),
"userId": user_id,
"profileId": buddy_pid,
"action": "like",
"intent": "friends",
"date": _today_key(),
"createdAt": _now_iso(),
}
)
match = _ensure_match(user_id, buddy, "friends")
conv_id = (match or {}).get("conversationId") or ""
if not conv_id:
conv = _ensure_conversation(user_id, buddy_uid, intent="friends", match=True)
conv_id = conv.get("id", "")
hello = f"嗨!我在{buddy.get('location', '旅途中')},看到你也在用 nomadro,交个朋友呀 👋"
reply = "你好!我也刚到,有机会一起去联合办公或 meetup~"
if conv_id:
if use_pb():
msgs = safe_list("messages", filter=f"conversationId={q(conv_id)}")
if not msgs:
safe_create("messages", {"conversationId": conv_id, "senderId": buddy_uid, "body": hello})
safe_create("messages", {"conversationId": conv_id, "senderId": user_id, "body": reply})
safe_update(
"conversations",
conv_id,
{
"lastMessageAt": _now_iso(),
"lastMessagePreview": reply[:80],
"readState": {user_id: _now_iso(), buddy_uid: _now_iso()},
},
)
else:
_jload()
msgs = [m for m in (_j.get("messages") or []) if m.get("conversationId") == conv_id]
if not msgs:
_j.setdefault("messages", []).extend(
[
{
"id": secrets.token_hex(8),
"conversationId": conv_id,
"senderId": buddy_uid,
"body": hello,
"createdAt": _now_iso(),
},
{
"id": secrets.token_hex(8),
"conversationId": conv_id,
"senderId": user_id,
"body": reply,
"createdAt": _now_iso(),
},
]
)
for c in _j.get("conversations") or []:
if c.get("id") == conv_id:
c["lastMessageAt"] = _now_iso()
c["lastMessagePreview"] = reply[:80]
break
try:
from app.services import community_store
from app.services.notify import notify_user
notify_user(
user_id,
"有人喜欢了你",
f"{buddy.get('name', '游民')} 等人对你感兴趣,去看看吧",
"/dating/likes",
"match",
)
if conv_id:
notify_user(
user_id,
"新消息",
f"{buddy.get('name', '游民')}: {hello[:40]}",
f"/chat/{conv_id}",
"chat",
)
community_store.add_notification(
user_id,
"欢迎来到匹配",
"左滑跳过、右滑喜欢、上滑超级喜欢。匹配成功可私信。",
"/dating",
"system",
)
except Exception:
pass
if not use_pb():
_jload()
_j.setdefault("demo_seeded", {})[user_id] = True
_jsave()
else:
pass
# ── JSON fallback ──────────────────────────────────────────────────────
def _jload() -> dict:
global _j
if _j:
return _j
if _JSON_PATH.exists():
try:
_j = json.loads(_JSON_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
_j = {}
else:
_j = {}
return _j
def _jsave() -> None:
_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
_JSON_PATH.write_text(json.dumps(_j, ensure_ascii=False), encoding="utf-8")