from __future__ import annotations from datetime import datetime, timezone from typing import Any from .pb_client import pb, pb_quote MAX_MESSAGE_LENGTH = 2000 def utc_now() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000Z") def pair_key_for_users(user_a_id: str, user_b_id: str) -> str: return "|".join(sorted([user_a_id, user_b_id])) def ordered_user_ids(user_a_id: str, user_b_id: str) -> tuple[str, str]: return tuple(sorted([user_a_id, user_b_id])) # type: ignore[return-value] def public_record(record: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in record.items() if not k.startswith("@")} def conversation_participants(conversation: dict[str, Any]) -> set[str]: return {str(conversation.get("userAId") or ""), str(conversation.get("userBId") or "")} def other_participant_id(conversation: dict[str, Any], user_id: str) -> str | None: participants = conversation_participants(conversation) participants.discard("") participants.discard(user_id) return next(iter(participants), None) def read_state_for(conversation: dict[str, Any]) -> dict[str, str]: raw = conversation.get("readState") if isinstance(raw, dict): return {str(key): str(value) for key, value in raw.items() if value} return {} async def ensure_direct_conversation( user_a_id: str, user_b_id: str, *, conv_type: str = "direct", intent: str = "friends", ) -> dict[str, Any]: pair_key = pair_key_for_users(user_a_id, user_b_id) existing = await pb.first_record("conversations", filter=f'pairKey={pb_quote(pair_key)}') if existing: return existing user_a, user_b = ordered_user_ids(user_a_id, user_b_id) return await pb.create_record( "conversations", { "pairKey": pair_key, "type": conv_type, "userAId": user_a, "userBId": user_b, "matchConnectionId": "", "intent": intent, "lastMessageAt": "", "lastMessagePreview": "", "readState": {}, }, ) async def ensure_conversation_for_match( *, user_a_id: str, user_b_id: str, match_connection_id: str = "", intent: str = "friends", ) -> dict[str, Any]: pair_key = pair_key_for_users(user_a_id, user_b_id) existing = await pb.first_record("conversations", filter=f'pairKey={pb_quote(pair_key)}') if existing: updates: dict[str, Any] = {} if match_connection_id and not existing.get("matchConnectionId"): updates["matchConnectionId"] = match_connection_id if intent and not existing.get("intent"): updates["intent"] = intent if updates: return await pb.update_record("conversations", existing["id"], updates) return existing user_a, user_b = ordered_user_ids(user_a_id, user_b_id) return await pb.create_record( "conversations", { "pairKey": pair_key, "type": "match", "userAId": user_a, "userBId": user_b, "matchConnectionId": match_connection_id, "intent": intent, "lastMessageAt": "", "lastMessagePreview": "", "readState": {}, }, ) async def sync_connection_conversation(connection: dict[str, Any]) -> dict[str, Any]: conversation = await ensure_conversation_for_match( user_a_id=str(connection.get("userAId") or ""), user_b_id=str(connection.get("userBId") or ""), match_connection_id=str(connection.get("id") or ""), intent=str(connection.get("intent") or "friends"), ) connection_id = str(connection.get("id") or "") conversation_id = str(conversation.get("id") or "") if connection_id and conversation_id and str(connection.get("conversationId") or "") != conversation_id: connection = await pb.update_record( "match_connections", connection_id, {"conversationId": conversation_id}, ) if connection_id and str(conversation.get("matchConnectionId") or "") != connection_id: conversation = await pb.update_record( "conversations", conversation_id, {"matchConnectionId": connection_id}, ) return conversation async def backfill_conversations_for_user(user_id: str) -> None: data = await pb.list_records( "match_connections", filter=f'userAId={pb_quote(user_id)} || userBId={pb_quote(user_id)}', per_page=100, sort="-created", ) for connection in data.get("items", []): await sync_connection_conversation(connection) async def conversation_for_user(conversation_id: str, user_id: str) -> dict[str, Any] | None: conversation = await pb.first_record("conversations", filter=f'id={pb_quote(conversation_id)}') if not conversation: return None if user_id not in conversation_participants(conversation): return None return conversation async def conversation_by_connection(connection_id: str, user_id: str) -> dict[str, Any] | None: connection = await pb.first_record("match_connections", filter=f'id={pb_quote(connection_id)}') if not connection: return None if user_id not in {str(connection.get("userAId") or ""), str(connection.get("userBId") or "")}: return None existing_id = str(connection.get("conversationId") or "") if existing_id: conversation = await conversation_for_user(existing_id, user_id) if conversation: return conversation return await sync_connection_conversation(connection) def unread_count_for(conversation: dict[str, Any], user_id: str) -> int: last_read = read_state_for(conversation).get(user_id, "") preview = str(conversation.get("lastMessagePreview") or "").strip() if not preview: return 0 last_message_at = str(conversation.get("lastMessageAt") or "") if not last_message_at: return 0 if not last_read: return 1 return 1 if last_message_at > last_read else 0 async def list_conversations_for_user(user_id: str) -> list[dict[str, Any]]: await backfill_conversations_for_user(user_id) data = await pb.list_records( "conversations", filter=f'userAId={pb_quote(user_id)} || userBId={pb_quote(user_id)}', per_page=100, sort="-lastMessageAt,-updated,-created", ) return list(data.get("items", [])) async def list_messages(conversation_id: str, *, limit: int = 50, before: str = "") -> list[dict[str, Any]]: safe_limit = max(1, min(limit, 100)) filter_parts = [f'conversationId={pb_quote(conversation_id)}'] if before: filter_parts.append(f'created<{pb_quote(before)}') data = await pb.list_records( "messages", filter=" && ".join(filter_parts), per_page=safe_limit, sort="-created", ) items = list(data.get("items", [])) items.reverse() return items async def mark_conversation_read(conversation: dict[str, Any], user_id: str) -> dict[str, Any]: read_state = read_state_for(conversation) read_state[user_id] = utc_now() return await pb.update_record( "conversations", conversation["id"], {"readState": read_state}, ) async def send_message( *, conversation: dict[str, Any], sender_id: str, body: str, ) -> tuple[dict[str, Any], str | None]: text = body.strip() if not text: raise ValueError("消息不能为空") if len(text) > MAX_MESSAGE_LENGTH: raise ValueError(f"消息不能超过 {MAX_MESSAGE_LENGTH} 字") if sender_id not in conversation_participants(conversation): raise ValueError("无权发送消息") preview = text.replace("\n", " ") if len(preview) > 180: preview = f"{preview[:177]}..." message = await pb.create_record( "messages", { "conversationId": conversation["id"], "senderId": sender_id, "body": text, }, ) message_at = str(message.get("created") or utc_now()) read_state = read_state_for(conversation) read_state[sender_id] = message_at await pb.update_record( "conversations", conversation["id"], { "lastMessageAt": message_at, "lastMessagePreview": preview, "readState": read_state, }, ) recipient_id = other_participant_id(conversation, sender_id) return message, recipient_id