155 lines
5.9 KiB
Python
155 lines
5.9 KiB
Python
"""Голосовые и кружки Бобра расшифровывает Telegram Premium, а достаёт beavergram.
|
||
|
||
Бот получает файл из лички с Бобром; userbot beavergram видит то же
|
||
сообщение с аккаунта Бобра и кладёт медиа по sha256 содержимого. По хешу
|
||
находится сообщение, по сообщению - расшифровка: готовая или свежая через
|
||
`/api/media/transcribe`. Треки и аудиофайлы не расшифровываются - только
|
||
`voice` и `video_note`; трек агент при нужде расшифрует MCP-тулзой сам.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import logging
|
||
from dataclasses import dataclass, field
|
||
from typing import TYPE_CHECKING, Any
|
||
from urllib.parse import parse_qs, urlsplit
|
||
|
||
import aiohttp
|
||
|
||
if TYPE_CHECKING:
|
||
from collections.abc import Mapping
|
||
from pathlib import Path
|
||
|
||
from beaver_gateway.transcribe import Audio
|
||
|
||
__all__ = ["BeavergramTranscriber"]
|
||
|
||
_log = logging.getLogger(__name__)
|
||
|
||
HEARD = frozenset({"audio/ogg", "video/mp4"})
|
||
"""Типы, под которыми Telegram-окно отдаёт голосовое и кружок."""
|
||
SPOKEN = ("voice", "video_note")
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class BeavergramTranscriber:
|
||
origin: str
|
||
token: str = field(repr=False)
|
||
tg_user_id: int
|
||
"""Аккаунт Бобра в beavergram ищется по его Telegram id."""
|
||
find_for: float = 15.0
|
||
"""Сколько ждать, пока userbot поймает и скачает то же сообщение."""
|
||
find_every: float = 1.0
|
||
wait: int = 60
|
||
"""Сколько beavergram ждёт Premium-расшифровку, прежде чем ответить pending."""
|
||
request_timeout: float = 10.0
|
||
_account: int | None = field(default=None, init=False, repr=False)
|
||
|
||
@classmethod
|
||
def from_env(cls, env: Mapping[str, str]) -> BeavergramTranscriber | None:
|
||
"""Из `BEAVERGRAM_MCP` (`https://host/mcp?token=…`) и `TELEGRAM_USER_ID`."""
|
||
url = urlsplit(env.get("BEAVERGRAM_MCP", ""))
|
||
token = parse_qs(url.query).get("token", [""])[0]
|
||
user = env.get("TELEGRAM_USER_ID", "")
|
||
if not (url.scheme and url.netloc and token and user.isdigit()):
|
||
return None
|
||
return cls(
|
||
origin=f"{url.scheme}://{url.netloc}", token=token, tg_user_id=int(user)
|
||
)
|
||
|
||
async def __call__(self, audio: Audio) -> str | None:
|
||
if audio.media_type not in HEARD:
|
||
return None
|
||
try:
|
||
digest = await asyncio.to_thread(_sha256, audio.path)
|
||
async with aiohttp.ClientSession(
|
||
base_url=self.origin,
|
||
headers={"Authorization": f"Bearer {self.token}"},
|
||
timeout=aiohttp.ClientTimeout(total=self.request_timeout),
|
||
) as http:
|
||
return await self._transcribe(http, digest)
|
||
except (aiohttp.ClientError, TimeoutError, OSError, ValueError) as exc:
|
||
_log.warning(
|
||
"beavergram: %s не расшифровано: %s: %s",
|
||
audio.path.name,
|
||
type(exc).__name__,
|
||
exc,
|
||
)
|
||
return None
|
||
|
||
async def _transcribe(self, http: aiohttp.ClientSession, digest: str) -> str | None:
|
||
account = await self._account_id(http)
|
||
if account is None:
|
||
_log.warning("beavergram: нет аккаунта с tg_user_id %s", self.tg_user_id)
|
||
return None
|
||
place = await self._find(http, digest, account)
|
||
if place is None:
|
||
_log.info("beavergram: голосового с sha256 %s нет", digest[:12])
|
||
return None
|
||
if place.get("extracted_text"):
|
||
return place["extracted_text"]
|
||
result = await _json(
|
||
http.post(
|
||
"/api/media/transcribe",
|
||
json={
|
||
"account_id": account,
|
||
"chat_id": place["chat_id"],
|
||
"message_id": place["message_id"],
|
||
"wait": self.wait,
|
||
},
|
||
timeout=aiohttp.ClientTimeout(total=self.wait + self.request_timeout),
|
||
)
|
||
)
|
||
if result.get("status") == "done":
|
||
return result.get("text")
|
||
_log.info(
|
||
"beavergram: расшифровка %s/%s - %s %s",
|
||
place["chat_id"],
|
||
place["message_id"],
|
||
result.get("status"),
|
||
result.get("error") or "",
|
||
)
|
||
return None
|
||
|
||
async def _account_id(self, http: aiohttp.ClientSession) -> int | None:
|
||
if self._account is None:
|
||
accounts = await _json(http.get("/api/accounts"))
|
||
self._account = next(
|
||
(
|
||
a["account_id"]
|
||
for a in accounts
|
||
if a.get("tg_user_id") == self.tg_user_id
|
||
),
|
||
None,
|
||
)
|
||
return self._account
|
||
|
||
async def _find(
|
||
self, http: aiohttp.ClientSession, digest: str, account: int
|
||
) -> dict[str, Any] | None:
|
||
loop = asyncio.get_running_loop()
|
||
deadline = loop.time() + self.find_for
|
||
while True:
|
||
places = await _json(
|
||
http.get(
|
||
"/api/media/lookup",
|
||
params={"sha256": digest, "account_id": account},
|
||
)
|
||
)
|
||
if places or loop.time() >= deadline:
|
||
return next((p for p in places if p.get("kind") in SPOKEN), None)
|
||
await asyncio.sleep(self.find_every)
|
||
|
||
|
||
async def _json(request: Any) -> Any:
|
||
async with request as response:
|
||
response.raise_for_status()
|
||
return await response.json()
|
||
|
||
|
||
def _sha256(path: Path) -> str:
|
||
with path.open("rb") as file:
|
||
return hashlib.file_digest(file, "sha256").hexdigest()
|