1. 业务场景与目标 (Scenario & Objective)

在开发即时通讯机器人插件(如将 New API 核心模型实时计费与高颜值看板渲染为海报回复 QQ 群或私聊)时,需要构建跨端(Windows 本机 + Linux 远端服务器)、跨平台(OneBot/aiocqhttp)且具备智能缓存的高性能 AstrBot 插件。


2. 插件架构与指令设计 (Plugin Architecture & Commands)

2.1 目录结构标准

astrbot_plugin_newapi_price_checker/
├── metadata.yaml           # 插件核心元数据(作者、版本、支持平台)
├── _conf_schema.json       # WebUI 可视化配置 Schema
├── requirements.txt        # 运行时第三方依赖
├── README.md               # 插件说明文档
├── main.py                 # 核心事件拦截、抓取与渲染逻辑
├── assets/                 # 静态内置资源(头像、表情包贴纸、图标)
├── cache/                  # 持久化价格数据与海报母本缓存
└── rendered/               # 每次回复派发的临时图片副本

2.2 多指令与纯文本同时支持

为满足 /价格、/中转站、/中转站价格 与纯文本 价格、中转站 均可触发且互不冲突,需双轨拦截:

@filter.command("中转站价格", alias={"中转站", "价格"})
async def price_command(self, event: AstrMessageEvent):
    async for res in self._handle_price_request(event):
        yield res

@filter.regex(r"^(中转站价格|中转站|价格)(\s+.*)?$")
async def price_regex(self, event: AstrMessageEvent):
    msg_str = (event.message_str or "").strip()
    # 过滤带指令前缀的消息,防止同时触发 command 和 regex 重复发送两次
    if msg_str.startswith(("/", "!", "!", "#")):
        return
    async for res in self._handle_price_request(event):
        yield res

3. 核心踩坑与根因排查 (Core Pitfalls & Troubleshooting)

坑 1:metadata.yaml 纯数字 author 触发 AttributeError

  • 现象:AstrBot 启动日志报错:

    AttributeError: 'int' object has no attribute 'lower'
    File ".../star/star_manager.py", line 1017, in load
      p_author = (metadata.author or "unknown").lower().replace("/", "_")
  • 根因:PyYAML 在解析未带引号的纯数字(如 author: 18086)时会将其解析为 Python int 类型,而 AstrBot 源码直接假设其为字符串并执行 .lower()。
  • 解决方案:在 metadata.yaml 中对包含数字的作者名强制加双引号:

    author: "18086"

坑 2:_conf_schema.json 不兼容标准 JSON Schema 类型

  • 现象:插件加载阶段抛出异常并导致插件直接被丢弃:

    TypeError: 不受支持的配置类型 integer。支持的类型有:dict_keys(['int', 'float', 'bool', 'string', 'text', 'list', 'file', 'object', 'template_list'])
  • 根因:AstrBot 自研了简化版的配置类型解析器,不支持标准 JSON Schema 的 integer、number、array。
  • 类型映射对照表:

    常见 JSON Schema 类型AstrBot 必须使用的类型
    integerint
    numberfloat
    arraylist
    string (多行)text

坑 3:Linux 无头环境 Playwright 渲染与国内源加速

  • 现象 1:官方源下载 Chrome for Testing 连接被重置断开。
  • 解决方案 1:使用国内高速镜像源:

    export PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright
    python -m playwright install chromium
  • 现象 2:Linux 云服务器(如 Ubuntu)缺少沙盒环境导致启动崩溃。
  • 解决方案 2:Python 启动 Chromium 必须携带关键无头参数:

    launch_args = [
        "--no-sandbox",
        "--disable-setuid-sandbox",
        "--disable-dev-shm-usage",
        "--disable-gpu"
    ]
    browser = await pw.chromium.launch(headless=True, args=launch_args)

坑 4:AstrBot 临时文件生命周期与持久缓存冲突

  • 现象:如果将母本缓存图片路径直接传入 event.track_temporary_local_file(str(cache_path)),AstrBot 在群聊发送完图片后会触发自动清理逻辑将母本文件删除,导致下一次直接读取缓存时报 FileNotFoundError。
  • 解决方案:母本文件存储在 cache/latest_poster.png;每次请求命中或生成后,shutil.copyfile 拷贝到 rendered/poster_{uuid}.png,只对副本执行 event.track_temporary_local_file,既避免磁盘垃圾堆积,又保证母本缓存安全。

坑 5:AstrBot 多配置文件映射与 plugin_set 插件白名单静默丢弃 (Config Whitelist Silent Drop)

  • 现象:插件放置在 data/plugins/ 目录且启动日志明确输出 Plugin astrbot_plugin_xxx loaded,但向群聊或私聊发送无论 /指令 还是纯文本触发词,机器人均完全无响应,无任何报错或日志输出。
  • 根因:

    1. AstrBot 根目录下的 cmd_config.json 仅作为启动引导配置,实际生效的运行态配置由 data/data_v4.db 的 preferences 表中 abconf_mapping 字段指向具体的 data/config/abconf_<uuid>.json。
    2. 在该活跃配置文件中,plugin_set 字段若显式指定了插件列表(如 ["astrbot_file_bridge", "astrbot_image_generation"]),AstrBot 在事件处理管道的 WakingCheckStage 阶段执行:

      handlers_for_loop = star_handlers_registry.get_handlers_by_event_type(
          EventType.AdapterMessageEvent,
          plugins_name=event.plugins_name, # 取自 plugin_set
      )
    3. 新加入的插件不在白名单内,其已注册的 Handlers 直接被跳过,导致静默无响应。
  • 解决方案:
    在活跃的 data/config/abconf_<uuid>.json 中将新插件包名追加至 plugin_set 数组,或将其设为通配符 ["*"]:

    "plugin_set": [
      "astrbot_file_bridge",
      "astrbot_image_generation",
      "astrbot_plugin_newapi_price_checker",
      "astrbot_plugin_claude_fleet"
    ]

坑 6:AstrBot 配置文件 UTF-8 BOM 导致脚本读取解析失败 (UTF-8 BOM JSONDecodeError)

  • 现象:编写 Python 脚本解析修改 data/config/abconf_*.json 时,json.loads(f.read()) 报错:

    json.decoder.JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)
  • 根因:AstrBot 在某些历史版本或 Windows 编辑同步路径下,将配置文件保存为了带 BOM 的 UTF-8 格式(字节头包含 \xef\xbb\xbf),标准 encoding="utf-8" 解析时无法跳过该前缀。
  • 解决方案:文件读写统一指定 encoding="utf-8-sig",写入时注意保持原编码或规范为标准无 BOM UTF-8。

坑 7:CommandFilter 的 alias 参数类型陷阱 (Set vs List)

  • 现象:使用 @filter.command("指令", aliases=["别名1", "别名2"]) 时,指令别名不生效或报初始化参数错误。
  • 根因:AstrBot 的 CommandFilter 源码构造函数签名中形参名为 alias(单数)且期望类型为 set | None:

    class CommandFilter(EventFilter):
        def __init__(self, command: str, alias: set | None = None, ...):
  • 解决方案:参数名必须为 alias,且必须传入集合(Set),如 alias={"中转站", "价格"}。

坑 8:插件内置 Node 子服务与内网既有 Agent 服务冲突 (Bundled Sub-server vs Intranet Service)

  • 现象:astrbot_plugin_claude_fleet 报错:

    API Error: 500 no active provider configured. This is a server-side issue, usually temporary — try again in a moment. If it persists, check your inference gateway (127.0.0.1:32180)
  • 根因:

    1. 插件默认配置 auto_start_server: true,在 Python 进程内通过 subprocess.Popen 拉起内部打包的 Node.js 服务并监听 127.0.0.1:32180。
    2. 其启动参数通过相对路径引用 PROVIDERS_FILE=data/plugin_data/.../providers.json,而子进程的工作目录是 .../bundled_server,导致文件读取抛出 ENOENT,服务回退为 activeProviderId: null。
    3. 实际宿主机内网已经以 systemd 服务常驻运行了完全同构的 handouts-agent.service(端口 127.0.0.1:3180),造成冗余启动与内网多端口混乱。
  • 解决方案:

    1. 修改 data/config/astrbot_plugin_claude_fleet_config.json,设置 "auto_start_server": false,"server_url": "http://127.0.0.1:3180",填写预设的 Bearer Token。
    2. 杀灭 32180 端口残留僵尸进程并释放端口。
    3. 确保 3180 服务提供兼容的产物下载路由(/tasks/:id/artifact 与 /files/:id/output.pdf),并与 AstrBot 的白名单域名校验机制(URL scheme/host/port 匹配)保持一致。

坑 9:Claude Code 自动化沙箱权限与 LaTeX 宏判断缺陷 (Headless Claude Code & TeX Long Def)

  • 现象 1:Node 服务拉起无头 claude 命令后,Agent 拒绝拷贝模版文件或执行校验命令。

    • 根因:agent.js 中 permissionMode 原为 'dontAsk',在 headless 非交互环境下遇到沙箱外部文件引用或关键指令时会直接拒执。
    • 解决方案:在专用任务临时工作区沙箱中,将 permissionMode 配置为 'bypassPermissions'。
  • 现象 2:编译阶段 XeLaTeX 报 ! LaTeX Error: File '' not found.。

    • 根因:类文件 readex-vivid.cls 中使用了 \ifx\readexcoverimage\empty\else。因为 \readexcoverimage 是用 \newcommand(隐含 \long 属性)定义的,而 TeX 的 \empty(\def\empty{})是非 \long 宏,\ifx 在比较两者的扩展属性时判定为不相等,即使字符串为空也跳入了有图分支并执行 \includegraphics{}。
    • 解决方案:替换为稳健的空检测语法:\if\relax\detokenize\expandafter{\readexcoverimage}\relax\else。

坑 10:次世代顶级旗舰模型动态置顶与卡片视觉层级设计 (Hero Model Layout & Dynamic Injection)

  • 现象:类似 GPT-6 Astra 这类突破性 AGI 级旗舰模型上线后,若直接塞入现有的对称 2x2 网格,会削弱其作为核心王牌模型的视觉焦点与尊贵感;若手动硬编码,又会导致多端数据源与 New API 后台实时价格割裂。
  • 解决方案:

    1. 双层数据聚合架构:在插件元数据中拆分为 HERO_MODEL_META(主王牌模型)与 FEATURED_MODELS_META(次级四宫格核心模型),在 API/Playwright 提取列表时联合检索 [HERO_MODEL_META["id"]] + [m["id"] for m in FEATURED_MODELS_META],保证动态抓取 New API 最新计费率(输入 ¥0.5 / 输出 ¥2.5 / 缓存 ¥0.05)。
    2. 居中顶部 Hero Card 视觉强化:在 2x2 网格上方开辟独占式横幅卡片,应用双层径向柔光(radial-gradient)、香槟粉渐变金徽章(✦ 次世代 AGI 旗舰 · FRONTIER SOTA ✦)、翠绿脉冲活跃指示灯(LIVE ASTRA CORE)及 1.05M 上下文胶囊标,形成极具层次感的顶级视觉冲击力。

坑 11:爬虫类插件页面语言不匹配导致解析失效与 Fallback 倍率硬编码 (DOM Selector Language Mismatch & Fallback Multiplier Bug)

  • 现象:用户反馈“中转站价格不对,是不是缓存了,发什么才能拿到新价格”。即使发送了强制刷新指令,新生成的海报上各模型价格依然全部只有真实价格的 1/3(如 GPT-6 Astra 显示输入 ¥0.5 / 输出 ¥2.5 / 缓存 ¥0.05,而 New API 前台实际为 ¥1.5 / ¥7.5 / ¥0.15)。
  • 根因:

    1. 页面渲染语言与选择器不匹配:Playwright 抓取 React SPA 价格页时,原选择器通过 x.includes('价格') 和 text.includes('输入价格') 过滤卡片文本。但目标站点默认按英文渲染(Input Price ¥1.5000 / 1M Tokens、Completion Price ¥7.5000 / 1M Tokens),导致未能提取到数字,卡片价格数组为空。
    2. 静默回退到错误的硬编码倍率:由于未抓到可见价格,代码静默掉入 fallback 分支。fallback 逻辑中使用了 unit_factor = 0.1 硬编码换算(model_ratio * 0.1)。但该站点在以 CNY / 1M Tokens 为结算基准时,实际系数为 0.3(1M tokens / 500,000 quota = 2 USD 额度,折算后系数正好为 0.3),导致算出来的输入/输出价格恒为真实价格的 1/3。
    3. 本地文件缓存混淆故障定性:插件设计了 10 分钟本地图片缓存(cache_ttl: 600,latest_poster.png),普通查询直接秒回旧图,极易被误判为单纯的“缓存未更新”;而实际上即便缓存过期或强刷,重跑代码依然会落入 fallback 算错。
  • 解决方案:

    1. 重构双语卡片向上遍历选择器:

      els => els.map(h => {
        let card = h.closest(".semi-card") || h.closest("[class*='card']") || h.parentElement;
        while (card && card.parentElement && !card.innerText.includes("Price") && !card.innerText.includes("价格")) {
          card = card.parentElement;
        }
        return {
          model: h.innerText.trim(),
          text: card ? card.innerText : ""
        };
      })
    2. 稳健正则提取与正确系数纠偏:
      提取正则改为 re.findall(r"¥\s*([0-9]+(?:\.[0-9]+)?)", text),并同时将 API 与回退逻辑中的 unit_factor 修正为正确的 0.3。

      1. 强刷指令支持:支持 any(kw in msg for kw in ("刷新", "refresh", "force", "重载")),发送 中转站 刷新 或 /价格 刷新 即可绕过缓存立即执行全新抓取渲染。

坑 12:AstrBot 内置 MCP 客户端在 Python mcp 2.x 下 Streamable HTTP 兼容异常 (AstrBot MCP Client Python mcp 2.x SDK Incompatibilities)

  • 现象:

    1. 启动日志警告 Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.,导致无法初始化 Streamable HTTP 协议的 MCP 外部服务。
    2. 若配置或测试连接,抛出 NameError: name 'streamablehttp_client' is not defined 或 AttributeError: 'Tool' object has no attribute 'inputSchema'。
    3. 执行工具调用时,底层报错 TypeError: unsupported operand type(s) for +: 'float' and 'datetime.timedelta'。
  • 根因:

    1. 符号名不匹配:mcp 2.x SDK 中函数名为 streamable_http_client(带下划线),AstrBot 源码中直接 from mcp.client.streamable_http import streamablehttp_client,导致导入失败且被静默忽略。
    2. 传参结构与解包数量不匹配:streamable_http_client 在 2.x 中要求通过 http_client=httpx2.AsyncClient(...) 传入自定义 Headers 与超时,不再接受裸 headers/timeout 参数;且上下文管理器仅返回 2 元组 (read_stream, write_stream),原代码按 3 项解包抛出 ValueError。
    3. Pydantic 模型字段由驼峰变为蛇形:mcp 2.x 将 Tool.inputSchema 更名为 Tool.input_schema,未做向下兼容导致直接访问抛出 AttributeError。
    4. AnyIO 超时类型冲突:MCPTool.call 向下传递了 timedelta(seconds=...),而底层 anyio.fail_after 仅支持 float | None,相加计算 deadline 时触发 TypeError。
  • 解决方案:

    1. 在 astrbot/core/agent/mcp_client.py 中对导入做多版本兼容:

      try:
          from mcp.client.streamable_http import streamable_http_client as streamablehttp_client
      except ImportError:
          from mcp.client.streamable_http import streamablehttp_client
    2. 构造 httpx2.AsyncClient 传递给 streamable_http_client,并按动态元组解包:

      http_client = httpx2.AsyncClient(headers=cfg.get("headers", {}), timeout=httpx2.Timeout(timeout_val))
      await self.exit_stack.enter_async_context(http_client)
      self._streams_context = streamablehttp_client(url=cfg["url"], http_client=http_client, terminate_on_close=cfg.get("terminate_on_close", True))
      streams_res = await self.exit_stack.enter_async_context(self._streams_context)
      read_s, write_s = streams_res[0], streams_res[1]
    3. 工具 Schema 读取兼容:getattr(mcp_tool, 'input_schema', getattr(mcp_tool, 'inputSchema', {}))。
    4. 超时安全转为 float:read_timeout_seconds.total_seconds() if isinstance(read_timeout_seconds, timedelta) else (float(read_timeout_seconds) if read_timeout_seconds is not None else None)。

坑 13:AstrBot 配置完整性检查静默剔除自定义键 (Config Integrity Sanitizer Silent Drop)

  • 现象:在 data/cmd_config.json 或活跃配置文件中手动写入新增的配置项(例如 provider_settings.llm_allowed_groups),服务启动后日志打印:

    [Core] [INFO] [config.astrbot_config:199]: Config key removed: provider_settings.llm_allowed_groups

    随后对应配置项被从 JSON 文件中自动删除并失效。

  • 根因:

    1. AstrBot 在启动时通过 astrbot/core/config/astrbot_config.py 的 check_config_integrity(refer_conf, conf) 方法对比用户配置与参考配置 refer_conf(即 DEFAULT_CONFIG)。
    2. 遍历现有配置项若发现不在 refer_conf 中,会直接调用 logger.info("Config key removed: %s", path_) 并将该键从内存及落盘字典中强制移除。
  • 解决方案:
    若需为系统核心管道或 Provider 添加持久化配置项,必须在 astrbot/core/config/default.py 中同时完成声明:

    1. DEFAULT_CONFIG["provider_settings"] 中声明默认值(如 "llm_allowed_groups": [])。
    2. CONFIG_SCHEMA 对应层级声明字段类型(如 "type": "list", "items": {"type": "string"})。
    3. WebUI 元数据 CONFIG_METADATA_3 中补充描述与提示词,避免前端渲染异常或被判定为非法字段。

坑 14:限定特定群聊与特定机器人响应 LLM 但保持私聊全开 (Scoped Group LLM with Open Private Chat)

  • 业务需求:多群共存或存在多 Bot 实例(例如主号用于业务管理,次号接入 ReadOS 群 1107256431)时,需要避免 LLM 在非授权群聊中误触发甚至刷屏,同时必须允许任何个人用户在私聊中无限制使用 LLM 能力(“私聊全开”)。
  • 架构切入点:

    1. AstrBot 的核心消息管道中,所有最终转交给 LLM(不论是由 @机器人、唤醒词还是插件生成的 ProviderRequest)统一流经 astrbot/core/pipeline/process_stage/method/agent_request.py 的 AgentRequestSubStage.process。
    2. 在该入口处注入前置检查:

      • 若当前事件是私聊消息(event.get_group_id() 为空),直接跳过群聊白名单判定,保持私聊全部放行。
      • 若当前事件为群聊消息,且目标机器人账号匹配(event.get_self_id() 在生效 Bot 名单中),则检查 group_id 是否在 llm_allowed_groups 白名单中;不在白名单则静默 return 跳过 LLM,普通指令和插件 Handler 依然不受影响。
  • 验证结论:服务平滑重启后,未在白名单的群即便 @机器人 亦不会触发模型调用和 token 消耗,授权群与所有私聊均秒级响应。

坑 15:Windows PowerShell 运行 OpenSSH 子进程挂起 (PowerShell OpenSSH Child Process Stdin Hang)

  • 现象:在 Windows PowerShell 环境下通过 Agent 或无头脚本执行 ssh tx '...' 时,偶尔无响应并永久卡住进入后台。
  • 根因:Windows OpenSSH 客户端在没有控制台分配的情况下,默认仍会尝试从管道的 stdin 读取数据,如果父进程没有显式重定向输入或关闭输入端,OpenSSH 进程会永久阻塞在等待标准输入上。
  • 解决方案:执行命令时强制传递 -n(重定向 stdin 到 /dev/null / NUL)以及 -T(禁用伪终端分配):

    ssh -n -T tx 'command ...'

4. 跨端落地与验证核验清单 (Verification Checklist)

  1. [x] 本机 Windows 与远端 Linux(tx 服务器)双端安装部署完成。
  2. [x] 静态素材(头像 avatar.png、贴纸 sticker.png)内嵌至插件目录,无跨平台路径依赖。
  3. [x] Playwright 页面 DOM 解析 + API(/api/pricing, /api/status)多重降级与本地 JSON 兜底机制已实现。
  4. [x] 远端执行 sudo systemctl restart astrbot.service,无报错正常加载并注册。
  5. [x] 覆盖 /中转站价格、/中转站、/价格 及纯文本、带参数 刷新。
  6. [x] 活跃配置 abconf_*.json 白名单更新与 OneBot 管道全流程测试通过。
  7. [x] 恢复 AstrBot 核心代码探针,服务稳定运行中。
  8. [x] astrbot_plugin_claude_fleet 复用内网 3180 服务,停用 32180 冗余子服务。
  9. [x] Claude Code 结合 DeepSeek 官方 Anthropic 端点实现完整端到端 3 页讲义 PDF 编译交付。
  10. [x] 上线并置顶 GPT-6 Astra 居中奢华 Hero 看板,双端同步生成 2x Retina 最新海报缓存。
  11. [x] 修复价格看板插件语言选择器与 0.3 倍率 Bug,更新海报并完成四层测试验证。
  12. [x] AstrBot 挂接本地 Google Search MCP(端口 3182),修补 Python mcp 2.x SDK 兼容问题,端到端联网搜索工具调用测试通过。
  13. [x] 在 astrbot/core/config/default.py 中声明 llm_allowed_groups 与 llm_allowed_groups_bot_ids,解决配置项被配置检查机制自动剥离的问题。
  14. [x] 在 AgentRequestSubStage 中实现仅 ReadOS 用户群(1107256431)开启 LLM 回复、私聊保持全开的精细化作用域路由。

本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/AI apps