Files
banban/talkingq-url/banban/service/im.py
2026-06-11 10:46:32 +08:00

765 lines
29 KiB
Python

from dataclasses import dataclass
import hashlib
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any
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 binding_service
from banban.service.pending_voice_message import pending_voice_message_service
from services.task_manager import task_manager
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.audio_duration import parse_audio_duration_ms
from utils.audio_transcode import (
AudioTranscodeError,
AudioTranscodeUnavailable,
prepare_audio_as_mp3,
)
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 build_device_parent_leave_message_member_client_msg_id(*, device_id: str, parent_user_id: int, media_file_key: str) -> str:
raw = f"{device_id}|parent|{parent_user_id}|{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 _update_message_media_duration(
self,
*,
message_id: int,
media_duration_ms: int,
source: str,
) -> None:
db_session = await self.get_session()
try:
dao = ImDAO(db_session)
updated = await dao.update_message_media_duration(
message_id=message_id,
media_duration_ms=media_duration_ms,
)
await db_session.commit()
session_logger.info(
"system",
"audio_duration",
(
"updated message audio duration: "
f"message_id={message_id}, media_duration_ms={media_duration_ms}, "
f"source={source}, updated={updated}"
),
)
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
async def _parse_and_update_message_media_duration(
self,
*,
message_id: int,
audio_data: bytes,
mime_type: str | None,
source: str,
) -> None:
try:
duration_ms = await parse_audio_duration_ms(
audio_data,
mime_type=mime_type,
source=source,
)
if duration_ms is None:
return
await self._update_message_media_duration(
message_id=message_id,
media_duration_ms=duration_ms,
source=source,
)
except Exception as exc:
session_logger.warning(
"system",
"audio_duration",
(
"failed to update message audio duration: "
f"message_id={message_id}, source={source}, error={exc}"
),
)
async def schedule_message_media_duration_parse(
self,
*,
message_id: int,
audio_data: bytes,
mime_type: str | None,
source: str,
) -> None:
if message_id <= 0 or not audio_data:
return
await task_manager.create_task(
self._parse_and_update_message_media_duration(
message_id=message_id,
audio_data=audio_data,
mime_type=mime_type,
source=source,
),
task_type="audio_duration",
)
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 _get_raw_message_media_file_key(
self,
*,
conversation_id: int,
client_msg_id: str,
) -> str | None:
db_session = await self.get_session()
try:
dao = ImDAO(db_session)
message_row = await dao._get_message_by_conversation_client_id(
conversation_id=conversation_id,
client_msg_id=client_msg_id,
)
if not message_row:
return None
return message_row["media_file_key"]
finally:
await db_session.close()
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")
await self.assert_parent_child_access(
user_id=parent_user_id,
child_id=child_id,
)
device = await binding_service.get_child_binding(child_id, parent_user_id)
if device is None:
raise HTTPException(status_code=404, detail="child has no bound device")
target_device_id = str(device["device_id"])
try:
prepared_audio = await prepare_audio_as_mp3(
content,
filename=filename,
content_type=content_type,
session_device_id=target_device_id,
session_id="parent_weapp_voice",
)
except AudioTranscodeUnavailable as exc:
session_logger.error(
target_device_id,
"parent_weapp_voice",
f"audio transcode service unavailable: {exc}",
exc_info=True,
)
raise HTTPException(status_code=500, detail="audio transcode service unavailable") from exc
except AudioTranscodeError as exc:
session_logger.warning(
target_device_id,
"parent_weapp_voice",
f"failed to transcode uploaded parent voice: {exc}",
)
raise HTTPException(status_code=415, detail="unsupported audio file type") from exc
payload_ext_json = dict(ext_json or {})
payload_ext_json.update(
{
"normalized_audio_format": "mp3",
"source_audio_format": prepared_audio.source_format,
"audio_transcoded": prepared_audio.transcoded,
}
)
if prepared_audio.original_mime_type:
payload_ext_json["original_media_mime_type"] = prepared_audio.original_mime_type
if prepared_audio.original_extension:
payload_ext_json["original_media_extension"] = prepared_audio.original_extension
stored_audio = await self.audio_storage.upload_audio(
device_id=f"parent-{parent_user_id}",
content=prepared_audio.content,
content_type=prepared_audio.mime_type,
extension=prepared_audio.extension,
)
payload = ParentChildMessageCreateRequest(
content_type=2,
media_file_key=stored_audio.file_key,
media_duration_ms=media_duration_ms,
media_mime_type=prepared_audio.mime_type,
media_size_bytes=prepared_audio.size_bytes,
media_transcript_text=(media_transcript_text or "").strip() or None,
client_msg_id=client_msg_id,
ext_json=payload_ext_json,
)
result = None
try:
result = await self.create_parent_child_message(
parent_user_id=parent_user_id,
child_id=child_id,
payload=payload,
)
pending_media_file_key = await self._get_raw_message_media_file_key(
conversation_id=result.conversation_id,
client_msg_id=client_msg_id,
)
if not pending_media_file_key:
pending_media_file_key = stored_audio.file_key
if result.idempotent:
try:
await self.audio_storage.delete_audio(stored_audio.file_key)
except MessageAudioStorageError:
pass
try:
audio_url = await device_audio_cache_service.get_device_audio_url(
pending_media_file_key,
device_id=target_device_id,
)
except Exception:
session_logger.error(target_device_id, "audio", f"failed to get device audio url: {pending_media_file_key}", exc_info=True)
audio_url = pending_media_file_key
await pending_voice_message_service.add_pending_message(
target_device_id=target_device_id,
sender_device_id=None,
im_message_id=result.message.id,
media_file_key=pending_media_file_key,
audio_url=f"{audio_url}",
source="parent_child_voice",
)
await self.schedule_message_media_duration_parse(
message_id=result.message.id,
audio_data=prepared_audio.content,
mime_type=prepared_audio.mime_type,
source="parent_weapp_voice",
)
except Exception:
if result is None:
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,
audio_content: bytes | 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)
family_identities = await dao.get_bound_device_family_identities(device_id=device_id)
if not family_identities:
raise HTTPException(status_code=404, detail="device family has no members")
first_result: ConversationMessageCreateResult | None = None
for owner_identity in family_identities:
member_client_msg_id = (client_msg_id or "").strip()
if len(family_identities) > 1 or not member_client_msg_id:
member_client_msg_id = build_device_parent_leave_message_member_client_msg_id(
device_id=device_id,
parent_user_id=owner_identity.owner_user_id,
media_file_key=normalized_media_file_key,
)
member_result = await self._create_device_parent_leave_message_for_identity(
dao=dao,
owner_identity=owner_identity,
device_id=device_id,
normalized_media_file_key=normalized_media_file_key,
media_duration_ms=media_duration_ms,
media_mime_type=media_mime_type,
media_size_bytes=media_size_bytes,
media_transcript_text=media_transcript_text,
client_msg_id=member_client_msg_id,
ext_json=ext_json,
)
if audio_content:
await self.schedule_message_media_duration_parse(
message_id=member_result.message.id,
audio_data=audio_content,
mime_type=media_mime_type,
source=str((ext_json or {}).get("source") or "device_parent_leave_message"),
)
if first_result is None:
first_result = member_result
if first_result is None:
raise HTTPException(status_code=404, detail="device family has no members")
return (
DeviceIdentity(
device_id=family_identities[0].device_id,
child_id=family_identities[0].child_id,
child_name=family_identities[0].child_name,
),
first_result,
)
except Exception:
await db_session.rollback()
raise
finally:
await db_session.close()
async def _create_device_parent_leave_message_for_identity(
self,
*,
dao: ImDAO,
owner_identity: Any,
device_id: str,
normalized_media_file_key: str,
media_duration_ms: int | None,
media_mime_type: str | None,
media_size_bytes: int | None,
media_transcript_text: str | None,
client_msg_id: str,
ext_json: dict[str, Any] | None,
) -> ConversationMessageCreateResult:
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,
ext_json=resolved_ext_json,
)
return await self._create_device_message_with_payload(
dao=dao,
device_identity=device_identity,
payload=payload,
)
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()