532 lines
20 KiB
Python
532 lines
20 KiB
Python
from dataclasses import dataclass
|
|
import hashlib
|
|
import json
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from services.offline_audio_cache import offline_audio_cache
|
|
from fastapi import HTTPException
|
|
from services.database_service_base import DatabaseServiceBase
|
|
from banban.service.device_audio_cache import device_audio_cache_service
|
|
from banban.service.message_audio_storage import MessageAudioStorageService, MessageAudioStorageError
|
|
from banban.service.binding import BindingService
|
|
try:
|
|
from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
|
|
from banban.schemas.im import (
|
|
ChildConversationMessageItem,
|
|
DeviceMessageCreateRequest,
|
|
ParentChildMessageCreateRequest,
|
|
)
|
|
except ModuleNotFoundError:
|
|
from banban.dao.im import ImDAO, DeviceIdentity, ConversationMessageCreateResult
|
|
from banban.schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest
|
|
from utils.logger import session_logger
|
|
|
|
|
|
|
|
PARENT_PARTICIPANT_TYPE = 1
|
|
CHILD_PARTICIPANT_TYPE = 2
|
|
|
|
CHILD_PEER_CONVERSATION_TYPE = 1
|
|
PARENT_CHILD_CONVERSATION_TYPE = 2
|
|
|
|
CONVERSATION_TYPE_NAMES = {
|
|
CHILD_PEER_CONVERSATION_TYPE: "child_peer",
|
|
PARENT_CHILD_CONVERSATION_TYPE: "parent_child",
|
|
}
|
|
|
|
PARTICIPANT_TYPE_NAMES = {
|
|
PARENT_PARTICIPANT_TYPE: "parent",
|
|
CHILD_PARTICIPANT_TYPE: "child",
|
|
}
|
|
|
|
_AUDIO_CONTENT_TYPE_TO_EXT = {
|
|
"audio/mpeg": "mp3",
|
|
"audio/mp3": "mp3",
|
|
"audio/aac": "aac",
|
|
"audio/wav": "wav",
|
|
"audio/x-wav": "wav",
|
|
"audio/x-m4a": "m4a",
|
|
"audio/mp4": "m4a",
|
|
"audio/webm": "webm",
|
|
"application/octet-stream": "mp3",
|
|
}
|
|
_AUDIO_EXTENSION_ALIASES = {
|
|
".mp3": "mp3",
|
|
".aac": "aac",
|
|
".m4a": "m4a",
|
|
".wav": "wav",
|
|
".webm": "webm",
|
|
}
|
|
|
|
|
|
def conversation_type_name(conversation_type: int) -> str:
|
|
return CONVERSATION_TYPE_NAMES.get(conversation_type, f"unknown_{conversation_type}")
|
|
|
|
|
|
def participant_type_name(participant_type: int) -> str:
|
|
return PARTICIPANT_TYPE_NAMES.get(participant_type, f"unknown_{participant_type}")
|
|
|
|
|
|
def build_device_audio_client_msg_id(*, device_id: str, target_device_id: str, audio_url: str) -> str:
|
|
raw = f"{device_id}|{target_device_id}|{audio_url}"
|
|
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
return f"device-audio-{digest[:32]}"
|
|
|
|
|
|
def build_device_parent_leave_message_client_msg_id(*, device_id: str, media_file_key: str) -> str:
|
|
raw = f"{device_id}|parent|{media_file_key}"
|
|
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
return f"device-parent-{digest[:32]}"
|
|
|
|
|
|
def normalize_content_json(value: Any) -> dict[str, Any] | None:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, dict):
|
|
return value
|
|
if isinstance(value, str):
|
|
try:
|
|
parsed = json.loads(value)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if isinstance(parsed, dict):
|
|
return parsed
|
|
return None
|
|
|
|
|
|
def normalize_audio_extension(*, filename: str | None, content_type: str | None) -> tuple[str, str]:
|
|
normalized_content_type = (content_type or "").strip().lower() or "audio/mpeg"
|
|
if normalized_content_type in _AUDIO_CONTENT_TYPE_TO_EXT:
|
|
return _AUDIO_CONTENT_TYPE_TO_EXT[normalized_content_type], normalized_content_type
|
|
|
|
suffix = Path(filename or "").suffix.lower()
|
|
if suffix in _AUDIO_EXTENSION_ALIASES:
|
|
extension = _AUDIO_EXTENSION_ALIASES[suffix]
|
|
fallback_content_type = "audio/m4a" if extension == "m4a" else f"audio/{extension}"
|
|
return extension, fallback_content_type
|
|
|
|
raise HTTPException(status_code=415, detail="unsupported audio file type")
|
|
|
|
|
|
def row_to_message_item(row: Mapping[str, Any]) -> ChildConversationMessageItem:
|
|
return ChildConversationMessageItem(
|
|
id=int(row["id"]),
|
|
conversation_id=int(row["conversation_id"]),
|
|
seq=int(row["seq"]),
|
|
sender_type=participant_type_name(int(row["sender_type"])),
|
|
sender_id=str(row["sender_id"]),
|
|
receiver_type=participant_type_name(int(row["receiver_type"])),
|
|
receiver_id=str(row["receiver_id"]),
|
|
content_type=int(row["content_type"]),
|
|
content_text=row["content_text"],
|
|
content_json=normalize_content_json(row["content_json"]),
|
|
media_file_key=row["media_file_key"],
|
|
media_duration_ms=row["media_duration_ms"],
|
|
media_mime_type=row["media_mime_type"],
|
|
media_size_bytes=row["media_size_bytes"],
|
|
media_transcript_text=row["media_transcript_text"],
|
|
client_msg_id=row["client_msg_id"],
|
|
sender_name_snapshot=row["sender_name_snapshot"],
|
|
sender_avatar_snapshot=row["sender_avatar_snapshot"],
|
|
receiver_name_snapshot=row["receiver_name_snapshot"],
|
|
receiver_avatar_snapshot=row["receiver_avatar_snapshot"],
|
|
ext_json=normalize_content_json(row["ext_json"]),
|
|
created_at=row["created_at"],
|
|
)
|
|
|
|
|
|
async def present_message_item(
|
|
row: Mapping[str, Any],
|
|
*,
|
|
audio_storage: MessageAudioStorageService,
|
|
) -> ChildConversationMessageItem:
|
|
item = row_to_message_item(row)
|
|
if item.content_type == 2 and item.media_file_key:
|
|
if item.media_file_key.startswith(("http://", "https://")):
|
|
return item
|
|
try:
|
|
item.media_file_key = await audio_storage.get_audio_url(item.media_file_key)
|
|
except MessageAudioStorageError:
|
|
pass
|
|
return item
|
|
|
|
|
|
async def present_device_message_item(
|
|
row: Mapping[str, Any],
|
|
*,
|
|
device_id: str,
|
|
) -> ChildConversationMessageItem:
|
|
item = row_to_message_item(row)
|
|
if item.content_type == 2 and item.media_file_key:
|
|
try:
|
|
item.media_file_key = await device_audio_cache_service.get_device_audio_url(
|
|
item.media_file_key,
|
|
device_id=device_id,
|
|
)
|
|
except Exception as exc:
|
|
session_logger.error(
|
|
device_id,
|
|
"device_audio",
|
|
f"failed to prepare device audio url: {exc}",
|
|
exc_info=True,
|
|
)
|
|
return item
|
|
|
|
|
|
class ImService(DatabaseServiceBase):
|
|
def __init__(self):
|
|
super().__init__(service_name="im_service")
|
|
self.audio_storage = MessageAudioStorageService()
|
|
|
|
async def assert_parent_child_access(self, *, user_id: int, child_id: int) -> Mapping[str, Any]:
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = ImDAO(db_session)
|
|
return await dao.assert_parent_child_access(user_id=user_id, child_id=child_id)
|
|
finally:
|
|
await db_session.close()
|
|
|
|
async def authenticate_device_identity(self, *, device_id: str, serial_number: str) -> DeviceIdentity:
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = ImDAO(db_session)
|
|
return await dao.authenticate_device_identity(device_id=device_id, serial_number=serial_number)
|
|
finally:
|
|
await db_session.close()
|
|
|
|
async def _build_message_create_result(
|
|
self,
|
|
*,
|
|
dao: ImDAO,
|
|
idempotent: bool,
|
|
conversation_id: int,
|
|
conversation_type: int,
|
|
client_msg_id: str,
|
|
) -> ConversationMessageCreateResult:
|
|
message_row = await dao._get_message_by_conversation_client_id(
|
|
conversation_id=conversation_id,
|
|
client_msg_id=client_msg_id,
|
|
)
|
|
if not message_row:
|
|
raise RuntimeError("message was not found after insert")
|
|
|
|
presented_message = await present_message_item(
|
|
message_row,
|
|
audio_storage=self.audio_storage,
|
|
)
|
|
|
|
return ConversationMessageCreateResult(
|
|
idempotent=idempotent,
|
|
conversation_id=conversation_id,
|
|
conversation_type=conversation_type,
|
|
message=presented_message,
|
|
)
|
|
|
|
async def create_parent_child_message(
|
|
self,
|
|
*,
|
|
parent_user_id: int,
|
|
child_id: int,
|
|
payload: ParentChildMessageCreateRequest,
|
|
) -> ConversationMessageCreateResult:
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = ImDAO(db_session)
|
|
child_row = await dao.assert_parent_child_access(user_id=parent_user_id, child_id=child_id)
|
|
parent_row = await dao._get_parent_row(parent_user_id)
|
|
if not parent_row:
|
|
raise HTTPException(status_code=404, detail="parent not found")
|
|
|
|
conversation_id, idempotent = await dao.create_message(
|
|
conversation_type=PARENT_CHILD_CONVERSATION_TYPE,
|
|
participant_a_type=CHILD_PARTICIPANT_TYPE,
|
|
participant_a_id=str(child_id),
|
|
participant_b_type=PARENT_PARTICIPANT_TYPE,
|
|
participant_b_id=str(parent_user_id),
|
|
pair_key=f"{child_id}:{parent_user_id}",
|
|
sender_type=PARENT_PARTICIPANT_TYPE,
|
|
sender_id=str(parent_user_id),
|
|
receiver_type=CHILD_PARTICIPANT_TYPE,
|
|
receiver_id=str(child_id),
|
|
sender_name_snapshot=parent_row["nickname"],
|
|
sender_avatar_snapshot=parent_row["avatar_url"],
|
|
receiver_name_snapshot=child_row["child_name"],
|
|
receiver_avatar_snapshot=None,
|
|
payload=payload,
|
|
)
|
|
return await self._build_message_create_result(
|
|
dao=dao,
|
|
idempotent=idempotent,
|
|
conversation_id=conversation_id,
|
|
conversation_type=PARENT_CHILD_CONVERSATION_TYPE,
|
|
client_msg_id=payload.client_msg_id,
|
|
)
|
|
except Exception:
|
|
await db_session.rollback()
|
|
raise
|
|
finally:
|
|
await db_session.close()
|
|
|
|
async def create_parent_child_voice_message(
|
|
self,
|
|
*,
|
|
parent_user_id: int,
|
|
child_id: int,
|
|
filename: str | None,
|
|
content_type: str | None,
|
|
content: bytes,
|
|
media_duration_ms: int | None,
|
|
media_transcript_text: str | None,
|
|
client_msg_id: str,
|
|
ext_json: dict[str, Any] | None = None,
|
|
) -> ConversationMessageCreateResult:
|
|
if not content:
|
|
raise HTTPException(status_code=400, detail="audio file is empty")
|
|
|
|
extension, normalized_content_type = normalize_audio_extension(
|
|
filename=filename,
|
|
content_type=content_type,
|
|
)
|
|
stored_audio = await self.audio_storage.upload_audio(
|
|
device_id=f"parent-{parent_user_id}",
|
|
content=content,
|
|
content_type=normalized_content_type,
|
|
extension=extension,
|
|
)
|
|
payload = ParentChildMessageCreateRequest(
|
|
content_type=2,
|
|
media_file_key=stored_audio.file_key,
|
|
media_duration_ms=media_duration_ms,
|
|
media_mime_type=normalized_content_type,
|
|
media_size_bytes=len(content),
|
|
media_transcript_text=(media_transcript_text or "").strip() or None,
|
|
client_msg_id=client_msg_id,
|
|
ext_json=ext_json,
|
|
)
|
|
|
|
try:
|
|
result = await self.create_parent_child_message(
|
|
parent_user_id=parent_user_id,
|
|
child_id=child_id,
|
|
payload=payload,
|
|
)
|
|
binding_service = BindingService()
|
|
device = await binding_service.get_current_binding(parent_user_id)
|
|
try:
|
|
audio_url = await device_audio_cache_service.get_device_audio_url(
|
|
stored_audio.file_key,
|
|
device_id=device.device_id,
|
|
)
|
|
except Exception:
|
|
session_logger.error(device.device_id, "audio", f"failed to get device audio url: {stored_audio.file_key}", exc_info=True)
|
|
audio_url = stored_audio.file_key
|
|
await offline_audio_cache.add_audio_url(device.device_id, f"{audio_url}")
|
|
except Exception:
|
|
try:
|
|
await self.audio_storage.delete_audio(stored_audio.file_key)
|
|
except MessageAudioStorageError:
|
|
pass
|
|
raise
|
|
|
|
return result
|
|
|
|
async def _create_device_message_with_payload(
|
|
self,
|
|
*,
|
|
dao: ImDAO,
|
|
device_identity: DeviceIdentity,
|
|
payload: DeviceMessageCreateRequest,
|
|
) -> ConversationMessageCreateResult:
|
|
if payload.conversation_type == CHILD_PEER_CONVERSATION_TYPE:
|
|
if payload.peer_child_id == device_identity.child_id:
|
|
raise HTTPException(status_code=400, detail="peer_child_id must be different from current child")
|
|
sender_child_row = await dao.assert_child_exists(child_id=device_identity.child_id)
|
|
receiver_child_row = await dao.assert_child_exists(child_id=payload.peer_child_id)
|
|
participant_a_id, participant_b_id, pair_key = dao._build_child_peer_pair(
|
|
device_identity.child_id,
|
|
payload.peer_child_id,
|
|
)
|
|
|
|
conversation_id, idempotent = await dao.create_message(
|
|
conversation_type=CHILD_PEER_CONVERSATION_TYPE,
|
|
participant_a_type=CHILD_PARTICIPANT_TYPE,
|
|
participant_a_id=participant_a_id,
|
|
participant_b_type=CHILD_PARTICIPANT_TYPE,
|
|
participant_b_id=participant_b_id,
|
|
pair_key=pair_key,
|
|
sender_type=CHILD_PARTICIPANT_TYPE,
|
|
sender_id=str(device_identity.child_id),
|
|
receiver_type=CHILD_PARTICIPANT_TYPE,
|
|
receiver_id=str(payload.peer_child_id),
|
|
sender_name_snapshot=sender_child_row["child_name"],
|
|
sender_avatar_snapshot=None,
|
|
receiver_name_snapshot=receiver_child_row["child_name"],
|
|
receiver_avatar_snapshot=None,
|
|
payload=payload,
|
|
)
|
|
else:
|
|
sender_child_row = await dao.assert_child_exists(child_id=device_identity.child_id)
|
|
parent_row = await dao._get_parent_row(payload.parent_user_id)
|
|
if not parent_row:
|
|
raise HTTPException(status_code=404, detail="parent not found")
|
|
await dao.assert_parent_child_access(user_id=payload.parent_user_id, child_id=device_identity.child_id)
|
|
|
|
conversation_id, idempotent = await dao.create_message(
|
|
conversation_type=PARENT_CHILD_CONVERSATION_TYPE,
|
|
participant_a_type=CHILD_PARTICIPANT_TYPE,
|
|
participant_a_id=str(device_identity.child_id),
|
|
participant_b_type=PARENT_PARTICIPANT_TYPE,
|
|
participant_b_id=str(payload.parent_user_id),
|
|
pair_key=f"{device_identity.child_id}:{payload.parent_user_id}",
|
|
sender_type=CHILD_PARTICIPANT_TYPE,
|
|
sender_id=str(device_identity.child_id),
|
|
receiver_type=PARENT_PARTICIPANT_TYPE,
|
|
receiver_id=str(payload.parent_user_id),
|
|
sender_name_snapshot=sender_child_row["child_name"],
|
|
sender_avatar_snapshot=None,
|
|
receiver_name_snapshot=parent_row["nickname"],
|
|
receiver_avatar_snapshot=parent_row["avatar_url"],
|
|
payload=payload,
|
|
)
|
|
|
|
return await self._build_message_create_result(
|
|
dao=dao,
|
|
idempotent=idempotent,
|
|
conversation_id=conversation_id,
|
|
conversation_type=payload.conversation_type,
|
|
client_msg_id=payload.client_msg_id,
|
|
)
|
|
|
|
async def create_device_message(
|
|
self,
|
|
*,
|
|
device_id: str,
|
|
serial_number: str,
|
|
payload: DeviceMessageCreateRequest | None = None,
|
|
target_device_id: str | None = None,
|
|
audio_url: str | None = None,
|
|
) -> tuple[DeviceIdentity, ConversationMessageCreateResult]:
|
|
if payload is None and (not target_device_id or not audio_url):
|
|
raise ValueError("payload or target_device_id/audio_url is required")
|
|
if payload is not None and (target_device_id is not None or audio_url is not None):
|
|
raise ValueError("payload and target_device_id/audio_url cannot be used together")
|
|
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = ImDAO(db_session)
|
|
device_identity = await dao.authenticate_device_identity(
|
|
device_id=device_id,
|
|
serial_number=serial_number,
|
|
)
|
|
|
|
resolved_payload = payload
|
|
if resolved_payload is None:
|
|
target_device_identity = await dao.get_device_by_id(device_id=target_device_id)
|
|
resolved_payload = DeviceMessageCreateRequest(
|
|
conversation_type=CHILD_PEER_CONVERSATION_TYPE,
|
|
peer_child_id=target_device_identity.child_id,
|
|
content_type=2,
|
|
media_file_key=audio_url,
|
|
media_mime_type="audio/mpeg",
|
|
client_msg_id=build_device_audio_client_msg_id(
|
|
device_id=device_id,
|
|
target_device_id=target_device_id,
|
|
audio_url=audio_url,
|
|
),
|
|
ext_json={
|
|
"source": "device_audio_message",
|
|
"source_device_id": device_id,
|
|
"target_device_id": target_device_id,
|
|
},
|
|
)
|
|
|
|
result = await self._create_device_message_with_payload(
|
|
dao=dao,
|
|
device_identity=device_identity,
|
|
payload=resolved_payload,
|
|
)
|
|
return device_identity, result
|
|
except Exception:
|
|
await db_session.rollback()
|
|
raise
|
|
finally:
|
|
await db_session.close()
|
|
|
|
async def create_device_parent_leave_message(
|
|
self,
|
|
*,
|
|
device_id: str,
|
|
media_file_key: str,
|
|
media_duration_ms: int | None = None,
|
|
media_mime_type: str | None = None,
|
|
media_size_bytes: int | None = None,
|
|
media_transcript_text: str | None = None,
|
|
client_msg_id: str | None = None,
|
|
ext_json: dict[str, Any] | None = None,
|
|
) -> tuple[DeviceIdentity, ConversationMessageCreateResult]:
|
|
normalized_media_file_key = str(media_file_key or "").strip()
|
|
if not normalized_media_file_key:
|
|
raise HTTPException(status_code=400, detail="media_file_key is required")
|
|
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = ImDAO(db_session)
|
|
owner_identity = await dao.get_bound_device_owner_identity(device_id=device_id)
|
|
device_identity = DeviceIdentity(
|
|
device_id=owner_identity.device_id,
|
|
child_id=owner_identity.child_id,
|
|
child_name=owner_identity.child_name,
|
|
)
|
|
if ext_json:
|
|
resolved_ext_json = dict(ext_json)
|
|
else:
|
|
resolved_ext_json = {}
|
|
resolved_ext_json.update(
|
|
{
|
|
"message_kind": "leave_message",
|
|
"source": "device_mqtt_011",
|
|
"source_device_id": device_id,
|
|
}
|
|
)
|
|
|
|
payload = DeviceMessageCreateRequest(
|
|
conversation_type=PARENT_CHILD_CONVERSATION_TYPE,
|
|
parent_user_id=owner_identity.owner_user_id,
|
|
content_type=2,
|
|
media_file_key=normalized_media_file_key,
|
|
media_duration_ms=media_duration_ms,
|
|
media_mime_type=(media_mime_type or "").strip() or "audio/mpeg",
|
|
media_size_bytes=media_size_bytes,
|
|
media_transcript_text=(media_transcript_text or "").strip() or None,
|
|
client_msg_id=(client_msg_id or "").strip()
|
|
or build_device_parent_leave_message_client_msg_id(
|
|
device_id=device_id,
|
|
media_file_key=normalized_media_file_key,
|
|
),
|
|
ext_json=resolved_ext_json,
|
|
)
|
|
|
|
result = await self._create_device_message_with_payload(
|
|
dao=dao,
|
|
device_identity=device_identity,
|
|
payload=payload,
|
|
)
|
|
return device_identity, result
|
|
except Exception:
|
|
await db_session.rollback()
|
|
raise
|
|
finally:
|
|
await db_session.close()
|
|
|
|
async def assert_child_exists(self, *, child_id: int) -> Mapping[str, Any]:
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = ImDAO(db_session)
|
|
return await dao.assert_child_exists(child_id=child_id)
|
|
finally:
|
|
await db_session.close()
|
|
|
|
|
|
im_service = ImService()
|