feat(avatar): add BOXIM chat takeover
This commit is contained in:
@@ -1,76 +1,178 @@
|
||||
"""盒子 IM 客户端 — 封装网易云信 IM 接口调用"""
|
||||
"""Client for Huihui's self-hosted BOXIM production APIs."""
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
_CN_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
class BoxIMError(RuntimeError):
|
||||
def __init__(self, message: str, *, code: Any = None, auth_error: bool = False):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.auth_error = auth_error
|
||||
|
||||
|
||||
class BoxIMClient:
|
||||
"""盒子 IM 客户端,通过会会平台网关调用网易云信 IM"""
|
||||
"""Exchange Huihui credentials and call BOXIM's private-message API."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.base_url = config.get("HUIHUI_IM_BASE_URL", "http://192.168.1.200:60040")
|
||||
self.platform_base_url = config.get(
|
||||
"HUIHUI_PLATFORM_BASE_URL", "https://open.99hui.com/api"
|
||||
).rstrip("/")
|
||||
self.im_base_url = config.get(
|
||||
"BOXIM_API_BASE_URL", "https://im.99hui.com/api"
|
||||
).rstrip("/")
|
||||
self.app_id = config.get("HUIHUI_APP_ID", "")
|
||||
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
|
||||
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
|
||||
self.timeout = float(config.get("BOXIM_TIMEOUT_SECONDS", 20))
|
||||
|
||||
def _build_sign_params(self, extra: dict) -> dict:
|
||||
"""构建带签名的请求参数(复用 news_service 签名模式)"""
|
||||
nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S") # 24小时制
|
||||
def _build_sign_params(self, extra: dict | None = None) -> dict:
|
||||
"""Build the same signed form used by Huihui's current production app."""
|
||||
params = {
|
||||
"appId": self.app_id,
|
||||
"accessId": self.access_id,
|
||||
"nonce": nonce,
|
||||
"timestamp": timestamp,
|
||||
**extra,
|
||||
"nonce": "".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
|
||||
"timestamp": datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S"),
|
||||
"signType": "MD5",
|
||||
"signVersion": "1.0",
|
||||
**(extra or {}),
|
||||
}
|
||||
# 计算签名 — 排序 key, 过滤空值, 拼接后加 accessSecret, MD5 大写
|
||||
keys = sorted(params.keys())
|
||||
params.pop("accessSecret", None)
|
||||
params.pop("signature", None)
|
||||
sign_parts = []
|
||||
for k in keys:
|
||||
if k in ("signature", "accessSecret"):
|
||||
for key in sorted(params):
|
||||
value = params[key]
|
||||
if value in (None, "", []):
|
||||
continue
|
||||
v = params.get(k)
|
||||
if v and v != "" and v != []:
|
||||
sign_parts.append(f"{k}={v}")
|
||||
sign_str = "&".join(sign_parts) + f"&accessSecret={self.access_secret}"
|
||||
signature = hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
params["signature"] = signature
|
||||
params["signType"] = "MD5"
|
||||
params["signVersion"] = "1.0"
|
||||
if isinstance(value, list):
|
||||
continue
|
||||
sign_parts.append(f"{key}={value}")
|
||||
sign_source = "&".join(sign_parts) + f"&accessSecret={self.access_secret}"
|
||||
params["signature"] = hashlib.md5(sign_source.encode("utf-8")).hexdigest().upper()
|
||||
return params
|
||||
|
||||
async def get_credentials(self, user_id: str) -> Optional[dict]:
|
||||
"""获取用户的网易云信 IM 凭证 (accid, token)"""
|
||||
params = self._build_sign_params({"userId": user_id})
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base_url}/box/netease",
|
||||
params=params,
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("code") in (0, 200):
|
||||
return data.get("data", {})
|
||||
return None
|
||||
@staticmethod
|
||||
def _response_payload(response: httpx.Response) -> dict:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise BoxIMError("BOXIM 返回了无效响应") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise BoxIMError("BOXIM 返回格式不正确")
|
||||
return payload
|
||||
|
||||
async def send_p2p_message(
|
||||
self, from_accid: str, to_accid: str, content: str
|
||||
) -> bool:
|
||||
"""发送单聊消息(文本)"""
|
||||
params = self._build_sign_params({
|
||||
"from": from_accid,
|
||||
"to": to_accid,
|
||||
"msgType": "text",
|
||||
"content": content,
|
||||
})
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base_url}/box/message/send/p2p",
|
||||
params=params,
|
||||
async def exchange_access_token(self, huihui_token: str) -> dict:
|
||||
"""Exchange a production Huihui token for a BOXIM access token."""
|
||||
if not huihui_token:
|
||||
raise BoxIMError("缺少会会登录凭证", auth_error=True)
|
||||
if not (self.app_id and self.access_id and self.access_secret):
|
||||
raise BoxIMError("会会开放平台凭证未配置", auth_error=True)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {huihui_token}",
|
||||
"appId": self.app_id,
|
||||
"windowAppId": self.app_id,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
response = await client.post(
|
||||
f"{self.platform_base_url}/im/box/netease",
|
||||
headers=headers,
|
||||
data=self._build_sign_params(),
|
||||
)
|
||||
data = r.json()
|
||||
return data.get("code") in (0, 200)
|
||||
payload = self._response_payload(response)
|
||||
data = payload.get("data") or {}
|
||||
code = payload.get("code")
|
||||
if response.status_code >= 400 or code not in (0, 200, "0", "200"):
|
||||
raise BoxIMError(
|
||||
payload.get("message") or "BOXIM 授权失败",
|
||||
code=code or response.status_code,
|
||||
auth_error=response.status_code in (400, 401, 403)
|
||||
or code in (400, 401, 40100, 403, "400", "401", "40100", "403"),
|
||||
)
|
||||
if not data.get("accessToken"):
|
||||
raise BoxIMError("会会未返回 BOXIM 访问凭证", auth_error=True)
|
||||
return data
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
access_token: str,
|
||||
*,
|
||||
params: dict | None = None,
|
||||
json: dict | None = None,
|
||||
) -> Any:
|
||||
headers = {"accessToken": access_token}
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{self.im_base_url}{path}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
payload = self._response_payload(response)
|
||||
code = payload.get("code")
|
||||
if response.status_code >= 400 or code not in (200, "200"):
|
||||
raise BoxIMError(
|
||||
payload.get("message") or "BOXIM 请求失败",
|
||||
code=code or response.status_code,
|
||||
auth_error=response.status_code in (400, 401, 403)
|
||||
or code in (400, 401, 403, "400", "401", "403"),
|
||||
)
|
||||
return payload.get("data")
|
||||
|
||||
async def get_self(self, access_token: str) -> dict:
|
||||
data = await self._request("GET", "/user/self", access_token)
|
||||
if not isinstance(data, dict) or data.get("id") is None:
|
||||
raise BoxIMError("BOXIM 未返回当前用户信息")
|
||||
return data
|
||||
|
||||
async def fetch_private_messages(self, access_token: str, min_id: str = "0") -> list[dict]:
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/message/private/loadOfflineMessage",
|
||||
access_token,
|
||||
params={"minId": str(min_id or "0")},
|
||||
)
|
||||
if data is None:
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
raise BoxIMError("BOXIM 私聊消息格式不正确")
|
||||
return [item for item in data if isinstance(item, dict)]
|
||||
|
||||
async def send_private_message(
|
||||
self,
|
||||
access_token: str,
|
||||
peer_id: str,
|
||||
content: str,
|
||||
*,
|
||||
local_id: int | str | None = None,
|
||||
) -> dict:
|
||||
local_id = int(local_id or (int(time.time() * 1000) * 1000 + secrets.randbelow(1000)))
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/message/private/send",
|
||||
access_token,
|
||||
json={
|
||||
"localId": local_id,
|
||||
"recvId": int(peer_id) if str(peer_id).isdigit() else peer_id,
|
||||
"content": content,
|
||||
"type": 0,
|
||||
"receipt": False,
|
||||
"atUserIds": [],
|
||||
},
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise BoxIMError("BOXIM 未返回发送结果")
|
||||
return data
|
||||
|
||||
@@ -1,184 +1,565 @@
|
||||
"""Takeover service — message listening, decision, reply execution."""
|
||||
import json
|
||||
"""Restart-safe automatic replies over Huihui's self-hosted BOXIM."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import Avatar, Authorization
|
||||
from services.boxim_client import BoxIMClient
|
||||
from models import (
|
||||
Avatar,
|
||||
TakeoverCursor,
|
||||
TakeoverMessage,
|
||||
TakeoverReplyTask,
|
||||
User,
|
||||
)
|
||||
from services.boxim_client import BoxIMClient, BoxIMError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTIVE_TASK_STATUSES = ("pending", "generating", "ready", "sending")
|
||||
GENERATABLE_TASK_STATUSES = ("pending",)
|
||||
MAX_PROMPT_LENGTH = 4000
|
||||
MAX_STALE_SECONDS = 120
|
||||
STUCK_LOCK_SECONDS = 90
|
||||
TAKEOVER_PERMISSION = "takeover"
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def _takeover_enabled(avatar: Avatar | None) -> bool:
|
||||
if not avatar or avatar.status != "active":
|
||||
return False
|
||||
permissions = (avatar.config or {}).get("authorizationPermissions", [])
|
||||
return isinstance(permissions, list) and TAKEOVER_PERMISSION in permissions
|
||||
|
||||
|
||||
def _boxim_time(value, fallback: datetime) -> datetime:
|
||||
try:
|
||||
timestamp = float(value)
|
||||
if timestamp > 10_000_000_000:
|
||||
timestamp /= 1000
|
||||
return datetime.utcfromtimestamp(timestamp)
|
||||
except (TypeError, ValueError, OSError, OverflowError):
|
||||
return fallback
|
||||
|
||||
|
||||
def _numeric_id(value) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _plain_text_reply(value: str) -> str:
|
||||
"""BOXIM is plain text, so remove Markdown markers without damaging paragraphs."""
|
||||
text = (value or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
text = re.sub(r"```(?:\w+)?\n?(.*?)```", r"\1", text, flags=re.S)
|
||||
text = re.sub(r"\*\*(.*?)\*\*|__(.*?)__", lambda m: m.group(1) or m.group(2), text)
|
||||
text = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"\1", text)
|
||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||
text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M)
|
||||
lines = [line.strip() for line in text.split("\n")]
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
|
||||
|
||||
class TakeoverService:
|
||||
"""Service for handling avatar takeover — generating replies and sending them via IM."""
|
||||
"""Poll BOXIM, prepare replies during the grace period, then send at +3s."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
session_factory: Callable[[], Session],
|
||||
boxim_client: BoxIMClient,
|
||||
redis_client=None,
|
||||
*,
|
||||
reply_delay_seconds: int = 3,
|
||||
now: Callable[[], datetime] = _utcnow,
|
||||
):
|
||||
self.db = db
|
||||
self.session_factory = session_factory
|
||||
self.boxim = boxim_client
|
||||
self.redis = redis_client
|
||||
self._chat_api_base = os.getenv(
|
||||
"TAKEOVER_CHAT_API_BASE", "http://localhost:8000/api"
|
||||
)
|
||||
|
||||
def check_takeover_enabled(
|
||||
self, owner_huihui_id: str, from_user_id: str
|
||||
) -> Optional[Authorization]:
|
||||
"""Check whether takeover is enabled for the given target user."""
|
||||
avatar = (
|
||||
self.db.query(Avatar)
|
||||
.filter(Avatar.owner_id == owner_huihui_id, Avatar.status == "active")
|
||||
.first()
|
||||
)
|
||||
if not avatar:
|
||||
return None
|
||||
|
||||
auth = (
|
||||
self.db.query(Authorization)
|
||||
.filter(Authorization.avatar_id == avatar.id)
|
||||
.filter(Authorization.target_id == from_user_id)
|
||||
.filter(Authorization.target_type == "user")
|
||||
.filter(Authorization.status == "active")
|
||||
.filter(Authorization.takeover_enabled == True)
|
||||
.first()
|
||||
)
|
||||
permissions = set(auth.permissions or []) if auth else set()
|
||||
return auth if auth and "takeover" in permissions else None
|
||||
|
||||
async def generate_reply(self, avatar_id: str, message: str) -> str:
|
||||
"""Call the avatar chat endpoint to generate a reply."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{self._chat_api_base}/avatar/{avatar_id}/chat",
|
||||
json={"message": message, "history": []},
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("code") in (0, 200):
|
||||
return data.get("data", {}).get("answer", "")
|
||||
logger.warning(f"Avatar chat API returned error code: {data}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to call avatar chat API: {e}")
|
||||
return ""
|
||||
|
||||
async def execute_takeover(self, auth: Authorization, message: dict) -> bool:
|
||||
"""Execute takeover: generate a reply and send it as the owner via IM."""
|
||||
try:
|
||||
# Resolve owner through Avatar model
|
||||
avatar = self.db.query(Avatar).filter(Avatar.id == auth.avatar_id).first()
|
||||
if not avatar:
|
||||
logger.warning(f"Avatar not found: {auth.avatar_id}")
|
||||
return False
|
||||
|
||||
owner_huihui_id = avatar.owner_id
|
||||
credentials = await self.boxim.get_credentials(owner_huihui_id)
|
||||
if not credentials:
|
||||
logger.warning(f"Cannot obtain IM credentials for owner: {owner_huihui_id}")
|
||||
return False
|
||||
|
||||
reply = await self.generate_reply(auth.avatar_id, message.get("content", ""))
|
||||
if not reply:
|
||||
logger.warning("Avatar did not generate a reply")
|
||||
return False
|
||||
|
||||
success = await self.boxim.send_p2p_message(
|
||||
from_accid=credentials["accid"],
|
||||
to_accid=message.get("from_accid", ""),
|
||||
content=reply,
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Takeover reply sent successfully: {reply[:50]}...")
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"Takeover execution failed: {e}")
|
||||
return False
|
||||
|
||||
def enqueue_delayed_message(self, auth: Authorization, message: dict):
|
||||
"""Write a message into the Redis delayed queue (TTL = delay + 10s buffer)."""
|
||||
if not self.redis:
|
||||
logger.warning("Redis not configured, degrading to immediate takeover")
|
||||
return
|
||||
|
||||
avatar = self.db.query(Avatar).filter(Avatar.id == auth.avatar_id).first()
|
||||
owner_huihui_id = avatar.owner_id if avatar else ""
|
||||
key = f"takeover:delayed:{auth.target_id}:{message.get('msg_id', '')}"
|
||||
value = json.dumps({
|
||||
"avatar_id": auth.avatar_id,
|
||||
"from_accid": message.get("from_accid", ""),
|
||||
"content": message.get("content", ""),
|
||||
"owner_huihui_id": owner_huihui_id,
|
||||
})
|
||||
self.redis.setex(key, auth.takeover_delay_seconds + 10, value)
|
||||
logger.info(f"Message enqueued to delayed queue: {key}")
|
||||
|
||||
async def process_delayed_queue(self):
|
||||
"""Process expired messages from the delayed queue.
|
||||
|
||||
Scans Redis keys matching the takeover:delayed: pattern and dispatches
|
||||
each to execute_takeover after resolving the Authorization.
|
||||
"""
|
||||
if not self.redis:
|
||||
return
|
||||
try:
|
||||
pattern = "takeover:delayed:*"
|
||||
keys = self.redis.keys(pattern)
|
||||
for key in keys:
|
||||
raw = self.redis.get(key)
|
||||
if not raw:
|
||||
continue
|
||||
data = json.loads(raw)
|
||||
auth = (
|
||||
self.db.query(Authorization)
|
||||
.filter(Authorization.target_id == key.split(":")[2])
|
||||
.first()
|
||||
)
|
||||
if auth:
|
||||
message = {
|
||||
"msg_id": key.split(":")[-1],
|
||||
"from_accid": data.get("from_accid", ""),
|
||||
"content": data.get("content", ""),
|
||||
}
|
||||
await self.execute_takeover(auth, message)
|
||||
self.redis.delete(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process delayed queue: {e}")
|
||||
self.reply_delay_seconds = reply_delay_seconds
|
||||
self.now = now
|
||||
self._sessions: dict[str, dict] = {}
|
||||
self._run_lock = asyncio.Lock()
|
||||
|
||||
async def poll_and_process_messages(self):
|
||||
"""Periodic polling job: fetch unread messages and process each."""
|
||||
"""Run one complete cycle; polling always happens before reply dispatch."""
|
||||
if self._run_lock.locked():
|
||||
return
|
||||
async with self._run_lock:
|
||||
self._recover_stuck_tasks()
|
||||
avatar_ids = self._enabled_avatar_ids()
|
||||
self._cancel_disabled_tasks(set(avatar_ids))
|
||||
for avatar_id in avatar_ids:
|
||||
await self._sync_avatar(avatar_id)
|
||||
|
||||
generated = await self._prepare_replies()
|
||||
if generated:
|
||||
# Catch a human reply sent while the model was preparing its answer.
|
||||
for avatar_id in avatar_ids:
|
||||
await self._sync_avatar(avatar_id)
|
||||
await self._dispatch_ready_replies()
|
||||
|
||||
def _enabled_avatar_ids(self) -> list[str]:
|
||||
db = self.session_factory()
|
||||
try:
|
||||
messages = await self.fetch_unread_messages()
|
||||
for msg in messages:
|
||||
await self.process_message(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"poll_and_process_messages failed: {e}")
|
||||
return [
|
||||
avatar.id
|
||||
for avatar in db.query(Avatar).filter(Avatar.status == "active").all()
|
||||
if _takeover_enabled(avatar)
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def fetch_unread_messages(self) -> list:
|
||||
"""Fetch unread messages from Box IM. Stub — replace with real API call."""
|
||||
logger.debug("fetch_unread_messages: no real API wired yet")
|
||||
return []
|
||||
def _cancel_disabled_tasks(self, enabled_avatar_ids: set[str]):
|
||||
db = self.session_factory()
|
||||
try:
|
||||
tasks = (
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(TakeoverReplyTask.status.in_(ACTIVE_TASK_STATUSES))
|
||||
.all()
|
||||
)
|
||||
changed = False
|
||||
for task in tasks:
|
||||
if task.avatar_id not in enabled_avatar_ids:
|
||||
task.status = "cancelled"
|
||||
task.cancel_reason = "takeover_disabled"
|
||||
task.locked_at = None
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def process_message(self, message: dict):
|
||||
"""Process a single message: check takeover, dispatch immediate or delayed."""
|
||||
owner_id = message.get("owner_huihui_id", "")
|
||||
from_id = message.get("from_accid", "")
|
||||
def _recover_stuck_tasks(self):
|
||||
db = self.session_factory()
|
||||
try:
|
||||
threshold = self.now() - timedelta(seconds=STUCK_LOCK_SECONDS)
|
||||
tasks = (
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(
|
||||
TakeoverReplyTask.status.in_(("generating", "sending")),
|
||||
TakeoverReplyTask.locked_at.isnot(None),
|
||||
TakeoverReplyTask.locked_at < threshold,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for task in tasks:
|
||||
task.status = "pending" if task.status == "generating" else "ready"
|
||||
task.locked_at = None
|
||||
task.last_error = "上次处理意外中断,已自动恢复"
|
||||
if tasks:
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
auth = self.check_takeover_enabled(owner_id, from_id)
|
||||
if not auth:
|
||||
async def _boxim_session(self, user: User) -> dict:
|
||||
token_fingerprint = hashlib.sha256((user.huihui_token or "").encode()).hexdigest()
|
||||
cached = self._sessions.get(user.id)
|
||||
if (
|
||||
cached
|
||||
and cached["expires_at"] > time.monotonic()
|
||||
and cached["token_fingerprint"] == token_fingerprint
|
||||
):
|
||||
return cached
|
||||
|
||||
token_data = await self.boxim.exchange_access_token(user.huihui_token)
|
||||
access_token = token_data["accessToken"]
|
||||
profile = await self.boxim.get_self(access_token)
|
||||
try:
|
||||
expires_in = int(token_data.get("accessTokenExpiresIn") or 3600)
|
||||
except (TypeError, ValueError):
|
||||
expires_in = 3600
|
||||
if expires_in > 86_400:
|
||||
expires_in //= 1000
|
||||
cache_for = max(60, min(expires_in - 60, 3600))
|
||||
cached = {
|
||||
"access_token": access_token,
|
||||
"boxim_owner_id": str(profile["id"]),
|
||||
"expires_at": time.monotonic() + cache_for,
|
||||
"token_fingerprint": token_fingerprint,
|
||||
}
|
||||
self._sessions[user.id] = cached
|
||||
return cached
|
||||
|
||||
def _forget_boxim_session(self, user_id: str):
|
||||
self._sessions.pop(user_id, None)
|
||||
|
||||
async def _sync_avatar(self, avatar_id: str) -> bool:
|
||||
db = self.session_factory()
|
||||
try:
|
||||
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not _takeover_enabled(avatar):
|
||||
return False
|
||||
user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first()
|
||||
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
|
||||
if not cursor:
|
||||
cursor = TakeoverCursor(avatar_id=avatar.id, owner_id=avatar.owner_id)
|
||||
db.add(cursor)
|
||||
db.flush()
|
||||
if not user or not user.huihui_token:
|
||||
cursor.last_error = "请重新登录会会生产账号后再开启主动接管"
|
||||
cursor.last_polled_at = self.now()
|
||||
db.commit()
|
||||
return False
|
||||
|
||||
try:
|
||||
session = await self._boxim_session(user)
|
||||
owner_boxim_id = session["boxim_owner_id"]
|
||||
if cursor.boxim_owner_id and cursor.boxim_owner_id != owner_boxim_id:
|
||||
cursor.initialized = False
|
||||
cursor.last_message_id = "0"
|
||||
cursor.boxim_owner_id = owner_boxim_id
|
||||
messages = await self.boxim.fetch_private_messages(
|
||||
session["access_token"], cursor.last_message_id or "0"
|
||||
)
|
||||
except Exception as exc:
|
||||
if isinstance(exc, BoxIMError) and exc.auth_error:
|
||||
self._forget_boxim_session(user.id)
|
||||
cursor.last_error = "BOXIM 授权已失效,请重新登录会会生产账号"
|
||||
else:
|
||||
cursor.last_error = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
|
||||
cursor.last_polled_at = self.now()
|
||||
db.commit()
|
||||
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc)
|
||||
return False
|
||||
|
||||
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
|
||||
priming = not bool(cursor.initialized)
|
||||
max_message_id = _numeric_id(cursor.last_message_id)
|
||||
for message in messages:
|
||||
self._record_message(
|
||||
db,
|
||||
avatar,
|
||||
cursor.boxim_owner_id,
|
||||
message,
|
||||
schedule_reply=not priming,
|
||||
)
|
||||
max_message_id = max(max_message_id, _numeric_id(message.get("id")))
|
||||
|
||||
cursor.last_message_id = str(max_message_id)
|
||||
cursor.initialized = True
|
||||
cursor.last_polled_at = self.now()
|
||||
cursor.last_error = ""
|
||||
db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to persist BOXIM messages for avatar %s", avatar_id)
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _record_message(
|
||||
self,
|
||||
db: Session,
|
||||
avatar: Avatar,
|
||||
boxim_owner_id: str,
|
||||
message: dict,
|
||||
*,
|
||||
schedule_reply: bool,
|
||||
):
|
||||
message_id = str(message.get("id") or "").strip()
|
||||
if not message_id:
|
||||
return
|
||||
local_id = str(message.get("localId") or "").strip() or None
|
||||
if (
|
||||
db.query(TakeoverMessage)
|
||||
.filter(
|
||||
TakeoverMessage.owner_id == avatar.owner_id,
|
||||
TakeoverMessage.boxim_message_id == message_id,
|
||||
)
|
||||
.first()
|
||||
):
|
||||
return
|
||||
|
||||
if auth.takeover_mode == "immediate":
|
||||
await self.execute_takeover(auth, message)
|
||||
elif not self.redis:
|
||||
logger.warning("Redis not configured, executing delayed takeover immediately")
|
||||
await self.execute_takeover(auth, message)
|
||||
send_id = str(message.get("sendId") or "")
|
||||
recv_id = str(message.get("recvId") or "")
|
||||
if send_id == boxim_owner_id:
|
||||
direction, peer_id = "outgoing", recv_id
|
||||
elif recv_id == boxim_owner_id:
|
||||
direction, peer_id = "incoming", send_id
|
||||
else:
|
||||
self.enqueue_delayed_message(auth, message)
|
||||
return
|
||||
if not peer_id:
|
||||
return
|
||||
|
||||
now = self.now()
|
||||
send_time = _boxim_time(message.get("sendTime"), now)
|
||||
is_avatar = False
|
||||
if direction == "outgoing" and local_id:
|
||||
is_avatar = bool(
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(
|
||||
TakeoverReplyTask.owner_id == avatar.owner_id,
|
||||
TakeoverReplyTask.boxim_local_id == local_id,
|
||||
TakeoverReplyTask.status == "sent",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
event = TakeoverMessage(
|
||||
avatar_id=avatar.id,
|
||||
owner_id=avatar.owner_id,
|
||||
boxim_message_id=message_id,
|
||||
boxim_local_id=local_id,
|
||||
peer_id=peer_id,
|
||||
direction=direction,
|
||||
message_type=int(message.get("type") or 0),
|
||||
content=str(message.get("content") or ""),
|
||||
is_avatar=is_avatar,
|
||||
send_time=send_time,
|
||||
)
|
||||
db.add(event)
|
||||
db.flush()
|
||||
|
||||
if direction == "outgoing":
|
||||
if not is_avatar:
|
||||
self._cancel_conversation(db, avatar.owner_id, peer_id, "owner_replied")
|
||||
return
|
||||
if not schedule_reply or event.message_type != 0 or not event.content.strip():
|
||||
return
|
||||
if (now - send_time).total_seconds() > MAX_STALE_SECONDS:
|
||||
return
|
||||
self._schedule_reply(db, avatar, event)
|
||||
|
||||
@staticmethod
|
||||
def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str):
|
||||
tasks = (
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(
|
||||
TakeoverReplyTask.owner_id == owner_id,
|
||||
TakeoverReplyTask.peer_id == peer_id,
|
||||
TakeoverReplyTask.status.in_(ACTIVE_TASK_STATUSES),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for task in tasks:
|
||||
task.status = "cancelled"
|
||||
task.cancel_reason = reason
|
||||
task.locked_at = None
|
||||
|
||||
def _schedule_reply(self, db: Session, avatar: Avatar, event: TakeoverMessage):
|
||||
active_tasks = (
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(
|
||||
TakeoverReplyTask.owner_id == avatar.owner_id,
|
||||
TakeoverReplyTask.peer_id == event.peer_id,
|
||||
TakeoverReplyTask.status.in_(("pending", "generating", "ready")),
|
||||
)
|
||||
.order_by(TakeoverReplyTask.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
prompt_parts = []
|
||||
source_ids = []
|
||||
if active_tasks:
|
||||
latest = active_tasks[0]
|
||||
prompt_parts.append(latest.prompt)
|
||||
source_ids.extend(latest.source_message_ids or [])
|
||||
for task in active_tasks:
|
||||
task.status = "cancelled"
|
||||
task.cancel_reason = "newer_incoming_message"
|
||||
task.locked_at = None
|
||||
prompt_parts.append(event.content.strip())
|
||||
source_ids.append(event.boxim_message_id)
|
||||
prompt = "\n".join(part for part in prompt_parts if part).strip()[-MAX_PROMPT_LENGTH:]
|
||||
due_at = event.send_time + timedelta(seconds=self.reply_delay_seconds)
|
||||
task_id = secrets.token_hex(16)
|
||||
local_id = int(time.time() * 1000) * 1000 + secrets.randbelow(1000)
|
||||
db.add(
|
||||
TakeoverReplyTask(
|
||||
id=task_id,
|
||||
avatar_id=avatar.id,
|
||||
owner_id=avatar.owner_id,
|
||||
peer_id=event.peer_id,
|
||||
trigger_message_id=event.boxim_message_id,
|
||||
source_message_ids=source_ids,
|
||||
prompt=prompt,
|
||||
status="pending",
|
||||
scheduled_at=due_at,
|
||||
boxim_local_id=str(local_id),
|
||||
)
|
||||
)
|
||||
|
||||
async def _prepare_replies(self) -> int:
|
||||
db = self.session_factory()
|
||||
try:
|
||||
task_ids = [
|
||||
row[0]
|
||||
for row in (
|
||||
db.query(TakeoverReplyTask.id)
|
||||
.filter(
|
||||
TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES),
|
||||
TakeoverReplyTask.response_text == "",
|
||||
)
|
||||
.order_by(TakeoverReplyTask.created_at.asc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
generated = 0
|
||||
for task_id in task_ids:
|
||||
if await asyncio.to_thread(self._generate_reply, task_id):
|
||||
generated += 1
|
||||
return generated
|
||||
|
||||
def _generate_reply(self, task_id: str) -> bool:
|
||||
db = self.session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.id == task_id).first()
|
||||
if not task or task.status != "pending":
|
||||
return False
|
||||
avatar = db.query(Avatar).filter(Avatar.id == task.avatar_id).first()
|
||||
if not _takeover_enabled(avatar):
|
||||
task.status = "cancelled"
|
||||
task.cancel_reason = "takeover_disabled"
|
||||
db.commit()
|
||||
return False
|
||||
|
||||
task.status = "generating"
|
||||
task.locked_at = self.now()
|
||||
db.commit()
|
||||
|
||||
excluded_ids = set(task.source_message_ids or [])
|
||||
events = (
|
||||
db.query(TakeoverMessage)
|
||||
.filter(
|
||||
TakeoverMessage.owner_id == task.owner_id,
|
||||
TakeoverMessage.peer_id == task.peer_id,
|
||||
)
|
||||
.order_by(TakeoverMessage.send_time.desc())
|
||||
.limit(30)
|
||||
.all()
|
||||
)
|
||||
history = []
|
||||
for event in reversed(events):
|
||||
if event.boxim_message_id in excluded_ids or not event.content.strip():
|
||||
continue
|
||||
history.append(
|
||||
{
|
||||
"role": "user" if event.direction == "incoming" else "assistant",
|
||||
"content": event.content.strip(),
|
||||
}
|
||||
)
|
||||
history = history[-10:]
|
||||
|
||||
from routers.chat import _resolve_reply
|
||||
|
||||
result = _resolve_reply(db, avatar, task.prompt, history)
|
||||
answer = _plain_text_reply(result.get("answer", ""))
|
||||
db.refresh(task)
|
||||
if task.status != "generating":
|
||||
return False
|
||||
if not answer:
|
||||
raise RuntimeError("分身没有生成有效回复")
|
||||
task.response_text = answer
|
||||
task.status = "ready"
|
||||
task.locked_at = None
|
||||
task.last_error = ""
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.id == task_id).first()
|
||||
if task and task.status in ("pending", "generating"):
|
||||
task.attempts = (task.attempts or 0) + 1
|
||||
task.status = "pending" if task.attempts < 3 else "failed"
|
||||
task.locked_at = None
|
||||
task.last_error = str(exc)[:300]
|
||||
db.commit()
|
||||
logger.warning("Failed to prepare takeover reply %s: %s", task_id, exc)
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def _dispatch_ready_replies(self):
|
||||
db = self.session_factory()
|
||||
try:
|
||||
task_ids = [
|
||||
row[0]
|
||||
for row in (
|
||||
db.query(TakeoverReplyTask.id)
|
||||
.filter(
|
||||
TakeoverReplyTask.status == "ready",
|
||||
TakeoverReplyTask.scheduled_at <= self.now(),
|
||||
)
|
||||
.order_by(TakeoverReplyTask.scheduled_at.asc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
for task_id in task_ids:
|
||||
await self._send_task(task_id)
|
||||
|
||||
async def _send_task(self, task_id: str) -> bool:
|
||||
db = self.session_factory()
|
||||
user = None
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.id == task_id).first()
|
||||
if not task or task.status != "ready":
|
||||
return False
|
||||
avatar = db.query(Avatar).filter(Avatar.id == task.avatar_id).first()
|
||||
if not _takeover_enabled(avatar):
|
||||
task.status = "cancelled"
|
||||
task.cancel_reason = "takeover_disabled"
|
||||
db.commit()
|
||||
return False
|
||||
if (self.now() - task.scheduled_at).total_seconds() > MAX_STALE_SECONDS:
|
||||
task.status = "cancelled"
|
||||
task.cancel_reason = "stale_reply"
|
||||
db.commit()
|
||||
return False
|
||||
user = db.query(User).filter(User.huihui_user_id == task.owner_id).first()
|
||||
if not user or not user.huihui_token:
|
||||
raise BoxIMError("缺少会会登录凭证", auth_error=True)
|
||||
|
||||
task.status = "sending"
|
||||
task.locked_at = self.now()
|
||||
db.commit()
|
||||
session = await self._boxim_session(user)
|
||||
result = await self.boxim.send_private_message(
|
||||
session["access_token"],
|
||||
task.peer_id,
|
||||
task.response_text,
|
||||
local_id=task.boxim_local_id,
|
||||
)
|
||||
db.refresh(task)
|
||||
if task.status != "sending":
|
||||
return False
|
||||
task.status = "sent"
|
||||
task.sent_at = self.now()
|
||||
task.locked_at = None
|
||||
task.last_error = ""
|
||||
task.boxim_sent_message_id = str(result.get("id") or "")
|
||||
db.commit()
|
||||
logger.info("BOXIM takeover reply sent for task %s", task.id)
|
||||
return True
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
if user and isinstance(exc, BoxIMError) and exc.auth_error:
|
||||
self._forget_boxim_session(user.id)
|
||||
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.id == task_id).first()
|
||||
if task and task.status in ("ready", "sending"):
|
||||
task.attempts = (task.attempts or 0) + 1
|
||||
task.status = "ready" if task.attempts < 3 else "failed"
|
||||
task.locked_at = None
|
||||
task.last_error = str(exc)[:300]
|
||||
if task.status == "ready":
|
||||
task.scheduled_at = self.now() + timedelta(seconds=2 ** task.attempts)
|
||||
db.commit()
|
||||
logger.warning("Failed to send takeover reply %s: %s", task_id, exc)
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user