fix(voice): recover dialogue after abandoned message recordings
This commit is contained in:
@@ -1,59 +1,143 @@
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from config import settings
|
||||
from services.connection_manager import connection_manager
|
||||
from services.target_audio_cache import target_audio_cache
|
||||
from utils.logger import session_logger
|
||||
|
||||
|
||||
VOICE_TARGET_KIND_DEVICE = "device"
|
||||
VOICE_TARGET_KIND_PARENT = "parent"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@dataclass
|
||||
class VoiceTarget:
|
||||
kind: str
|
||||
target_device_id: Optional[str] = None
|
||||
uuid: Optional[str] = None
|
||||
audio_cache_key: Optional[str] = None
|
||||
websocket: object = None
|
||||
session_id: Optional[str] = None
|
||||
expires_at: float = 0
|
||||
|
||||
|
||||
class DeviceTargetCache:
|
||||
def __init__(self):
|
||||
def __init__(self, *, timeout_seconds=None, clock=None):
|
||||
self.targets: Dict[str, VoiceTarget] = {}
|
||||
self.lock = asyncio.Lock()
|
||||
self.timeout_seconds = timeout_seconds if timeout_seconds is not None else settings.voice_target_timeout_seconds
|
||||
self.clock = clock or time.monotonic
|
||||
|
||||
async def _remove_locked(self, device_id, target, reason, *, clear_audio=True):
|
||||
del self.targets[device_id]
|
||||
if clear_audio:
|
||||
await target_audio_cache.clear_audio_data(target.audio_cache_key)
|
||||
session_logger.info(device_id, target.session_id or "target",
|
||||
f"留言目标已清除: kind={target.kind}, reason={reason}")
|
||||
|
||||
async def _get_locked(self, device_id):
|
||||
target = self.targets.get(device_id)
|
||||
if target and self.clock() >= target.expires_at:
|
||||
await self._remove_locked(device_id, target, "timeout")
|
||||
return None
|
||||
return target
|
||||
|
||||
async def get_target(self, device_id: str) -> Optional[str]:
|
||||
async with self.lock:
|
||||
target = self.targets.get(device_id)
|
||||
if not target or target.kind != VOICE_TARGET_KIND_DEVICE:
|
||||
return None
|
||||
return target.target_device_id
|
||||
target = await self.get_voice_target(device_id)
|
||||
return target.target_device_id if target and target.kind == VOICE_TARGET_KIND_DEVICE else None
|
||||
|
||||
async def get_voice_target(self, device_id: str) -> Optional[VoiceTarget]:
|
||||
async with self.lock:
|
||||
return self.targets.get(device_id)
|
||||
return await self._get_locked(device_id)
|
||||
|
||||
async def _set_target(self, device_id, *, kind, target_device_id=None, uuid=None):
|
||||
async with self.lock:
|
||||
old = self.targets.get(device_id)
|
||||
if old:
|
||||
await self._remove_locked(device_id, old, "replaced")
|
||||
self.targets[device_id] = VoiceTarget(
|
||||
kind=kind, target_device_id=target_device_id, uuid=uuid,
|
||||
audio_cache_key=f"voice:{device_id}:{uuid4().hex}",
|
||||
websocket=await connection_manager.get_connection(device_id),
|
||||
expires_at=self.clock() + self.timeout_seconds,
|
||||
)
|
||||
session_logger.info(device_id, "target", f"留言目标已设置: kind={kind}, timeout={self.timeout_seconds}s")
|
||||
|
||||
async def set_target(self, device_id: str, target_device_id: str):
|
||||
async with self.lock:
|
||||
self.targets[device_id] = VoiceTarget(
|
||||
kind=VOICE_TARGET_KIND_DEVICE,
|
||||
target_device_id=target_device_id,
|
||||
audio_cache_key=target_device_id,
|
||||
)
|
||||
await self._set_target(device_id, kind=VOICE_TARGET_KIND_DEVICE, target_device_id=target_device_id)
|
||||
|
||||
async def set_parent_target(self, device_id: str, uuid: str):
|
||||
async with self.lock:
|
||||
self.targets[device_id] = VoiceTarget(
|
||||
kind=VOICE_TARGET_KIND_PARENT,
|
||||
uuid=uuid,
|
||||
audio_cache_key=f"parent:{device_id}",
|
||||
)
|
||||
await self._set_target(device_id, kind=VOICE_TARGET_KIND_PARENT, uuid=uuid)
|
||||
|
||||
async def remove_target(self, device_id: str):
|
||||
async def claim_target(self, device_id, websocket, session_id):
|
||||
"""Bind one pending intent to one recording on one connection."""
|
||||
async with self.lock:
|
||||
if device_id in self.targets:
|
||||
del self.targets[device_id]
|
||||
active = await connection_manager.get_connection(device_id)
|
||||
if active is not None and active is not websocket:
|
||||
return None
|
||||
target = await self._get_locked(device_id)
|
||||
if target is None:
|
||||
return None
|
||||
if target.websocket is not None and target.websocket is not websocket:
|
||||
await self._remove_locked(device_id, target, "connection_changed")
|
||||
return None
|
||||
if target.session_id is not None and target.session_id != session_id:
|
||||
await self._remove_locked(device_id, target, "new_recording")
|
||||
return None
|
||||
target.websocket, target.session_id = websocket, session_id
|
||||
target.expires_at = self.clock() + self.timeout_seconds
|
||||
session_logger.info(device_id, session_id, f"留言目标已关联录音: kind={target.kind}")
|
||||
return target
|
||||
|
||||
async def bind_connection(self, device_id, websocket):
|
||||
"""Adopt commands received before connect; discard older socket intents."""
|
||||
async with self.lock:
|
||||
if await connection_manager.get_connection(device_id) is not websocket:
|
||||
return
|
||||
target = await self._get_locked(device_id)
|
||||
if target is None:
|
||||
return
|
||||
if target.websocket is not None and target.websocket is not websocket:
|
||||
await self._remove_locked(device_id, target, "connection_changed")
|
||||
else:
|
||||
target.websocket = websocket
|
||||
|
||||
async def append_audio(self, device_id, target, websocket, session_id, audio):
|
||||
async with self.lock:
|
||||
current = await self._get_locked(device_id)
|
||||
if current is not target or target.websocket is not websocket or target.session_id != session_id:
|
||||
return False
|
||||
target.expires_at = self.clock() + self.timeout_seconds
|
||||
await target_audio_cache.add_audio_data(target.audio_cache_key, audio)
|
||||
return True
|
||||
|
||||
async def take_target(self, device_id, target, websocket, session_id):
|
||||
"""Consume the exact recording before slow upload/notification work."""
|
||||
async with self.lock:
|
||||
current = await self._get_locked(device_id)
|
||||
if current is not target or target.websocket is not websocket or target.session_id != session_id:
|
||||
return None
|
||||
await self._remove_locked(device_id, target, "recording_ended", clear_audio=False)
|
||||
return target
|
||||
|
||||
async def remove_target(self, device_id: str, *, expected=None, websocket=None, reason="cancelled"):
|
||||
async with self.lock:
|
||||
target = self.targets.get(device_id)
|
||||
if target is None or (expected is not None and target is not expected):
|
||||
return False
|
||||
if websocket is not None and target.websocket is not websocket:
|
||||
return False
|
||||
await self._remove_locked(device_id, target, reason)
|
||||
return True
|
||||
|
||||
async def get_all_targets(self) -> list:
|
||||
async with self.lock:
|
||||
for device_id in list(self.targets):
|
||||
await self._get_locked(device_id)
|
||||
return list(self.targets.items())
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user