Skip to content

[Bug]: Adapter 层缺陷汇总:NapCat 接收循环静默退出 / Bilibili 缺少 stop() / 评论游标漏处理 #249

Description

@LyaQanYi

概述

对 v2.30.0(1228ac2)做代码审查时,在 core/adapter/ 发现 7 处缺陷。共同特征是适配器会以用户不可见的方式"脑死亡"——进程还在、注册表状态正常,但收发消息已经停了。对 7×24 运行的场景影响较大。


1. NapCat 接收循环遇到非 ConnectionClosed 异常直接退出,QQ 永久静默失联(High)

位置:core/adapter/src/qq/napcat_client/client.py:173-191

while not self.shutdown_event.is_set():
    try:
        async for message in self.websocket:
            try:
                data = json.loads(message)
                await self.handle_message(data)
            except json.JSONDecodeError:
                logger.error(f"❌ 无法解析消息: {message}")
    except websockets.exceptions.ConnectionClosed:
        ...
    except Exception as e:
        logger.error(f"❌ 监听错误: {e}")
        break

实际行为:只有 ConnectionClosed 走重连分支,任何其他异常都会 break 彻底结束接收循环,且不重连、不通知上层。内层 try 只捕获 JSONDecodeError,handle_message(data) 抛出的异常(例如服务端下发的 JSON 是数组而非 dict 时,data.get("echo") 触发 AttributeError)会直接命中这个 break。

之后所有 send_action 只会超时,收消息完全停止,而适配器对象看起来仍在运行,只能靠翻日志发现。

期望行为:非 ConnectionClosed 异常也应记录后重连(或至少上报状态),而非静默退出循环。


2. BiliBiliAdapter 未实现 stop(),停止/更新/删除全部失败并中断关闭流程(High)

位置:core/adapter/src/bilibili/bilibili.py:26(类中无 stop 方法)、core/adapter/adapter_registry.py:467-469、core/lifecycle.py:283-284

adapter = self._adapters.get(name)
if adapter:
    await adapter.stop()

实际行为:SocialMediaAdapter 基类未定义 stop(core/adapter/adapter_utils.py:98-121),BiliBiliAdapter 也没实现,因此对启用的 Bilibili 适配器调用 stop_adapter() 必然抛 AttributeError。影响链:

  • 进程关停:lifecycle.py:284 的 stop_adapters() 没有 per-adapter 的 try/except,AttributeError 会中断循环,后续其他适配器不会被停止,event_bus.stop()、数据库 dispose 等清理全部被跳过。
  • WebUI 更新配置:update_adapter(adapter_registry.py:404-412)中 stop_adapter 抛异常后整个 reload 被 except 吞掉,旧实例仍留在 _adapters 里,_start_listening 的轮询任务永不取消 —— 表现为改了配置不生效或新旧行为混杂。
  • 删除适配器:delete_adapter 虽捕获了异常,但没有任何机制取消 self.listening_task,删除后该任务继续每 20 秒拉评论并向事件总线发布事件。

期望行为:实现 BiliBiliAdapter.stop() 取消 listening_task;同时建议 stop_adapters() 对每个适配器单独 try/except,避免一个适配器拖垮整个关闭流程。


3. Bilibili 评论游标共用,会永久丢弃用户评论(High)

位置:core/adapter/src/bilibili/bilibili.py:196、:208、:217、:231

主评论与楼中楼共用同一个 self.last_process_ts 游标,但两者时间戳是交错的:楼层列表按主评论 ctime 升序遍历,而 bot 某条旧主评论下的子回复可能比后面的新主评论更晚发出。

复现(纯数据即可):bot 在 t=100 发过评论,用户 A 在 t=110 发主评论,用户 B 在 t=115 回复 bot 的楼。遍历顺序 bot(100) → A(110):处理 bot 楼时先把子回复(115)处理掉并把 last_process_ts 推进到 115,随后轮到 A(110) 时 110 > 115 不成立 —— A 的评论被永久跳过,无任何日志。反向场景同理。

另外 :196/:217 用严格大于比较,同秒内的多条评论也可能漏掉一条。

期望行为:主评论与子回复各自维护游标,或改用已处理评论 ID 集合去重。


4. Telegram 使用 filters.ALL:频道帖子崩溃、编辑消息被重复回复(Medium)

位置:core/adapter/src/telegram/telegram.py:99-100

# Register message handler for text, images, voice, etc. (excluding edited messages)
self.app.add_handler(MessageHandler(filters.ALL, self._on_message))

实际行为:注释声称排除编辑消息,但在 PTB v21(requirements.txt 锁定 python-telegram-bot~=21.3)中 filters.ALL 会同时接收 message、edited_message、channel_post、edited_channel_post。两个后果:

  • 频道帖子崩溃:channel post 的 msg.from_user 为 None,chat.type == "channel" 落入私聊分支后 :226 的 str(user.id) 抛 AttributeError。bot 被加进任何频道后每条帖子都报错并丢弃。
  • 编辑消息重复回复:用户编辑一条消息会作为全新消息再次发布到事件总线,bot 对同一内容回复两次。

期望行为:改用 filters.UpdateType.MESSAGES 之类的过滤器,使实际行为与注释一致。


5. QQ 出站消息链只对首个元素做类型分派,混合链中的附件被静默丢弃(Medium)

位置:core/adapter/src/qq/qq.py:149-153、core/adapter/src/qq/napcat_client/utils.py:91-178

ele = message_chain[0]
if isinstance(ele, Poke):
    await self.bot.send_poke(user_id=ele.pid, group_id=group_id)

实际行为:_process_outgoing_message 会把 Poke/File/Video/Forward 原样放进列表(qq.py:825-832),但 send_group_message/send_direct_message 只检查 message_chain[0]。若链形如 [Text("给你发个文件"), File(...)],会走通用路径进入 QQMessageChain.to_list() —— 该方法的 if/elif 没有兜底分支,非 QQMessageType.* 的元素被无声跳过:文本发出去了,文件消失,无日志也无错误返回。LLM 生成"文字 + 附件"回复时必然触发。

期望行为:遍历整条消息链分派,或对无法序列化的元素记录警告并在返回结果中标记失败。


6. weixin_oc 长轮询错误路径无退避,形成紧循环(Medium)

位置:core/adapter/src/weixin_oc/weixin_oc.py:466-497、:824-837

_poll_inbound_updates 在 ret != 0 或 errcode != 0(非 -14)时正常 return,而 _run_loop 只在抛异常时才 sleep(5)。服务器持续快速返回业务错误(如账号被风控)时,循环会以 HTTP 往返速度全速空转刷请求,既刷爆日志又可能加重封禁。

另外 :468 的 ret is not None 是死代码(ret 来自 int(... or 0),永远不为 None)。

期望行为:错误返回路径同样施加退避 sleep。


7. 权限名单不做类型归一化,deny_list 模式下可能失效放行(Medium)

位置:core/adapter/adapter_utils.py:44-55

if group_allow_list and isinstance(group_allow_list, list):
    self.group_list = group_allow_list

实际行为:配置里的列表元素原样使用,而所有适配器都用 str(chat.id) in self.group_list 比较。若用户手工编辑 JSON 把群号写成整数 [123456],"123456" in [123456] 为 False:

  • allow_list 模式表现为"配置了却收不到消息"(用户能发现)
  • deny_list 模式则表现为拉黑不生效、被拒绝的群继续被处理(安全侧 fail-open,且无任何告警)

期望行为:初始化时归一化为字符串,[str(x) for x in ...]。


补充:其他观察

  • 后台任务普遍不保存引用且 done callback 从不调用 t.exception():adapter_registry.py:460-461、qq.py:134-135、weixin_oc.py:775-776。适配器 start_blocking() 抛异常时只会在 GC 时出现 "Task exception was never retrieved"。顺带一个日志问题:adapter_registry.py:461 的 callback 在任务结束时打印 "Started adapter",对 Bilibili 这类阻塞到死的适配器等于在它死亡时打印"已启动"。
  • NapCat 重连有 20 次硬上限(client.py:125-138),之后 close() 置位 shutdown_event 永不再连,一次超过 ~15 分钟的 NapCat 维护就意味着永久掉线需人工重启。
  • bilibili.py:126-128、:138 使用 print 而非 logger,send_comment 失败被吞掉。
  • bilibili.py:43 的 self.config.get("listening_interval") 若旧配置缺键返回 None,asyncio.sleep(None) 抛 TypeError 杀死监听任务。
  • 测试覆盖:tests/test_adapter_manager.py 仅 2 个用例(测适配器名不含冒号)。NapCat 重连状态机、QQMessageChain.to_list()、Telegram UTF-16 entity 切片、权限名单判定、Bilibili 评论游标均无测试,而其中多数是纯函数或可注入 fake client 的逻辑。

KiraAI version: v2.30.0(1228ac2)
Environment: 静态代码审查,非特定 OS/Python 版本相关

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions