fix(avatar): prevent takeover loops and isolate settings

This commit is contained in:
stefanfeng
2026-08-27 11:30:12 +08:00
parent ef58c5f2d2
commit 46d42b7d98
14 changed files with 848 additions and 62 deletions
@@ -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)