feat(ner): 新增 NER 命名实体识别功能
- 新增 NER 接口支持 (ner.py) - 新增 SAMI 认证模块 (sami.py) - 新增 Wave 加密协议客户端 (wave_client.py) - 扩展 ASR 响应结构支持详细识别结果 - 扩展设备凭据支持 sami_token 和 wave_session - 重构 AudioEncoder 为同步实现,优化异步文件操作 - 移除不安全的 SSL 证书验证禁用
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
from .asr import (
|
||||
DoubaoASR,
|
||||
ASRResponse,
|
||||
ASRResult,
|
||||
ASRAlternative,
|
||||
ASRWord,
|
||||
ASRExtra,
|
||||
OIDecodingInfo,
|
||||
ASRError,
|
||||
ResponseType,
|
||||
AudioChunk,
|
||||
@@ -9,15 +14,26 @@ from .asr import (
|
||||
transcribe_realtime,
|
||||
)
|
||||
from .config import ASRConfig
|
||||
from .ner import NerResponse, NerResult, NerWord, ner, async_ner
|
||||
|
||||
__all__ = [
|
||||
"DoubaoASR",
|
||||
"ASRConfig",
|
||||
"ASRResponse",
|
||||
"ASRResult",
|
||||
"ASRAlternative",
|
||||
"ASRWord",
|
||||
"ASRExtra",
|
||||
"OIDecodingInfo",
|
||||
"ASRError",
|
||||
"ResponseType",
|
||||
"AudioChunk",
|
||||
"transcribe",
|
||||
"transcribe_stream",
|
||||
"transcribe_realtime",
|
||||
"NerResponse",
|
||||
"NerResult",
|
||||
"NerWord",
|
||||
"ner",
|
||||
"async_ner",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -37,6 +37,59 @@ class ResponseType(Enum):
|
||||
UNKNOWN = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ASRWord:
|
||||
"""单词级别的识别结果"""
|
||||
word: str
|
||||
start_time: float
|
||||
end_time: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class OIDecodingInfo:
|
||||
"""OI 解码信息"""
|
||||
oi_former_word_num: int = 0
|
||||
oi_latter_word_num: int = 0
|
||||
oi_words: Optional[List] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ASRAlternative:
|
||||
"""识别候选结果"""
|
||||
text: str
|
||||
start_time: float
|
||||
end_time: float
|
||||
words: List[ASRWord] = field(default_factory=list)
|
||||
semantic_related_to_prev: Optional[bool] = None
|
||||
oi_decoding_info: Optional[OIDecodingInfo] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ASRResult:
|
||||
"""单条识别结果"""
|
||||
text: str
|
||||
start_time: float
|
||||
end_time: float
|
||||
confidence: float = 0.0
|
||||
alternatives: List[ASRAlternative] = field(default_factory=list)
|
||||
is_interim: bool = True
|
||||
is_vad_finished: bool = False
|
||||
index: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ASRExtra:
|
||||
"""响应附加信息"""
|
||||
audio_duration: Optional[int] = None
|
||||
model_avg_rtf: Optional[float] = None
|
||||
model_send_first_response: Optional[int] = None
|
||||
speech_adaptation_version: Optional[str] = None
|
||||
model_total_process_time: Optional[int] = None
|
||||
packet_number: Optional[int] = None
|
||||
vad_start: Optional[bool] = None
|
||||
req_payload: Optional[dict] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ASRResponse:
|
||||
"""
|
||||
@@ -50,6 +103,8 @@ class ASRResponse:
|
||||
packet_number: int = -1
|
||||
error_msg: str = ""
|
||||
raw_json: Optional[dict] = None
|
||||
results: List[ASRResult] = field(default_factory=list)
|
||||
extra: Optional[ASRExtra] = None
|
||||
|
||||
|
||||
class ASRError(Exception):
|
||||
@@ -71,6 +126,14 @@ class _SessionState(BaseModel):
|
||||
error: Optional[ASRResponse] = None
|
||||
|
||||
|
||||
def _create_ssl_context() -> ssl.SSLContext:
|
||||
"""
|
||||
在线程中创建并初始化 SSL 上下文,避免阻塞事件循环
|
||||
"""
|
||||
ctx = ssl.create_default_context()
|
||||
return ctx
|
||||
|
||||
|
||||
class DoubaoASR:
|
||||
"""
|
||||
豆包输入法 ASR 客户端
|
||||
@@ -86,13 +149,13 @@ class DoubaoASR:
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
pass
|
||||
|
||||
async def _ensure_ssl_context(self):
|
||||
async def _get_ssl_context(self) -> ssl.SSLContext:
|
||||
"""
|
||||
确保 SSL 上下文已创建(在 executor 中运行避免阻塞)
|
||||
获取预初始化的 SSL 上下文(懒加载,在线程中创建)
|
||||
"""
|
||||
if self._ssl_context is None:
|
||||
loop = asyncio.get_event_loop()
|
||||
self._ssl_context = await loop.run_in_executor(None, ssl.create_default_context)
|
||||
self._ssl_context = await asyncio.to_thread(_create_ssl_context)
|
||||
return self._ssl_context
|
||||
|
||||
async def transcribe(self, audio: Union[str, Path, bytes], *, realtime = False, on_interim: Callable[[str], None] = None) -> str:
|
||||
"""
|
||||
@@ -127,21 +190,18 @@ class DoubaoASR:
|
||||
else:
|
||||
pcm_data = audio
|
||||
|
||||
opus_frames = await self._encoder.pcm_to_opus_frames(pcm_data)
|
||||
opus_frames = self._encoder.pcm_to_opus_frames(pcm_data)
|
||||
state = _SessionState()
|
||||
|
||||
# 预创建 SSL 上下文避免阻塞
|
||||
await self._ensure_ssl_context()
|
||||
|
||||
# 获取 WebSocket URL
|
||||
ws_url = await self.config.get_ws_url()
|
||||
ws_url = await self.config.async_ws_url()
|
||||
ssl_context = await self._get_ssl_context()
|
||||
|
||||
try:
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
additional_headers=self.config.headers,
|
||||
open_timeout=self.config.connect_timeout,
|
||||
ssl=self._ssl_context,
|
||||
ssl=ssl_context,
|
||||
) as ws:
|
||||
# 初始化会话
|
||||
async for resp in self._initialize_session(ws, state):
|
||||
@@ -207,18 +267,15 @@ class DoubaoASR:
|
||||
"""
|
||||
state = _SessionState()
|
||||
|
||||
# 预创建 SSL 上下文避免阻塞
|
||||
await self._ensure_ssl_context()
|
||||
|
||||
# 获取 WebSocket URL
|
||||
ws_url = await self.config.get_ws_url()
|
||||
ws_url = await self.config.async_ws_url()
|
||||
ssl_context = await self._get_ssl_context()
|
||||
|
||||
try:
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
additional_headers=self.config.headers,
|
||||
open_timeout=self.config.connect_timeout,
|
||||
ssl=self._ssl_context,
|
||||
ssl=ssl_context,
|
||||
) as ws:
|
||||
# 初始化会话
|
||||
async for resp in self._initialize_session(ws, state):
|
||||
@@ -270,9 +327,6 @@ class DoubaoASR:
|
||||
"""
|
||||
从异步迭代器读取 PCM 数据并实时发送
|
||||
"""
|
||||
# 预先获取编码器,避免在循环中触发阻塞导入
|
||||
encoder = await self._encoder.get_encoder()
|
||||
|
||||
timestamp_ms = int(time.time() * 1000)
|
||||
frame_index = 0
|
||||
pcm_buffer = b""
|
||||
@@ -294,7 +348,7 @@ class DoubaoASR:
|
||||
pcm_buffer = pcm_buffer[bytes_per_frame:]
|
||||
|
||||
# 编码为 Opus
|
||||
opus_frame = encoder.encode(pcm_frame, samples_per_frame)
|
||||
opus_frame = self._encoder.encoder.encode(pcm_frame, samples_per_frame)
|
||||
|
||||
# 确定帧状态(实时模式下不知道最后一帧,使用 FIRST/MIDDLE)
|
||||
if frame_index == 0:
|
||||
@@ -317,7 +371,7 @@ class DoubaoASR:
|
||||
if len(pcm_buffer) < bytes_per_frame:
|
||||
pcm_buffer += b"\x00" * (bytes_per_frame - len(pcm_buffer))
|
||||
|
||||
opus_frame = encoder.encode(pcm_buffer, samples_per_frame)
|
||||
opus_frame = self._encoder.encoder.encode(pcm_buffer, samples_per_frame)
|
||||
|
||||
msg = _build_asr_request(
|
||||
opus_frame,
|
||||
@@ -330,7 +384,7 @@ class DoubaoASR:
|
||||
# 没有剩余数据,但需要发送一个 LAST 帧标记
|
||||
# 发送一个空的 LAST 帧(静音)
|
||||
silent_frame = b"\x00" * bytes_per_frame
|
||||
opus_frame = encoder.encode(silent_frame, samples_per_frame)
|
||||
opus_frame = self._encoder.encoder.encode(silent_frame, samples_per_frame)
|
||||
|
||||
msg = _build_asr_request(
|
||||
opus_frame,
|
||||
@@ -342,14 +396,14 @@ class DoubaoASR:
|
||||
|
||||
# FinishSession
|
||||
if not state.is_finished:
|
||||
token = await self.config.get_token()
|
||||
token = await self.config.async_get_token()
|
||||
await ws.send(_build_finish_session(state.request_id, token))
|
||||
|
||||
async def _initialize_session(self, ws: ClientConnection, state: _SessionState) -> AsyncIterator[ASRResponse]:
|
||||
"""
|
||||
初始化 ASR 会话
|
||||
"""
|
||||
token = await self.config.get_token()
|
||||
token = await self.config.async_get_token()
|
||||
|
||||
# StartTask
|
||||
await ws.send(_build_start_task(state.request_id, token))
|
||||
@@ -360,9 +414,9 @@ class DoubaoASR:
|
||||
yield parsed
|
||||
|
||||
# StartSession
|
||||
session_config = await self.config.get_session_config()
|
||||
session_cfg = await self.config.async_session_config()
|
||||
await ws.send(
|
||||
_build_start_session(state.request_id, token, session_config)
|
||||
_build_start_session(state.request_id, token, session_cfg)
|
||||
)
|
||||
resp = await ws.recv()
|
||||
parsed = _parse_response(resp)
|
||||
@@ -406,7 +460,7 @@ class DoubaoASR:
|
||||
await asyncio.sleep(frame_interval)
|
||||
|
||||
# FinishSession
|
||||
token = await self.config.get_token()
|
||||
token = await self.config.async_get_token()
|
||||
await ws.send(_build_finish_session(state.request_id, token))
|
||||
|
||||
async def _receive_responses(
|
||||
@@ -499,6 +553,68 @@ def _build_asr_request(
|
||||
|
||||
|
||||
|
||||
def _parse_word(data: dict) -> ASRWord:
|
||||
"""解析单词数据"""
|
||||
return ASRWord(
|
||||
word=data.get("word", ""),
|
||||
start_time=data.get("start_time", 0.0),
|
||||
end_time=data.get("end_time", 0.0),
|
||||
)
|
||||
|
||||
|
||||
def _parse_oi_decoding_info(data: Optional[dict]) -> Optional[OIDecodingInfo]:
|
||||
"""解析 OI 解码信息"""
|
||||
if data is None:
|
||||
return None
|
||||
return OIDecodingInfo(
|
||||
oi_former_word_num=data.get("oi_former_word_num", 0),
|
||||
oi_latter_word_num=data.get("oi_latter_word_num", 0),
|
||||
oi_words=data.get("oi_words"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_alternative(data: dict) -> ASRAlternative:
|
||||
"""解析候选结果"""
|
||||
words = [_parse_word(w) for w in data.get("words", [])]
|
||||
return ASRAlternative(
|
||||
text=data.get("text", ""),
|
||||
start_time=data.get("start_time", 0.0),
|
||||
end_time=data.get("end_time", 0.0),
|
||||
words=words,
|
||||
semantic_related_to_prev=data.get("semantic_related_to_prev"),
|
||||
oi_decoding_info=_parse_oi_decoding_info(data.get("oi_decoding_info")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_result(data: dict) -> ASRResult:
|
||||
"""解析单条识别结果"""
|
||||
alternatives = [_parse_alternative(a) for a in data.get("alternatives", [])]
|
||||
return ASRResult(
|
||||
text=data.get("text", ""),
|
||||
start_time=data.get("start_time", 0.0),
|
||||
end_time=data.get("end_time", 0.0),
|
||||
confidence=data.get("confidence", 0.0),
|
||||
alternatives=alternatives,
|
||||
is_interim=data.get("is_interim", True),
|
||||
is_vad_finished=data.get("is_vad_finished", False),
|
||||
index=data.get("index", 0),
|
||||
)
|
||||
|
||||
|
||||
def _parse_extra(data: dict) -> ASRExtra:
|
||||
"""解析附加信息"""
|
||||
return ASRExtra(
|
||||
audio_duration=data.get("audio_duration"),
|
||||
model_avg_rtf=data.get("model_avg_rtf"),
|
||||
model_send_first_response=data.get("model_send_first_response"),
|
||||
speech_adaptation_version=data.get("speech_adaptation_version"),
|
||||
model_total_process_time=data.get("model_total_process_time"),
|
||||
packet_number=data.get("packet_number"),
|
||||
vad_start=data.get("vad_start"),
|
||||
req_payload=data.get("req_payload"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_response(data: bytes) -> ASRResponse:
|
||||
"""解析 ASR 响应 (使用 protobuf)"""
|
||||
pb = AsrResponsePb()
|
||||
@@ -530,20 +646,33 @@ def _parse_response(data: bytes) -> ASRResponse:
|
||||
except json.JSONDecodeError:
|
||||
return ASRResponse(type=ResponseType.UNKNOWN)
|
||||
|
||||
results = json_data.get("results")
|
||||
extra = json_data.get("extra", {})
|
||||
results_raw = json_data.get("results")
|
||||
extra_raw = json_data.get("extra", {})
|
||||
|
||||
# 解析为强类型
|
||||
parsed_extra = _parse_extra(extra_raw)
|
||||
|
||||
# 无 results,可能是心跳包
|
||||
if results is None:
|
||||
if results_raw is None:
|
||||
return ASRResponse(
|
||||
type=ResponseType.HEARTBEAT,
|
||||
packet_number=extra.get("packet_number", -1),
|
||||
packet_number=extra_raw.get("packet_number", -1),
|
||||
raw_json=json_data,
|
||||
extra=parsed_extra,
|
||||
)
|
||||
|
||||
# 解析 results
|
||||
parsed_results = [_parse_result(r) for r in results_raw]
|
||||
|
||||
# VAD 开始
|
||||
if extra.get("vad_start"):
|
||||
return ASRResponse(type=ResponseType.VAD_START, vad_start=True, raw_json=json_data)
|
||||
if extra_raw.get("vad_start"):
|
||||
return ASRResponse(
|
||||
type=ResponseType.VAD_START,
|
||||
vad_start=True,
|
||||
raw_json=json_data,
|
||||
results=parsed_results,
|
||||
extra=parsed_extra,
|
||||
)
|
||||
|
||||
# 解析识别结果
|
||||
text = ""
|
||||
@@ -551,7 +680,7 @@ def _parse_response(data: bytes) -> ASRResponse:
|
||||
vad_finished = False
|
||||
nonstream_result = False
|
||||
|
||||
for r in results:
|
||||
for r in results_raw:
|
||||
if r.get("text"):
|
||||
text = r.get("text")
|
||||
if r.get("is_interim") is False:
|
||||
@@ -569,6 +698,8 @@ def _parse_response(data: bytes) -> ASRResponse:
|
||||
is_final=True,
|
||||
vad_finished=vad_finished,
|
||||
raw_json=json_data,
|
||||
results=parsed_results,
|
||||
extra=parsed_extra,
|
||||
)
|
||||
|
||||
# 中间结果
|
||||
@@ -577,6 +708,8 @@ def _parse_response(data: bytes) -> ASRResponse:
|
||||
text=text,
|
||||
is_final=False,
|
||||
raw_json=json_data,
|
||||
results=parsed_results,
|
||||
extra=parsed_extra,
|
||||
)
|
||||
|
||||
|
||||
@@ -653,4 +786,4 @@ async def transcribe_realtime(
|
||||
"""
|
||||
async with DoubaoASR(config) as asr:
|
||||
async for resp in asr.transcribe_realtime(audio_source):
|
||||
yield resp
|
||||
yield resp
|
||||
@@ -1,13 +1,11 @@
|
||||
from typing import Optional, List, Union, TYPE_CHECKING
|
||||
from typing import Optional, List, Union
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
|
||||
import opuslib
|
||||
import miniaudio
|
||||
|
||||
from .config import ASRConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import opuslib
|
||||
|
||||
|
||||
class AudioEncoder:
|
||||
@@ -16,36 +14,19 @@ class AudioEncoder:
|
||||
"""
|
||||
def __init__(self, config: ASRConfig) -> None:
|
||||
self.config = config
|
||||
self._encoder: Optional["opuslib.Encoder"] = None
|
||||
self._opuslib_module = None
|
||||
self._encoder: Optional[opuslib.Encoder] = None
|
||||
|
||||
async def _ensure_opuslib(self):
|
||||
"""在 executor 中导入 opuslib,避免阻塞事件循环"""
|
||||
if self._opuslib_module is None:
|
||||
loop = asyncio.get_event_loop()
|
||||
self._opuslib_module = await loop.run_in_executor(None, self._import_opuslib)
|
||||
|
||||
@staticmethod
|
||||
def _import_opuslib():
|
||||
"""在独立线程中导入 opuslib"""
|
||||
import opuslib
|
||||
return opuslib
|
||||
|
||||
async def get_encoder(self) -> "opuslib.Encoder":
|
||||
"""异步获取编码器"""
|
||||
@property
|
||||
def encoder(self) -> opuslib.Encoder:
|
||||
if self._encoder is None:
|
||||
await self._ensure_opuslib()
|
||||
self._encoder = self._opuslib_module.Encoder(
|
||||
self._encoder = opuslib.Encoder(
|
||||
self.config.sample_rate,
|
||||
self.config.channels,
|
||||
self._opuslib_module.APPLICATION_AUDIO,
|
||||
opuslib.APPLICATION_AUDIO,
|
||||
)
|
||||
return self._encoder
|
||||
|
||||
async def pcm_to_opus_frames(self, pcm_data: bytes) -> List[bytes]:
|
||||
"""将 PCM 数据转换为 Opus 帧(异步)"""
|
||||
encoder = await self.get_encoder()
|
||||
|
||||
def pcm_to_opus_frames(self, pcm_data: bytes) -> List[bytes]:
|
||||
samples_per_frame = (
|
||||
self.config.sample_rate * self.config.frame_duration_ms // 1000
|
||||
)
|
||||
@@ -57,7 +38,7 @@ class AudioEncoder:
|
||||
if len(chunk) < bytes_per_frame:
|
||||
chunk = chunk + b"\x00" * (bytes_per_frame - len(chunk))
|
||||
|
||||
opus_frame = encoder.encode(chunk, samples_per_frame)
|
||||
opus_frame = self.encoder.encode(chunk, samples_per_frame)
|
||||
frames.append(opus_frame)
|
||||
|
||||
return frames
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
from dataclasses import dataclass, field
|
||||
import asyncio
|
||||
import base64
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import Optional, Union
|
||||
from pydantic import BaseModel
|
||||
import aiofiles
|
||||
|
||||
from .constants import WEBSOCKET_URL, USER_AGENT, AID
|
||||
from .device import DeviceCredentials, register_device, get_asr_token
|
||||
from .sami import get_sami_token
|
||||
|
||||
|
||||
def _jwt_is_expired(token: str, margin: int = 60) -> bool:
|
||||
"""
|
||||
检查 JWT token 是否已过期(提前 margin 秒视为过期)
|
||||
"""
|
||||
try:
|
||||
payload_b64 = token.split(".")[1]
|
||||
# JWT base64url 需要补齐 padding
|
||||
padding = 4 - len(payload_b64) % 4
|
||||
if padding != 4:
|
||||
payload_b64 += "=" * padding
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||
exp = payload.get("exp")
|
||||
if exp is None:
|
||||
return False
|
||||
return time.time() >= exp - margin
|
||||
except (IndexError, ValueError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
|
||||
class _AudioInfo(BaseModel):
|
||||
@@ -92,10 +113,11 @@ class ASRConfig:
|
||||
# 内部状态
|
||||
_credentials: Optional[DeviceCredentials] = field(default=None, repr=None)
|
||||
_initialized: bool = field(default=False, repr=False)
|
||||
_wave_client: Optional[object] = field(default=None, repr=False)
|
||||
|
||||
async def _load_credentials_from_file(self) -> Optional[DeviceCredentials]:
|
||||
def _load_credentials_from_file_sync(self) -> Optional[DeviceCredentials]:
|
||||
"""
|
||||
从缓存文件中加载凭据信息(异步)
|
||||
同步的文件读取实现(在线程中调用)
|
||||
"""
|
||||
if self.credential_path is None:
|
||||
return None
|
||||
@@ -105,34 +127,88 @@ class ASRConfig:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with aiofiles.open(path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
data = json.loads(content)
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.loads(f.read())
|
||||
return DeviceCredentials(**data)
|
||||
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
async def _save_credentials_to_file(self, creds: DeviceCredentials):
|
||||
def _save_credentials_to_file_sync(self, creds: DeviceCredentials):
|
||||
"""
|
||||
保存凭据至缓存文件(异步)
|
||||
同步的文件写入实现(在线程中调用)
|
||||
"""
|
||||
if self.credential_path is None:
|
||||
return
|
||||
|
||||
path = Path(self.credential_path).expanduser()
|
||||
|
||||
# 使用 run_in_executor 来执行同步的 mkdir 操作
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, lambda: path.parent.mkdir(parents=True, exist_ok=True))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with aiofiles.open(path, 'w', encoding='utf-8') as f:
|
||||
content = json.dumps(creds.model_dump(), indent=2, ensure_ascii=False)
|
||||
await f.write(content)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(creds.model_dump(), f, indent=2, ensure_ascii=False)
|
||||
|
||||
async def _load_credentials_from_file(self) -> Optional[DeviceCredentials]:
|
||||
"""
|
||||
从缓存文件中加载凭据信息(异步,不阻塞事件循环)
|
||||
"""
|
||||
return await asyncio.to_thread(self._load_credentials_from_file_sync)
|
||||
|
||||
async def _save_credentials_to_file(self, creds: DeviceCredentials):
|
||||
"""
|
||||
保存凭据至缓存文件(异步,不阻塞事件循环)
|
||||
"""
|
||||
await asyncio.to_thread(self._save_credentials_to_file_sync, creds)
|
||||
|
||||
def ensure_credentials(self):
|
||||
"""
|
||||
确保凭据已初始化(同步版本,仅用于非 async 上下文)
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# 保存直接通过参数传入的凭据,用于进行覆盖
|
||||
user_device_id = self.device_id
|
||||
user_token = self.token
|
||||
|
||||
# 尝试从文件中加载凭据
|
||||
file_creds = self._load_credentials_from_file_sync()
|
||||
if file_creds:
|
||||
self._credentials = file_creds
|
||||
# 使用文件中的值作为默认
|
||||
if self.device_id is None:
|
||||
self.device_id = file_creds.device_id
|
||||
if self.token is None:
|
||||
self.token = file_creds.token
|
||||
|
||||
# 如果 device_id 仍为 None, 则注册设备
|
||||
need_save = False
|
||||
if self.device_id is None:
|
||||
self._credentials = register_device()
|
||||
self.device_id = self._credentials.device_id
|
||||
need_save = True
|
||||
|
||||
# 如果 token 仍为 None, 则获取 token
|
||||
if self.token is None:
|
||||
cdid = self._credentials.cdid if self._credentials else None
|
||||
self.token = get_asr_token(self.device_id, cdid)
|
||||
|
||||
# 如果指定了 credential_path 且有新注册的凭据,则保存至文件
|
||||
if self.credential_path and need_save and self._credentials:
|
||||
self._credentials.token = self.token
|
||||
self._save_credentials_to_file_sync(self._credentials)
|
||||
|
||||
# 覆盖用户传入的参数
|
||||
if user_device_id is not None:
|
||||
self.device_id = user_device_id
|
||||
|
||||
if user_token is not None:
|
||||
self.token = user_token
|
||||
|
||||
self._initialized = True
|
||||
|
||||
async def async_ensure_credentials(self):
|
||||
"""
|
||||
确保凭据已初始化(异步)
|
||||
确保凭据已初始化(异步版本,不阻塞事件循环)
|
||||
|
||||
优先级:
|
||||
1. 直接传入的 device_id/token 参数(最高优先级)
|
||||
@@ -148,7 +224,7 @@ class ASRConfig:
|
||||
user_device_id = self.device_id
|
||||
user_token = self.token
|
||||
|
||||
# 尝试从文件中加载凭据
|
||||
# 尝试从文件中加载凭据(异步)
|
||||
file_creds = await self._load_credentials_from_file()
|
||||
if file_creds:
|
||||
self._credentials = file_creds
|
||||
@@ -158,23 +234,19 @@ class ASRConfig:
|
||||
if self.token is None:
|
||||
self.token = file_creds.token
|
||||
|
||||
# 如果 device_id 仍为 None, 则注册设备
|
||||
# 如果 device_id 仍为 None, 则注册设备(网络 I/O 在线程中执行)
|
||||
need_save = False
|
||||
if self.device_id is None:
|
||||
# 在 executor 中运行同步的 register_device
|
||||
loop = asyncio.get_event_loop()
|
||||
self._credentials = await loop.run_in_executor(None, register_device)
|
||||
self._credentials = await asyncio.to_thread(register_device)
|
||||
self.device_id = self._credentials.device_id
|
||||
need_save = True
|
||||
|
||||
# 如果 token 仍为 None, 则获取 token
|
||||
# 如果 token 仍为 None, 则获取 token(网络 I/O 在线程中执行)
|
||||
if self.token is None:
|
||||
cdid = self._credentials.cdid if self._credentials else None
|
||||
# 在 executor 中运行同步的 get_asr_token
|
||||
loop = asyncio.get_event_loop()
|
||||
self.token = await loop.run_in_executor(None, get_asr_token, self.device_id, cdid)
|
||||
self.token = await asyncio.to_thread(get_asr_token, self.device_id, cdid)
|
||||
|
||||
# 如果指定了 credential_path 且有新注册的凭据,则保存至文件
|
||||
# 如果指定了 credential_path 且有新注册的凭据,则保存至文件(异步)
|
||||
if self.credential_path and need_save and self._credentials:
|
||||
self._credentials.token = self.token
|
||||
await self._save_credentials_to_file(self._credentials)
|
||||
@@ -188,8 +260,18 @@ class ASRConfig:
|
||||
|
||||
self._initialized = True
|
||||
|
||||
async def get_ws_url(self) -> str:
|
||||
"""获取 WebSocket URL(异步)"""
|
||||
@property
|
||||
def ws_url(self) -> str:
|
||||
"""
|
||||
获取 WebSocket URL(同步版本,需确保凭据已初始化)
|
||||
"""
|
||||
self.ensure_credentials()
|
||||
return f'{self.url}?aid={self.aid}&device_id={self.device_id}'
|
||||
|
||||
async def async_ws_url(self) -> str:
|
||||
"""
|
||||
获取 WebSocket URL(异步版本,不阻塞事件循环)
|
||||
"""
|
||||
await self.async_ensure_credentials()
|
||||
return f'{self.url}?aid={self.aid}&device_id={self.device_id}'
|
||||
|
||||
@@ -201,9 +283,24 @@ class ASRConfig:
|
||||
"x-custom-keepalive": "true"
|
||||
}
|
||||
|
||||
async def get_session_config(self) -> SessionConfig:
|
||||
"""获取会话配置(异步)"""
|
||||
def session_config(self) -> SessionConfig:
|
||||
"""
|
||||
获取会话配置(同步版本,需确保凭据已初始化)
|
||||
"""
|
||||
self.ensure_credentials()
|
||||
return self._build_session_config()
|
||||
|
||||
async def async_session_config(self) -> SessionConfig:
|
||||
"""
|
||||
获取会话配置(异步版本,不阻塞事件循环)
|
||||
"""
|
||||
await self.async_ensure_credentials()
|
||||
return self._build_session_config()
|
||||
|
||||
def _build_session_config(self) -> SessionConfig:
|
||||
"""
|
||||
构建会话配置(内部方法,需确保凭据已初始化后调用)
|
||||
"""
|
||||
audio_info = _AudioInfo(
|
||||
channel=self.channels,
|
||||
format="speech_opus",
|
||||
@@ -225,7 +322,114 @@ class ASRConfig:
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
async def get_token(self) -> str:
|
||||
"""获取 token(异步)"""
|
||||
def get_token(self) -> str:
|
||||
"""
|
||||
获取 token(同步版本,需确保凭据已初始化)
|
||||
"""
|
||||
self.ensure_credentials()
|
||||
return self.token
|
||||
|
||||
async def async_get_token(self) -> str:
|
||||
"""
|
||||
获取 token(异步版本,不阻塞事件循环)
|
||||
"""
|
||||
await self.async_ensure_credentials()
|
||||
return self.token
|
||||
return self.token
|
||||
|
||||
def _on_wave_session_update(self, session) -> None:
|
||||
"""
|
||||
Wave 会话更新时的回调,将新会话同步到凭据缓存
|
||||
"""
|
||||
if self._credentials:
|
||||
self._credentials.wave_session = session.to_dict()
|
||||
self._save_credentials_to_file_sync(self._credentials)
|
||||
|
||||
def get_wave_client(self):
|
||||
"""
|
||||
获取 WaveClient 实例(同步版本,懒加载,自动管理握手)
|
||||
"""
|
||||
from .wave_client import WaveClient, WaveSession
|
||||
|
||||
self.ensure_credentials()
|
||||
if self._wave_client is None:
|
||||
cached_session = None
|
||||
if self._credentials and self._credentials.wave_session:
|
||||
try:
|
||||
session = WaveSession.from_dict(self._credentials.wave_session)
|
||||
if not session.is_expired():
|
||||
cached_session = session
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
self._wave_client = WaveClient(
|
||||
self.device_id,
|
||||
self.aid,
|
||||
session=cached_session,
|
||||
on_session_update=self._on_wave_session_update,
|
||||
)
|
||||
return self._wave_client
|
||||
|
||||
async def async_get_wave_client(self):
|
||||
"""
|
||||
获取 WaveClient 实例(异步版本,不阻塞事件循环)
|
||||
"""
|
||||
from .wave_client import WaveClient, WaveSession
|
||||
|
||||
await self.async_ensure_credentials()
|
||||
if self._wave_client is None:
|
||||
cached_session = None
|
||||
if self._credentials and self._credentials.wave_session:
|
||||
try:
|
||||
session = WaveSession.from_dict(self._credentials.wave_session)
|
||||
if not session.is_expired():
|
||||
cached_session = session
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
self._wave_client = WaveClient(
|
||||
self.device_id,
|
||||
self.aid,
|
||||
session=cached_session,
|
||||
on_session_update=self._on_wave_session_update,
|
||||
)
|
||||
return self._wave_client
|
||||
|
||||
def get_sami_token(self) -> str:
|
||||
"""
|
||||
获取 SAMI token(同步版本,需确保凭据已初始化)
|
||||
"""
|
||||
self.ensure_credentials()
|
||||
|
||||
if (self._credentials
|
||||
and self._credentials.sami_token
|
||||
and not _jwt_is_expired(self._credentials.sami_token)):
|
||||
return self._credentials.sami_token
|
||||
|
||||
cdid = self._credentials.cdid if self._credentials else None
|
||||
sami_token = get_sami_token(cdid)
|
||||
|
||||
if self._credentials:
|
||||
self._credentials.sami_token = sami_token
|
||||
self._save_credentials_to_file_sync(self._credentials)
|
||||
|
||||
return sami_token
|
||||
|
||||
async def async_get_sami_token(self) -> str:
|
||||
"""
|
||||
获取 SAMI token(异步版本,不阻塞事件循环)
|
||||
"""
|
||||
await self.async_ensure_credentials()
|
||||
|
||||
if (self._credentials
|
||||
and self._credentials.sami_token
|
||||
and not _jwt_is_expired(self._credentials.sami_token)):
|
||||
return self._credentials.sami_token
|
||||
|
||||
cdid = self._credentials.cdid if self._credentials else None
|
||||
sami_token = await asyncio.to_thread(get_sami_token, cdid)
|
||||
|
||||
if self._credentials:
|
||||
self._credentials.sami_token = sami_token
|
||||
await self._save_credentials_to_file(self._credentials)
|
||||
|
||||
return sami_token
|
||||
|
||||
@@ -7,9 +7,24 @@ SETTINGS_URL = "https://is.snssdk.com/service/settings/v3/"
|
||||
# ASR WebSocket URL
|
||||
WEBSOCKET_URL = "wss://frontier-audio-ime-ws.doubao.com/ocean/api/v1/ws"
|
||||
|
||||
# 需要通过该接口获取 SAMI TOKEN 来访问 NER 接口
|
||||
SAMI_CONFIG_URL = "https://ime.oceancloudapi.com/api/v1/user/get_config"
|
||||
|
||||
# 加密协议握手 URL
|
||||
HANDSHAKE_URL = "https://keyhub.zijieapi.com/handshake"
|
||||
|
||||
# NER 接口 URL
|
||||
NER_URL = "https://speech.bytedance.com/api/v3/context/ime/ner"
|
||||
|
||||
# 豆包输入法的 APP ID
|
||||
AID = 401734
|
||||
|
||||
# SAMI 语音相关的服务 APP KEY (写死在客户端的)
|
||||
SAMI_APP_KEY = "SYlxZr6LnvBaIVmF"
|
||||
|
||||
# HKDF info 字符串 (写死在客户端的)
|
||||
HKDF_INFO = b"4e30514609050cd3"
|
||||
|
||||
# 应用配置 (豆包输入法)
|
||||
APP_CONFIG = {
|
||||
"aid": AID,
|
||||
|
||||
@@ -29,6 +29,14 @@ class DeviceCredentials(BaseModel):
|
||||
"""
|
||||
用于 ASR 的 token
|
||||
"""
|
||||
sami_token: Optional[str] = None
|
||||
"""
|
||||
用于 NER 等 SAMI 服务的 token
|
||||
"""
|
||||
wave_session: Optional[dict] = None
|
||||
"""
|
||||
Wave 加密会话缓存(序列化后的 WaveSession)
|
||||
"""
|
||||
|
||||
|
||||
class DeviceRegisterHeaderField(BaseModel):
|
||||
@@ -273,7 +281,6 @@ def register_device() -> DeviceCredentials:
|
||||
params=params.model_dump(),
|
||||
json=body.model_dump(),
|
||||
headers=headers,
|
||||
verify=False, # 禁用SSL证书验证以兼容某些环境
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
@@ -311,7 +318,6 @@ def get_asr_token(device_id: str, cdid: str) -> str:
|
||||
params=params,
|
||||
data=body_str,
|
||||
headers=headers,
|
||||
verify=False, # 禁用SSL证书验证以兼容某些环境
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .wave_client import WaveClient
|
||||
from .constants import AID, APP_CONFIG, NER_URL, SAMI_APP_KEY
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import ASRConfig
|
||||
|
||||
|
||||
class NerUserInfo(BaseModel):
|
||||
uid: str = "0"
|
||||
did: str
|
||||
app_name: str
|
||||
app_version: str
|
||||
sdk_version: str = ""
|
||||
platform: str = "android"
|
||||
experience_improve: bool = False
|
||||
|
||||
@classmethod
|
||||
def new(cls, did: str, app_name: str):
|
||||
app_version = APP_CONFIG.get("version_name", "")
|
||||
return cls(did=did, app_name=app_name, app_version=app_version)
|
||||
|
||||
|
||||
class NerRequest(BaseModel):
|
||||
"""
|
||||
ner 接口请求体
|
||||
"""
|
||||
user: NerUserInfo
|
||||
text: str
|
||||
additions: dict = Field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def new(cls, text: str, did: str, app_name: str = "", addiction: dict = None):
|
||||
return cls(user=NerUserInfo.new(did, app_name), text=text, additions=addiction or {})
|
||||
|
||||
|
||||
class NerWord(BaseModel):
|
||||
freq: int
|
||||
word: str
|
||||
|
||||
|
||||
class NerResult(BaseModel):
|
||||
text: str
|
||||
words: List[NerWord]
|
||||
|
||||
|
||||
class NerResponse(BaseModel):
|
||||
"""
|
||||
ner 接口响应体
|
||||
"""
|
||||
results: List[NerResult]
|
||||
|
||||
|
||||
def get_ner_results(wave_client: WaveClient, sami_token: str, text: str, did: str, app_name: str = "") -> NerResponse:
|
||||
"""
|
||||
调用 ner 接口获取结果
|
||||
"""
|
||||
request = NerRequest.new(text, did, app_name)
|
||||
|
||||
headers = {
|
||||
'app_version': APP_CONFIG.get('version_name', ''),
|
||||
'app_id': str(AID),
|
||||
'os_type': 'android',
|
||||
'x-api-resource-id': 'asr.user.context',
|
||||
'x-api-app-key': SAMI_APP_KEY,
|
||||
'x-api-token': sami_token,
|
||||
'x-api-request-id': str(uuid.uuid4()),
|
||||
}
|
||||
req_data = request.model_dump_json().encode()
|
||||
|
||||
payload, headers = wave_client.prepare_request(req_data, headers)
|
||||
|
||||
response = requests.post(NER_URL, data=payload, headers=headers)
|
||||
|
||||
resp_headers = response.headers
|
||||
nonce = base64.b64decode(resp_headers.get('x-tt-e-p'))
|
||||
|
||||
decoded = wave_client.decrypt(response.content, nonce=nonce)
|
||||
|
||||
return NerResponse(**json.loads(decoded.decode()))
|
||||
|
||||
|
||||
def ner(config: ASRConfig, text: str, app_name: str = "") -> NerResponse:
|
||||
"""
|
||||
通过 ASRConfig 调用 NER 接口(同步便捷函数)
|
||||
|
||||
自动管理 WaveClient、sami_token 和 device_id。
|
||||
|
||||
:param config: ASR 配置(会自动初始化凭据)
|
||||
:param text: 需要进行 NER 的文本
|
||||
:param app_name: 应用名称(可选),可能会根据不同应用的使用场景适配不同的识别策略?"
|
||||
:return: NER 响应
|
||||
"""
|
||||
config.ensure_credentials()
|
||||
wave_client = config.get_wave_client()
|
||||
sami_token = config.get_sami_token()
|
||||
return get_ner_results(wave_client, sami_token, text, config.device_id, app_name)
|
||||
|
||||
|
||||
async def async_ner(config: ASRConfig, text: str, app_name: str = "") -> NerResponse:
|
||||
"""
|
||||
通过 ASRConfig 调用 NER 接口(异步便捷函数,不阻塞事件循环)
|
||||
|
||||
自动管理 WaveClient、sami_token 和 device_id。
|
||||
|
||||
:param config: ASR 配置(会自动初始化凭据)
|
||||
:param text: 需要进行 NER 的文本
|
||||
:param app_name: 应用名称(可选)
|
||||
:return: NER 响应
|
||||
"""
|
||||
import asyncio
|
||||
await config.async_ensure_credentials()
|
||||
wave_client = await config.async_get_wave_client()
|
||||
sami_token = await config.async_get_sami_token()
|
||||
return await asyncio.to_thread(
|
||||
get_ner_results, wave_client, sami_token, text, config.device_id, app_name
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
SAMI 服务相关
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .constants import SAMI_CONFIG_URL, SAMI_APP_KEY, USER_AGENT, APP_CONFIG, DEFAULT_DEVICE_CONFIG
|
||||
|
||||
|
||||
class _SamiConfigParams(BaseModel):
|
||||
"""
|
||||
SAMI 配置接口的 URL Params
|
||||
"""
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
device_platform: str = "android"
|
||||
os: str = "android"
|
||||
ssmix: str = "a"
|
||||
rticket: str = Field(default_factory=lambda: str(int(time.time() * 1000)), serialization_alias="_rticket")
|
||||
cdid: str
|
||||
channel: str
|
||||
aid: str
|
||||
app_name: str
|
||||
version_code: str
|
||||
version_name: str
|
||||
manifest_version_code: str
|
||||
update_version_code: str
|
||||
resolution: str
|
||||
dpi: str
|
||||
device_type: str
|
||||
device_brand: str
|
||||
language: str
|
||||
os_api: str
|
||||
os_version: str
|
||||
ac: str = "wifi"
|
||||
use_olympus_account: str = Field(default="1", serialization_alias="use-olympus-account")
|
||||
|
||||
@classmethod
|
||||
def default(cls, cdid: str) -> "_SamiConfigParams":
|
||||
"""
|
||||
使用默认配置构建 SAMI 配置 Params
|
||||
"""
|
||||
app_config = {
|
||||
**{k: APP_CONFIG[k] for k in ("channel", "app_name", "version_name")},
|
||||
"aid": str(APP_CONFIG["aid"]),
|
||||
"version_code": str(APP_CONFIG["version_code"]),
|
||||
"manifest_version_code": str(APP_CONFIG["manifest_version_code"]),
|
||||
"update_version_code": str(APP_CONFIG["update_version_code"]),
|
||||
}
|
||||
|
||||
device_keys = ("device_platform", "os", "resolution", "dpi", "device_type",
|
||||
"device_brand", "language", "os_api", "os_version")
|
||||
device_config = {k: DEFAULT_DEVICE_CONFIG[k] for k in device_keys}
|
||||
|
||||
return cls(cdid=cdid, **app_config, **device_config)
|
||||
|
||||
|
||||
class _SamiConfigRequest(BaseModel):
|
||||
"""
|
||||
SAMI 配置接口的请求体
|
||||
"""
|
||||
sami_app_key: str = SAMI_APP_KEY
|
||||
|
||||
|
||||
class _SamiConfigData(BaseModel):
|
||||
"""SAMI 配置数据"""
|
||||
sami_token: str
|
||||
|
||||
|
||||
class _SamiConfigResponse(BaseModel):
|
||||
"""
|
||||
SAMI 配置接口的响应
|
||||
"""
|
||||
code: int
|
||||
msg: str
|
||||
data: _SamiConfigData
|
||||
|
||||
@property
|
||||
def sami_token(self) -> str:
|
||||
return self.data.sami_token
|
||||
|
||||
|
||||
def get_sami_config(cdid: str) -> requests.Response:
|
||||
"""
|
||||
获取 SAMI 配置 (包含 token)
|
||||
|
||||
Args:
|
||||
cdid: 客户端设备 ID
|
||||
|
||||
Returns:
|
||||
响应对象
|
||||
"""
|
||||
params = _SamiConfigParams.default(cdid)
|
||||
body = _SamiConfigRequest()
|
||||
body_json = body.model_dump_json()
|
||||
x_ss_stub = hashlib.md5(body_json.encode()).hexdigest().upper()
|
||||
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Content-Type": "application/json",
|
||||
"app_version": APP_CONFIG["version_name"],
|
||||
"app_id": str(APP_CONFIG["aid"]),
|
||||
"os_type": "Android",
|
||||
"x-ss-stub": x_ss_stub,
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
SAMI_CONFIG_URL,
|
||||
params=params.model_dump(by_alias=True),
|
||||
data=body_json,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def get_sami_token(cdid: Optional[str] = None) -> str:
|
||||
"""
|
||||
获取 SAMI token
|
||||
|
||||
:param cdid: 客户端设备 ID,如果为 None 则自动生成
|
||||
:return: SAMI token 字符串
|
||||
"""
|
||||
if cdid is None:
|
||||
cdid = str(uuid.uuid4())
|
||||
|
||||
response = get_sami_config(cdid)
|
||||
response.raise_for_status()
|
||||
|
||||
data = _SamiConfigResponse(**response.json())
|
||||
return data.sami_token
|
||||
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
ByteDance Wave 加密协议客户端
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from typing import Callable, Optional, Union
|
||||
|
||||
import requests
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .constants import HANDSHAKE_URL, HKDF_INFO, USER_AGENT
|
||||
|
||||
|
||||
class _KeyShare(BaseModel):
|
||||
"""
|
||||
密钥交换信息
|
||||
"""
|
||||
curve: str
|
||||
pubkey: str
|
||||
|
||||
|
||||
class _HandshakeRequest(BaseModel):
|
||||
"""
|
||||
握手请求
|
||||
"""
|
||||
version: int = 2
|
||||
random: str
|
||||
app_id: str
|
||||
did: str
|
||||
key_shares: list[_KeyShare]
|
||||
cipher_suites: list[int] = [4097] # ChaCha20
|
||||
|
||||
|
||||
class _HandshakeResponse(BaseModel):
|
||||
"""
|
||||
握手响应
|
||||
"""
|
||||
version: int
|
||||
random: str
|
||||
key_share: _KeyShare
|
||||
cipher_suite: int
|
||||
cert: str
|
||||
ticket: str
|
||||
ticket_exp: int
|
||||
ticket_long: str
|
||||
ticket_long_exp: int
|
||||
|
||||
|
||||
class WaveSession(BaseModel):
|
||||
"""
|
||||
Wave 加密会话
|
||||
"""
|
||||
ticket: str
|
||||
ticket_long: str
|
||||
encryption_key: bytes
|
||||
client_random: bytes
|
||||
server_random: bytes
|
||||
shared_key: bytes
|
||||
ticket_exp: int
|
||||
ticket_long_exp: int
|
||||
expires_at: float
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""检查会话是否已过期"""
|
||||
return time.time() >= self.expires_at
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""序列化为可 JSON 存储的 dict(bytes 字段用 base64 编码)"""
|
||||
return {
|
||||
"ticket": self.ticket,
|
||||
"ticket_long": self.ticket_long,
|
||||
"encryption_key": base64.b64encode(self.encryption_key).decode(),
|
||||
"client_random": base64.b64encode(self.client_random).decode(),
|
||||
"server_random": base64.b64encode(self.server_random).decode(),
|
||||
"shared_key": base64.b64encode(self.shared_key).decode(),
|
||||
"ticket_exp": self.ticket_exp,
|
||||
"ticket_long_exp": self.ticket_long_exp,
|
||||
"expires_at": self.expires_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "WaveSession":
|
||||
"""从 dict 反序列化(base64 解码 bytes 字段)"""
|
||||
return cls(
|
||||
ticket=data["ticket"],
|
||||
ticket_long=data["ticket_long"],
|
||||
encryption_key=base64.b64decode(data["encryption_key"]),
|
||||
client_random=base64.b64decode(data["client_random"]),
|
||||
server_random=base64.b64decode(data["server_random"]),
|
||||
shared_key=base64.b64decode(data["shared_key"]),
|
||||
ticket_exp=data["ticket_exp"],
|
||||
ticket_long_exp=data["ticket_long_exp"],
|
||||
expires_at=data["expires_at"],
|
||||
)
|
||||
|
||||
|
||||
class WaveClient:
|
||||
"""
|
||||
Wave 协议客户端
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
app_id: Union[str, int],
|
||||
session: Optional[WaveSession] = None,
|
||||
on_session_update: Optional[Callable[[WaveSession], None]] = None,
|
||||
):
|
||||
self.device_id = device_id
|
||||
self.app_id = str(app_id)
|
||||
self.session = session
|
||||
self._on_session_update = on_session_update
|
||||
|
||||
@staticmethod
|
||||
def _chacha20_crypt(key: bytes, nonce: bytes, data: bytes) -> bytes:
|
||||
"""ChaCha20 加密/解密"""
|
||||
if len(nonce) == 12:
|
||||
nonce_16 = b'\x00\x00\x00\x00' + nonce
|
||||
else:
|
||||
nonce_16 = nonce
|
||||
cipher = Cipher(algorithms.ChaCha20(key, nonce_16), mode=None, backend=default_backend())
|
||||
encryptor = cipher.encryptor()
|
||||
return encryptor.update(data) + encryptor.finalize()
|
||||
|
||||
@staticmethod
|
||||
def _derive_key(shared_key: bytes, salt: bytes, info: bytes) -> bytes:
|
||||
"""HKDF 密钥派生"""
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
info=info,
|
||||
backend=default_backend()
|
||||
).derive(shared_key)
|
||||
|
||||
def handshake(self) -> bool:
|
||||
"""
|
||||
执行 Wave 握手,建立加密会话
|
||||
|
||||
:return: 握手是否成功
|
||||
"""
|
||||
# 生成 ECDH 密钥对
|
||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
client_random = secrets.token_bytes(32)
|
||||
|
||||
pubkey_bytes = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
|
||||
request = _HandshakeRequest(
|
||||
random=base64.b64encode(client_random).decode(),
|
||||
app_id=self.app_id,
|
||||
did=self.device_id,
|
||||
key_shares=[_KeyShare(curve="secp256r1", pubkey=base64.b64encode(pubkey_bytes).decode())],
|
||||
)
|
||||
|
||||
request_json = request.model_dump_json(by_alias=True)
|
||||
|
||||
# ECDSA 签名
|
||||
signature = private_key.sign(request_json.encode(), ec.ECDSA(hashes.SHA256()))
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-tt-s-sign": base64.b64encode(signature).decode(),
|
||||
"User-Agent": USER_AGENT,
|
||||
}
|
||||
|
||||
response = requests.post(HANDSHAKE_URL, data=request_json, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
|
||||
resp = _HandshakeResponse(**response.json())
|
||||
|
||||
# 计算共享密钥
|
||||
server_pubkey = base64.b64decode(resp.key_share.pubkey)
|
||||
server_public_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), server_pubkey)
|
||||
shared_key = private_key.exchange(ec.ECDH(), server_public_key)
|
||||
server_random = base64.b64decode(resp.random)
|
||||
|
||||
# 派生加密密钥
|
||||
salt = client_random + server_random
|
||||
encryption_key = self._derive_key(shared_key, salt, HKDF_INFO)
|
||||
|
||||
# 保存会话,计算绝对过期时间(提前 60 秒刷新)
|
||||
self.session = WaveSession(
|
||||
ticket=resp.ticket,
|
||||
ticket_long=resp.ticket_long,
|
||||
encryption_key=encryption_key,
|
||||
client_random=client_random,
|
||||
server_random=server_random,
|
||||
shared_key=shared_key,
|
||||
ticket_exp=resp.ticket_exp,
|
||||
ticket_long_exp=resp.ticket_long_exp,
|
||||
expires_at=time.time() + resp.ticket_exp - 60,
|
||||
)
|
||||
|
||||
if self._on_session_update:
|
||||
self._on_session_update(self.session)
|
||||
|
||||
return True
|
||||
|
||||
def _ensure_session(self) -> None:
|
||||
"""确保会话有效,如果过期则自动刷新"""
|
||||
if self.session is None or self.session.is_expired():
|
||||
if not self.handshake():
|
||||
raise RuntimeError("Failed to establish/refresh session")
|
||||
|
||||
def prepare_request(self, plaintext: bytes, extra_headers: Optional[dict] = None) -> tuple[bytes, dict]:
|
||||
"""
|
||||
准备加密请求
|
||||
|
||||
:param plaintext: 明文数据
|
||||
:param extra_headers: 额外的请求头
|
||||
:return: (密文, headers) 元组
|
||||
"""
|
||||
self._ensure_session()
|
||||
|
||||
nonce = secrets.token_bytes(12)
|
||||
ciphertext = self._chacha20_crypt(self.session.encryption_key, nonce, plaintext)
|
||||
stub = hashlib.md5(ciphertext).hexdigest().upper()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-tt-e-b": "1",
|
||||
"x-tt-e-t": self.session.ticket,
|
||||
"x-tt-e-p": base64.b64encode(nonce).decode(),
|
||||
"x-ss-stub": stub,
|
||||
}
|
||||
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
return ciphertext, headers
|
||||
|
||||
def decrypt(self, ciphertext: bytes, nonce: bytes) -> bytes:
|
||||
"""
|
||||
解密数据
|
||||
|
||||
:param ciphertext: 密文数据
|
||||
:param nonce: 12 字节 nonce
|
||||
:return: 明文数据
|
||||
"""
|
||||
if not self.session:
|
||||
raise RuntimeError("No active session. Call handshake() first.")
|
||||
|
||||
return self._chacha20_crypt(self.session.encryption_key, nonce, ciphertext)
|
||||
Reference in New Issue
Block a user