fix(voice): recover dialogue after abandoned message recordings

This commit is contained in:
stu2not
2026-10-28 12:22:58 +08:00
parent a02e0d5f13
commit 9ebb1a78ec
10 changed files with 622 additions and 85 deletions
+107 -23
View File
@@ -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())