Merge pull request 'fix(avatar): 部署重启后自动恢复 BOXIM 接管' (#6) from codex/avatar-takeover-restart-safe-20260826 into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-08-26 14:53:49 +08:00
2 changed files with 61 additions and 6 deletions
@@ -201,13 +201,20 @@ class TakeoverService:
def _forget_boxim_session(self, user_id: str): def _forget_boxim_session(self, user_id: str):
self._sessions.pop(user_id, None) self._sessions.pop(user_id, None)
def _disable_after_connection_failure( def _record_connection_failure(
self, self,
db: Session, db: Session,
avatar: Avatar, avatar: Avatar,
cursor: TakeoverCursor, cursor: TakeoverCursor,
message: str, message: str,
*,
disable_takeover: bool,
): ):
cursor.last_error = message
cursor.last_polled_at = self.now()
if not disable_takeover:
return
permissions = (avatar.config or {}).get("authorizationPermissions", []) permissions = (avatar.config or {}).get("authorizationPermissions", [])
avatar.config = { avatar.config = {
**(avatar.config or {}), **(avatar.config or {}),
@@ -217,8 +224,6 @@ class TakeoverService:
if permission != TAKEOVER_PERMISSION if permission != TAKEOVER_PERMISSION
], ],
} }
cursor.last_error = message
cursor.last_polled_at = self.now()
tasks = ( tasks = (
db.query(TakeoverReplyTask) db.query(TakeoverReplyTask)
.filter( .filter(
@@ -245,11 +250,12 @@ class TakeoverService:
db.add(cursor) db.add(cursor)
db.flush() db.flush()
if not user or not user.huihui_token: if not user or not user.huihui_token:
self._disable_after_connection_failure( self._record_connection_failure(
db, db,
avatar, avatar,
cursor, cursor,
"请重新登录会会生产账号后再开启主动接管", "请重新登录会会生产账号后再开启主动接管",
disable_takeover=True,
) )
db.commit() db.commit()
return False return False
@@ -268,11 +274,24 @@ class TakeoverService:
if isinstance(exc, BoxIMError) and exc.auth_error: if isinstance(exc, BoxIMError) and exc.auth_error:
self._forget_boxim_session(user.id) self._forget_boxim_session(user.id)
message = "BOXIM 授权已失效,请重新登录会会生产账号" message = "BOXIM 授权已失效,请重新登录会会生产账号"
disable_takeover = True
else: else:
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}" message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
self._disable_after_connection_failure(db, avatar, cursor, message) disable_takeover = False
self._record_connection_failure(
db,
avatar,
cursor,
message,
disable_takeover=disable_takeover,
)
db.commit() db.commit()
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc) logger.warning(
"BOXIM sync failed for avatar %s (will_retry=%s): %s",
avatar.id,
not disable_takeover,
exc,
)
return False return False
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0)) messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
@@ -291,5 +291,41 @@ async def test_connection_failure_disables_takeover_and_stops_retrying(service_c
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token") boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
@pytest.mark.asyncio
async def test_transient_connection_failure_keeps_takeover_and_recovers(service_context):
session_factory, service, boxim, _ = service_context
boxim.exchange_access_token = AsyncMock(
side_effect=[
BoxIMError("连接超时"),
{"accessToken": "box-token", "accessTokenExpiresIn": 3600},
]
)
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is False
assert "暂时连接失败" in cursor.last_error
finally:
db.close()
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is True
assert cursor.last_error == ""
finally:
db.close()
assert boxim.exchange_access_token.await_count == 2
def test_plain_text_reply_removes_markdown_and_empty_lines(): def test_plain_text_reply_removes_markdown_and_empty_lines():
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医" assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"