给 AstrBot 写一个天气推送插件:12 次提交、6 个坑

起因

每天早上出门前都要查一次天气:今天会不会下雨、要不要带伞、外面几度。手机上点开 App------开屏广告、定位授权、切城市,一套流程走下来十几秒,天天重复。

干脆让机器人自己推。目标很简单:工作日早上推一份当日天气,晚上推一份明天和后天,下雨下雪之前提前叫我一声,有气象预警也告诉我。

AstrBot 是个能接 QQ / 微信 / 飞书等平台的聊天机器人框架,写插件就是写个 Python 类。本来以为两三天能搞定,结果从 v1.0.0 磨到 v1.5.4,12 次提交。这篇把过程里值得记的东西写下来。

先看成品的输出:

erlang 复制代码
【天气|公司|示例市·示例区|10-30 08:00】
实况:晴 21.8°C 体感 16.8°C
西 4 级 湿度 15% 气压 1015hPa 能见度 22.1km
今天:7°~21° 晴
明天:6°~15° 晴间多云转多云
后天:8°~16° 少云转晴
日出 06:36 日落 17:13
空气:AQI 102 轻度污染(首要 PM10)
复制代码
⚠️ 【公司·示例区】橙色大雾预警(↑ 黄色→橙色)
发布时间:10-30 07:40
内容:预计未来 6 小时内能见度小于 200 米...
来源:示例市气象台

整体长什么样

  • 多地址:一个会话能绑多个地点(公司、家、老家),每个地址有各自独立的推送方案,互不干扰
  • 定时推送:每天 / 每周 / 每月 / 每小时 / 日出日落前后,内容按项勾选(实况、逐日、日出日落、空气、生活指数......)
  • 降水提醒:降水即将开始、即将结束时提醒,可以限定只在上班时段盯
  • 灾害预警:轮询,同一条不重复推,等级升高或降低都再推一次
  • 额度报告:把和风天气的账户余额和请求量也定时推一份
  • 工作日日历:自动抓节假日和调休数据,推送可限定只发工作日
  • 成本估算:一条命令算出这套配置一个月要花多少次请求、多少钱
  • 零额外依赖 :Ed25519 签名和 JWT 用标准库自己实现,不需要 cryptography / PyJWT

代码规模:6640 行 Python + 1976 行测试,13 个模块。全部配置在对话里完成,WebUI 里只放系统默认的 API 凭据。

perl 复制代码
qw/
├── client.py     和风 API 客户端(JWT 签发、坐标解析、地理编码)
├── commands.py   指令处理,最厚的一个,1322 行
├── pushers.py    推送主循环:定时、降水、预警、额度
├── schedule.py   规则解析:每天/每周/日出/工作日、时段、内容项
├── store.py      多地址、多规则的存储与迁移
├── cost.py       每月请求数与费用估算
├── usage.py      用量账本(按天分桶)
├── workday.py    节假日日历:抓取、缓存、逐年轮换
├── keys.py       会话级凭据管理
├── naming.py     指令前缀路由
├── render.py     文本渲染
├── quota.py      额度接口
└── jwtlite.py    Ed25519 + JWT,纯标准库

下面挑六个踩得最深的坑。

坑 1:坐标顺序,我改了两次

第一版我定的是「经度,纬度」,理由听起来很正当:和风 API 的 location 参数就是这个顺序,GeoJSON、Google Maps 也是 lon,lat,跟标准对齐总没错。

用了几天发现不对劲。用户(就是我自己)从地图上右键复制坐标,拿到的是 39°54'27", 116°23'17",纬度在前。粘进去被拒,还得手动换一下。每次都要换,每次都要想一下。

第二版改成纬度在前,同时把内部表示也翻了:parse_latlon 返回 (lat, lon),展示、示例、文档全按纬度在前,只有真正发请求时由 geo_location 翻成和风要的 经度,纬度。翻转藏在客户端内部,用户看不见。

中间还试过第三个方案,自动判序------哪个数超过 90 哪个就是经度。看起来聪明,其实是个陷阱:

中国境内经度 73136、纬度 354,两个区间有重叠。乌鲁木齐 87.62、拉萨 91.11 这些地方经度小于 90,按这个规则会判反。

而且用户永远不知道自己该按哪个顺序写,写对了是运气好。所以最终定的是:固定一种写法,写反了给明确报错和可以直接照抄的正确写法。

复制代码
坐标顺序是「纬度,经度」------纬度在前,经度在后。
你写的是「经度,纬度」,第一个数 116.40 超过 ±90,那只能是经度。
改成:39.90,116.40

报错里带上用户实际输入的数字,不是写死的示例值。这点小事对体验影响很大------用户看到自己的数字被复述,才确信插件真的读懂了。

度分秒也得认,而且必须有容错:

python 复制代码
parse_latlon('39°54\'27",116°23\'17"')     # 标准写法
parse_latlon('39度54分27秒,116度23分17秒')   # 中文单位
parse_latlon('39°54′27″,116°23′17″')       # 全角撇号(从网页复制的常见形态)
parse_latlon('39°54\',116°23\'')           # 只有度分
parse_latlon('N39.90,E116.40')            # 方位词
parse_latlon('39 54 27, 116 23 17')       # 纯数字串:分不开,返回 None

最后一行是重点:纯数字串既可能是「度 分 秒」,也可能是三个独立的数,插件不猜,直接告诉用户「两个点都得带 ° 和 '」。

坑 2:「黄色」两个字,让预警静默失效了

这个 bug 从 v1.0.0 就在,一直到 v1.5.0 才发现。

预警推送有个「最低推送等级」设置,默认黄色及以上。实现是按等级排序取 rank,低于配置的过滤掉:

python 复制代码
LEVEL_ORDER = {"blue": 1, "yellow": 2, "orange": 3, "red": 4}

然后 level_rank(text) 从接口返回的 level 字段查表。问题在于,和风 /v7/warning/now 返回的是**「黄色」两个汉字**,不是「黄」。

json 复制代码
{"level": "黄色", "title": "大雾黄色预警", "type": "大雾"}

LEVEL_ORDER["黄色"] 查不到 → 所有等级都落到默认值 0 → 全部低于最低等级 2 → 过滤掉,一条预警都推不出去。而且不报错、不打日志,因为「查不到等级」和「等级确实很低」在代码里长得一模一样。

修法是查表前先归一化,单字、双字、英文都认:

python 复制代码
LEVEL_ALIAS = {
    "blue": "blue", "白": "blue", "蓝": "blue",
    "yellow": "yellow", "黄": "yellow",
    "orange": "orange", "橙": "orange",
    "red": "red", "红": "red",
}

教训很直接:外部数据里的枚举值,永远不要假设成你想的那个样子。 写测试的时候我用的假数据是 "level": "黄",因为参数名和中文习惯都指向单字;真实接口给的是双字。测试全绿,功能全废。

现在测试里这几个形态都覆盖了,还加了一条:认不出来的等级要原样展示,不能吞成空字符串------宁可显示一个看不懂的值,也不要静默丢弃。

坑 3:插件根本没拿到配置(最致命的一个)

这个 bug 从一开始就存在,只是前期没配 API 凭据,所以没暴露。

现象:在 WebUI 里填好 iss / sub / kid / host / 私钥,点保存,插件日志里却写着「系统默认 key 未配置(系统默认 key 缺少:iss, sub, kid, host, 私钥)」。重载插件、重启进程,都一样。而 self.config.get("iss") 明明应该读到东西。

翻 AstrBot v4.29.3 的源码:

python 复制代码
class Star(CommandParserMixin, PluginKVStoreMixin):
    def __init__(self, context: Context, config: dict | None = None) -> None:
        self.context = context
        # ... 就没了

基类只存了 context,不再把 config 赋给 self.config。 老版本是存的,官方文档的示例也写着「记得保存 config」,但 4.29 的基类不做了。

于是插件里所有 self.config.get(...) 都抛 AttributeError,又被读取函数里的 except 兜底吃掉,返回值统统退化成默认值:

  • iss / sub / kid / host / 私钥 → 全空 → key 永远配不好
  • command_prefix → 空 → 永远回落默认前缀
  • enabled / push_enabled → 永远 true

这解释了为什么「填了、保存了、重载了、重启了」,读到的还是空。

而测试是共犯。 我写测试桩的时候「顺手」写了一句:

python 复制代码
class Star:
    def __init__(self, context=None, config=None):
        self.context = context
        self.config = config or {}   # ← 真实框架没这行

桩比真实框架「更好用」,正好把这个 bug 盖住了。四百多条用例全绿,因为用例跑的是个更完美的世界。

修法很简单:自己留一份,读取全走它。

python 复制代码
self._conf = config if config is not None else {}
self.config = self._conf   # 兼容老框架

配套还做了一件事:框架给不出 config_path 时,按数据目录反推配置文件位置(data/plugin_data/<插件名> → data/config/<插件名>_config.json),因为热重读要靠它定位文件。

这个坑的真正教训不是「记得保存 config」,而是:测试桩必须照抄框架源码,不能顺手写得更好。 桩和真实实现之间的任何「优化」,都是在给未来的自己埋雷。

坑 4:保存配置,插件不会重载

在坑 3 之后才看清这是两层问题。

AstrBot 保存插件配置走 PUT /plugins/config,它只把 JSON 写进 data/config/<插件名>_config.json,不会重载正在运行的插件实例 ------只有 .py 文件变动才触发热重载。

所以就算插件正确拿到了 config 对象,那个对象也是加载时构造的。用户在 11:59 填完保存,插件在 11:58 加载,内存里还是空值。

解法是每次读配置前 stat 一下配置文件,mtime 变了就重新合并:

python 复制代码
def _sync_config(self) -> None:
    path = self._config_path
    if not path:
        return
    try:
        mtime = os.stat(path).st_mtime_ns
    except OSError:
        return
    if mtime == self._cfg_mtime:
        return
    self._cfg_mtime = mtime
    data = json.loads(Path(path).read_text(encoding="utf-8"))
    self._conf.update(data)
    self._keys.set_defaults(...)   # 重算默认 key,坏私钥只记错误不抛
    logger.info(f"[和风天气] 配置已保存,自动重读:系统默认 key {...}")

同时把启动日志改成逐项自证,能看出到底是哪一项没读到:

ini 复制代码
[和风天气] 配置文件:/AstrBot/data/config/astrbot_plugin_qweather_config.json
[和风天气] 配置读到 14 项
[和风天气] 配置项 iss=Q9ST...(10 位)
[和风天气] 配置项 host=ab12...(29 位)
[和风天气] 配置项 私钥=已载入(公钥 VATVQpX1...)

值都打码,只露前 4 位和长度。这五行在排查时救了我:一眼就能分清「没保存」和「保存了没读到」------前者显示「空」,后者会显示长度不对。

顺手把 QWeatherError(status=0) 的 0 前缀去掉了,本地错误不该伪装成 HTTP 状态码。

坑 5:HTTP 200 不等于数据到了

节假日和调休数据用 NateScarlet/holiday-cn,每年一份 JSON,结构是 days: [{name, date, isOffDay}]。

节假日安排每年 10 月底到 11 月才公布。公布之前请求次年的文件,会拿到什么?

直觉是 404。实际是 HTTP 200 + 空的 days 数组。

python 复制代码
# 错的:以为 200 就是有数据
if resp.status == 200:
    self._ready.add(year)

# 对的:按内容判断
if data.get("days"):
    self._ready.add(year)

要是按状态码判断,插件会在 11 月 1 日「成功」抓到一个空文件,然后标成「已就绪」,那一年的工作日判断全部退化成「周一到周五」------调休上班的周六会被当成休息日,推送就漏了。而且不会报错。

现在的策略是「抓取窗口」:从次年数据开放日(默认 11-01)起,每天 0 点试一次,拿到非空的 days 就永久停手;加载时如果已经过了开放日,立刻补一次。每年 2 月 1 日清理旧年份,但当前年和次年永远保留------它们是「今天是不是工作日」要用的数据。

数据源挂掉也不会让插件报错,只是退化成纯周末规则,并在查询时明确提示「2027 年调休数据还没公布」。

坑 6:成本不算,就会以为是「免费」

和风天气免费额度是每月 5 万次(「天气和基础服务」档),很多人到这一步就不算了。但推送类插件的请求数是乘出来的:

  • 雨雪提醒:每 10 分钟轮询一次,一天 12 小时窗口 → 6 × 12 × 30 = 2160 次/月,一个地址就是两千多次
  • 定时推送:10 条规则 × 每天 2 次 × 30 天 = 600 次
  • 预警轮询:1 小时一次 = 720 次

地址一多、轮询一密,五万次就没那么宽裕了。

所以做了三件事,让「加个地址」不必先算账:

① 预警用全局列表。 /v7/warning/list?range=cn 一次调用返回全国所有正在生效的预警,只有自己绑定的城市真在列表里,才去查详情。预警成本与用户数、地址数无关,永远是 720 次/月。

② 降水先预筛。 查分钟级降水之前,先看逐小时预报里的降水概率。概率为 0 整轮跳过,压根不碰分钟接口。逐小时结果缓存 1 小时。

③ 给个算账的命令。 /qweather 成本 直接列出每一项的算法:

ini 复制代码
【公司】
  1. 每天 08:00〔仅工作日〕|全部内容 约 18 次/月 × 7 = 126 次
  2. 每天 21:30〔仅工作日〕|全部除实况 约 18 次/月 × 6 = 108 次
  雨雪轮询 每 30 分钟 约 450 次/月
  雨雪预筛 逐时预报 约 234 次/月
  预警轮询 每 60 分钟 约 225 次/月
  预警详情(列表命中才查) 约 225 次/月 × 0.05 = 11 次

合计:约 1,190 次/月
成本:0.00 元/月(每月前 50,000 次免费,还剩 48,810 次余量)

有个细节容易算错:内容项里「日出日落」是本地计算的,不额外请求接口。8 项内容实际只发 7 次请求。第一版按 8 次算,成本表多报了 14%。

其他值得记的小事

指令前缀必须非空。 一开始用 @filter.command("天气") 注册,好处是框架原生支持,坏处是指令名在 import 时就固定了,配置里改了前缀根本不生效。更麻烦的是,空的或者通用的指令名会和别的天气插件撞------两个插件同时回一条消息。

最后改成单一的消息处理器,自己解析前缀(默认 qweather,空值、空格、只有 / 一律回落默认),顺便支持了 qweather天气 这种漏空格的写法。

命中前缀要认领消息。 匹配到前缀后调 event.stop_event(),否则私聊里发 qweather 天气(不带 /、不 @),插件回一份、LLM 再自由发挥一份,两条消息说的是同一件事。不匹配时一个都不动,别的插件照常工作。

id 要带字母前缀。 规则 id 用 secrets.token_hex(3) 生成,纯十六进制有约 6% 的概率全是数字,会被「按序号删除」的逻辑当成序号。加个 r 前缀省掉这类排查。

零依赖是划算的。 AstrBot 官方镜像里不一定装了 cryptography 或 PyJWT,装依赖失败的插件在容器里很难排查。Ed25519(RFC 8032)的参考实现只有一百多行,签名结果和 openssl pkeyutl -sign -rawin 逐字节一致,换掉一个依赖很值。

顺带记一句和风 JWT 的坑:payload 必须有 iss(开发者 ID)、sub(项目 ID)、iat、exp,少 iss 会 401。网上几个开源插件就是漏了这个字段。

420 项测试,跑完不到 3 秒

没有连真实 API,全部是离线用例:给 astrbot.api 打桩,然后跑签名、规则引擎、存储、指令解析、推送循环、日历维护、成本计算。

复制代码
结果:420 通过 / 0 失败

这套测试的价值在后期体现得很明显:坐标顺序从「经度在前」改成「纬度在前」时,要动客户端、渲染、指令、文档四处,改完跑一遍就知道有没有漏;stop_event() 加了之后补 5 条用例,确认不匹配的消息不会被误吞。

但坑 3 也说明了测试的边界:桩写错了,测试保护的是幻觉。 现在测试桩严格照抄框架源码,包括「不保存 config」这个看着像 bug 的行为。

成本总结

按上面那套「工作日两班制」的配置(早 8 点全量日报、晚 21:30 复盘、只在 8-12 / 13-21:30 盯雨雪和预警、每天 21:00 对额度):

项目 次/月
定时推送 2 条(全量 7 次 + 除实况 6 次) 234
雨雪轮询 + 预筛 684
预警轮询 + 详情 236
额度推送(财务汇总 + 请求量) 36
合计 约 1,190

免费额度 5 万次/月,占用 2.4%,费用 0 元。按官方「天气和基础服务」档的阶梯价(超出每万次 7 元)算,得再叠 40 个同样的地址才会开始产生费用。

最后

从「每天点开 App 查天气」到一个 6600 行的插件,真实的成本不在写功能,在于那些不报错的 bug:

  • 等级查不到 → 预警静默消失
  • config 读不到 → 所有配置退化成默认值
  • 200 但 days 为空 → 日历「就绪」了但没数据

它们的共同点是:代码不崩、日志不响、测试全绿,只是功能悄悄不工作。所以后来加了不少「自证」的设计------启动日志逐项打码输出配置、查不到等级就原样显示、日历没就绪就在查询时明说。让系统能证明自己读到了什么,比让它跑起来更难,也更重要。

项目地址:github.com/szjmhxd/ast...(MIT) 数据来源:NateScarlet/holiday-cn、和风天气官方文档

相关推荐
触底反弹1 小时前
🚀 FastAPI 五大重难点深度剖析:从 500 报错到彻底搞懂 Depends、Pydantic 与 SQLAlchemy
python·fastapi
cpolar技术支持1 小时前
网关加 gzip,同时开启 chunked,下载回来的包解不开:把两层编码的解压顺序与长度核对做成只读页
python·http·网络编程·socket·内网穿透
小嘉丶学长1 小时前
Day14 · 字典与集合
java·开发语言·网络·python·学习·1024程序员节
灿飞1 小时前
第 3 章 第一个 Mojo 程序与 CLI 全解
ai编程
迪菲赫尔曼2 小时前
【基础篇 11】把官方源码放到服务器:版本、解压层级和安装位置,一次弄清楚
运维·服务器·开发语言·人工智能·python·yolo·目标检测
用户8356290780512 小时前
Python 调整 Excel 工作表显示设置
后端·python
SL_staff2 小时前
制造业订单交付总踩坑?用结构化节点协同模型重构计划系统
java·开源·全栈
程序员老刘2 小时前
从短剧App的bug看响应式编程在AI时代的优势
flutter·ai编程·客户端