fix(avatar): prevent takeover loops and isolate settings
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user