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
@@ -11,7 +11,12 @@ from sqlalchemy.orm import sessionmaker
from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from services.boxim_client import BoxIMError
from services.takeover_service import TakeoverService, _plain_text_reply
from services.takeover_service import (
AVATAR_LOCAL_ID_PREFIX,
TakeoverService,
_avatar_local_id,
_plain_text_reply,
)
class Clock:
@@ -77,7 +82,10 @@ def service_context(tmp_path):
owner_id=user.huihui_user_id,
name="分身",
status="active",
config={"authorizationPermissions": ["chat", "takeover"]},
config={
"authorizationPermissions": ["chat", "takeover"],
"takeoverReplyDelaySeconds": 3,
},
)
db.add_all([user, avatar])
db.commit()
@@ -120,8 +128,7 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
{"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"}
)
with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}):
await service.poll_and_process_messages()
await service.poll_and_process_messages()
assert boxim.sent == []
assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}]
@@ -130,7 +137,8 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
assert boxim.sent == []
clock.advance(1)
await service.poll_and_process_messages()
with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}):
await service.poll_and_process_messages()
assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
db = session_factory()
@@ -142,6 +150,131 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
db.close()
@pytest.mark.asyncio
async def test_default_reply_delay_is_three_minutes(service_context):
session_factory, service, boxim, clock = service_context
db = session_factory()
try:
avatar = db.query(Avatar).one()
avatar.config = {"authorizationPermissions": ["chat", "takeover"]}
db.commit()
finally:
db.close()
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 12, "localId": 12, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "三分钟后回复"}
)
await service.poll_and_process_messages()
db = session_factory()
try:
task = db.query(TakeoverReplyTask).one()
assert task.scheduled_at == clock.now() + timedelta(seconds=180)
finally:
db.close()
clock.advance(179)
await service.process_reply_tasks()
assert boxim.sent == []
clock.advance(1)
with patch("routers.chat._resolve_reply", return_value={"answer": "好的"}):
await service.poll_and_process_messages()
assert [item["content"] for item in boxim.sent] == ["好的"]
@pytest.mark.asyncio
async def test_avatar_origin_message_never_schedules_a_reply(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
local_id = _avatar_local_id("peer-owner", "peer-trigger")
boxim.messages.append(
{"id": 15, "localId": local_id, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "另一端分身回复"}
)
with patch("routers.chat._resolve_reply") as resolver:
await service.poll_and_process_messages()
resolver.assert_not_called()
db = session_factory()
try:
event = db.query(TakeoverMessage).filter(TakeoverMessage.boxim_message_id == "15").one()
assert event.is_avatar is True
assert db.query(TakeoverReplyTask).count() == 0
finally:
db.close()
@pytest.mark.asyncio
async def test_peer_avatar_messages_are_excluded_from_later_human_context(service_context):
_session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{
"id": 16,
"localId": _avatar_local_id("peer-owner", "peer-trigger"),
"sendId": 200,
"recvId": 100,
"sendTime": clock.millis(),
"type": 0,
"content": "分身生成的夸张长文",
}
)
await service.poll_and_process_messages()
clock.advance(1)
boxim.messages.append(
{
"id": 17,
"localId": 17,
"sendId": 200,
"recvId": 100,
"sendTime": clock.millis(),
"type": 0,
"content": "真人的新问题",
}
)
await service.poll_and_process_messages()
clock.advance(3)
with patch("routers.chat._resolve_reply", return_value={"answer": "正常回复"}) as resolver:
await service.poll_and_process_messages()
assert resolver.call_args.args[3] == []
@pytest.mark.asyncio
async def test_owner_message_pauses_future_takeover_for_ten_minutes(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 18, "localId": 18, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我先来回复"}
)
await service.poll_and_process_messages()
clock.advance(30)
boxim.messages.append(
{"id": 19, "localId": 19, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "收到"}
)
with patch("routers.chat._resolve_reply") as resolver:
await service.poll_and_process_messages()
resolver.assert_not_called()
db = session_factory()
try:
assert db.query(TakeoverReplyTask).count() == 0
finally:
db.close()
def test_avatar_local_id_is_deterministic_and_self_describing():
first = _avatar_local_id("owner", "message-1")
assert first == _avatar_local_id("owner", "message-1")
assert first != _avatar_local_id("owner", "message-2")
assert first.startswith(AVATAR_LOCAL_ID_PREFIX)
assert len(first) == 18
assert first.isdigit()
@pytest.mark.asyncio
async def test_different_contacts_generate_without_blocking_each_other(service_context):
session_factory, service, boxim, clock = service_context
@@ -158,11 +291,11 @@ async def test_different_contacts_generate_without_blocking_each_other(service_c
both_generating.wait()
return {"answer": f"回复{prompt[-1]}"}
with patch("routers.chat._resolve_reply", side_effect=resolve):
await service.poll_and_process_messages()
await service.poll_and_process_messages()
clock.advance(3)
await service.process_reply_tasks()
with patch("routers.chat._resolve_reply", side_effect=resolve):
await service.poll_and_process_messages()
assert {(item["peerId"], item["content"]) for item in boxim.sent} == {
("200", "回复甲"),
("300", "回复乙"),
@@ -237,6 +370,34 @@ async def test_owner_message_cancels_pending_reply(service_context):
db.close()
@pytest.mark.asyncio
async def test_owner_message_in_final_second_wins_before_generation(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 23, "localId": 23, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "还在吗"}
)
await service.poll_and_process_messages()
clock.advance(2)
boxim.messages.append(
{"id": 24, "localId": 24, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我来处理"}
)
clock.advance(1)
with patch("routers.chat._resolve_reply") as resolver:
await service.poll_and_process_messages()
resolver.assert_not_called()
assert boxim.sent == []
db = session_factory()
try:
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.trigger_message_id == "23").one()
assert task.status == "cancelled"
assert task.cancel_reason == "owner_replied"
finally:
db.close()
@pytest.mark.asyncio
async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context):
session_factory, service, boxim, clock = service_context
@@ -244,19 +405,18 @@ async def test_quick_successive_messages_are_coalesced_into_one_reply(service_co
boxim.messages.append(
{"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"}
)
with patch("routers.chat._resolve_reply", return_value={"answer": "第一版"}):
await service.poll_and_process_messages()
await service.poll_and_process_messages()
clock.advance(1)
boxim.messages.append(
{"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"}
)
await service.poll_and_process_messages()
clock.advance(3)
with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver:
await service.poll_and_process_messages()
assert resolver.call_args.args[2] == "第一句\n第二句"
clock.advance(3)
await service.poll_and_process_messages()
assert [item["content"] for item in boxim.sent] == ["合并回复"]
db = session_factory()