fix(avatar): prevent takeover loops and isolate settings
This commit is contained in:
@@ -28,6 +28,14 @@ MAX_PROMPT_LENGTH = 4000
|
||||
MAX_STALE_SECONDS = 120
|
||||
STUCK_LOCK_SECONDS = 90
|
||||
TAKEOVER_PERMISSION = "takeover"
|
||||
TAKEOVER_DELAY_KEY = "takeoverReplyDelaySeconds"
|
||||
DEFAULT_REPLY_DELAY_SECONDS = 180
|
||||
MIN_REPLY_DELAY_SECONDS = 3
|
||||
MAX_REPLY_DELAY_SECONDS = 86_400
|
||||
HUMAN_PAUSE_SECONDS = 600
|
||||
RATE_LIMIT_WINDOW_SECONDS = 300
|
||||
RATE_LIMIT_MAX_REPLIES = 5
|
||||
AVATAR_LOCAL_ID_PREFIX = "880"
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
@@ -70,15 +78,43 @@ def _plain_text_reply(value: str) -> str:
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
|
||||
|
||||
def _avatar_local_id(owner_id: str, trigger_message_id: str) -> str:
|
||||
"""Build a deterministic BOXIM idempotency key that also marks avatar traffic."""
|
||||
digest = hashlib.sha256(f"{owner_id}:{trigger_message_id}".encode("utf-8")).digest()
|
||||
suffix = int.from_bytes(digest[:8], "big") % (10**15)
|
||||
return f"{AVATAR_LOCAL_ID_PREFIX}{suffix:015d}"
|
||||
|
||||
|
||||
def _is_avatar_local_id(value: str | None) -> bool:
|
||||
local_id = str(value or "").strip()
|
||||
return len(local_id) == 18 and local_id.isdigit() and local_id.startswith(AVATAR_LOCAL_ID_PREFIX)
|
||||
|
||||
|
||||
def _configured_reply_delay(avatar: Avatar, fallback: int | None = None) -> int:
|
||||
raw = (avatar.config or {}).get(
|
||||
TAKEOVER_DELAY_KEY,
|
||||
fallback if fallback is not None else DEFAULT_REPLY_DELAY_SECONDS,
|
||||
)
|
||||
if isinstance(raw, bool):
|
||||
return DEFAULT_REPLY_DELAY_SECONDS
|
||||
try:
|
||||
delay = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_REPLY_DELAY_SECONDS
|
||||
if not MIN_REPLY_DELAY_SECONDS <= delay <= MAX_REPLY_DELAY_SECONDS:
|
||||
return DEFAULT_REPLY_DELAY_SECONDS
|
||||
return delay
|
||||
|
||||
|
||||
class TakeoverService:
|
||||
"""Poll BOXIM, prepare replies during the grace period, then send at +3s."""
|
||||
"""Poll BOXIM, honor the owner grace period, then generate and send one reply."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[], Session],
|
||||
boxim_client: BoxIMClient,
|
||||
*,
|
||||
reply_delay_seconds: int = 3,
|
||||
reply_delay_seconds: int | None = None,
|
||||
now: Callable[[], datetime] = _utcnow,
|
||||
):
|
||||
self.session_factory = session_factory
|
||||
@@ -119,11 +155,17 @@ class TakeoverService:
|
||||
def _enabled_avatar_ids(self) -> list[str]:
|
||||
db = self.session_factory()
|
||||
try:
|
||||
return [
|
||||
avatar.id
|
||||
for avatar in db.query(Avatar).filter(Avatar.status == "active").all()
|
||||
if _takeover_enabled(avatar)
|
||||
]
|
||||
avatars = (
|
||||
db.query(Avatar)
|
||||
.filter(Avatar.status == "active")
|
||||
.order_by(Avatar.updated_at.desc(), Avatar.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
selected = {}
|
||||
for avatar in avatars:
|
||||
if _takeover_enabled(avatar) and avatar.owner_id not in selected:
|
||||
selected[avatar.owner_id] = avatar.id
|
||||
return list(selected.values())
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -369,13 +411,21 @@ class TakeoverService:
|
||||
|
||||
now = self.now()
|
||||
send_time = _boxim_time(message.get("sendTime"), now)
|
||||
is_avatar = False
|
||||
if direction == "outgoing" and local_id:
|
||||
is_avatar = _is_avatar_local_id(local_id)
|
||||
if not is_avatar and local_id:
|
||||
is_avatar = bool(
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(
|
||||
TakeoverReplyTask.owner_id == avatar.owner_id,
|
||||
TakeoverReplyTask.boxim_local_id == local_id,
|
||||
TakeoverReplyTask.status.in_(("ready", "sending", "sent")),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not is_avatar:
|
||||
is_avatar = bool(
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(
|
||||
TakeoverReplyTask.boxim_sent_message_id == message_id,
|
||||
TakeoverReplyTask.status == "sent",
|
||||
)
|
||||
.first()
|
||||
@@ -404,8 +454,52 @@ class TakeoverService:
|
||||
return
|
||||
if (now - send_time).total_seconds() > MAX_STALE_SECONDS:
|
||||
return
|
||||
if is_avatar:
|
||||
self._cancel_conversation(db, avatar.owner_id, peer_id, "peer_avatar_message")
|
||||
return
|
||||
if self._human_pause_active(db, avatar.owner_id, peer_id, now):
|
||||
self._cancel_conversation(db, avatar.owner_id, peer_id, "owner_active")
|
||||
return
|
||||
if self._conversation_rate_limited(db, avatar.owner_id, peer_id, now):
|
||||
self._cancel_conversation(db, avatar.owner_id, peer_id, "rate_limited")
|
||||
return
|
||||
self._schedule_reply(db, avatar, event)
|
||||
|
||||
@staticmethod
|
||||
def _human_pause_active(db: Session, owner_id: str, peer_id: str, now: datetime) -> bool:
|
||||
threshold = now - timedelta(seconds=HUMAN_PAUSE_SECONDS)
|
||||
return bool(
|
||||
db.query(TakeoverMessage.id)
|
||||
.filter(
|
||||
TakeoverMessage.owner_id == owner_id,
|
||||
TakeoverMessage.peer_id == peer_id,
|
||||
TakeoverMessage.direction == "outgoing",
|
||||
TakeoverMessage.is_avatar.is_(False),
|
||||
TakeoverMessage.send_time >= threshold,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _conversation_rate_limited(
|
||||
db: Session,
|
||||
owner_id: str,
|
||||
peer_id: str,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
threshold = now - timedelta(seconds=RATE_LIMIT_WINDOW_SECONDS)
|
||||
return (
|
||||
db.query(TakeoverReplyTask.id)
|
||||
.filter(
|
||||
TakeoverReplyTask.owner_id == owner_id,
|
||||
TakeoverReplyTask.peer_id == peer_id,
|
||||
TakeoverReplyTask.status == "sent",
|
||||
TakeoverReplyTask.sent_at >= threshold,
|
||||
)
|
||||
.count()
|
||||
>= RATE_LIMIT_MAX_REPLIES
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str):
|
||||
tasks = (
|
||||
@@ -446,9 +540,11 @@ class TakeoverService:
|
||||
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)
|
||||
due_at = event.send_time + timedelta(
|
||||
seconds=_configured_reply_delay(avatar, self.reply_delay_seconds)
|
||||
)
|
||||
task_id = secrets.token_hex(16)
|
||||
local_id = int(time.time() * 1000) * 1000 + secrets.randbelow(1000)
|
||||
local_id = _avatar_local_id(avatar.owner_id, event.boxim_message_id)
|
||||
db.add(
|
||||
TakeoverReplyTask(
|
||||
id=task_id,
|
||||
@@ -474,6 +570,7 @@ class TakeoverService:
|
||||
.filter(
|
||||
TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES),
|
||||
TakeoverReplyTask.response_text == "",
|
||||
TakeoverReplyTask.scheduled_at <= self.now(),
|
||||
)
|
||||
.order_by(TakeoverReplyTask.created_at.asc())
|
||||
.limit(10)
|
||||
@@ -520,6 +617,7 @@ class TakeoverService:
|
||||
.filter(
|
||||
TakeoverMessage.owner_id == task.owner_id,
|
||||
TakeoverMessage.peer_id == task.peer_id,
|
||||
TakeoverMessage.avatar_id == task.avatar_id,
|
||||
)
|
||||
.order_by(TakeoverMessage.send_time.desc())
|
||||
.limit(30)
|
||||
@@ -529,6 +627,8 @@ class TakeoverService:
|
||||
for event in reversed(events):
|
||||
if event.boxim_message_id in excluded_ids or not event.content.strip():
|
||||
continue
|
||||
if event.direction == "incoming" and event.is_avatar:
|
||||
continue
|
||||
history.append(
|
||||
{
|
||||
"role": "user" if event.direction == "incoming" else "assistant",
|
||||
@@ -606,6 +706,15 @@ class TakeoverService:
|
||||
task.cancel_reason = "stale_reply"
|
||||
db.commit()
|
||||
return False
|
||||
cursor = (
|
||||
db.query(TakeoverCursor)
|
||||
.filter(TakeoverCursor.avatar_id == task.avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not cursor or not cursor.last_polled_at or cursor.last_polled_at < task.scheduled_at:
|
||||
# Do not race the owner's final seconds of the grace period. A
|
||||
# completed poll at/after the due time must confirm no human reply.
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user