功能代码编写帮助
适用:Web 后台 →「功能管理」→ 任一功能 →「功能代码」。保存后即时生效,无需重启机器人。
一、功能是什么
后台「功能管理」里的一项 = 一个功能。一个功能可以同时用两种方式触发,也可以只用其中一种:
| 触发方式 | 后台字段 | 说明 |
|---|---|---|
| 菜单按钮 | data |
在主菜单卡片上显示一个按钮,点击后按 data 回调。要显示就必须填 data;留空表示这个功能不做按钮。 |
| 文本指令 | command + aliases |
用户发「指令名 参数」触发;aliases 是别名数组,效果与主指令名相同。留空表示不注册指令。 |
两种触发方式可以同时配置(双触发):一个功能既是菜单按钮、又是文本指令,共用同一段功能代码。
只做文本指令、不想占用菜单位置时,取消勾选「在菜单中显示」(show_in_menu: false),
这样不必填 data,功能仍可用指令触发。
每个功能还可以写一段 Python 代码(code 字段),触发时执行。触发后的查找顺序:
- 若该功能有代码且勾选了「覆盖内置功能」(
override: true) → 执行代码并结束, 内置按钮 / 内置指令都不再执行; - 否则先看这次触发是否命中内置逻辑:按钮
data命中@pm.button、 指令名命中@pm.command→ 用内置逻辑; - 没有命中内置逻辑时,该功能有代码就执行这段代码;
- 都没有:按钮只记一条 debug 日志;指令不产生动作。
也就是说:不勾选「覆盖内置功能」时,功能代码不会覆盖内置按钮/指令;要接管
menu:home、menu:sign、内置「绑定」指令这类已有功能,勾上
「覆盖内置功能」即可,不用改 plugins/ 源码。适合做「几行代码就能搞定」的小功能:
回一句提示、发张卡片、调你自己的服务器接口、记录点击次数,或者替换某个内置功能的行为。
代码存在 data/menu.json 的菜单项里,保存即生效。
两个典型场景
场景 A:接管内置功能(勾选「覆盖内置功能」) —— 把「签到」换成自己服务器的签到:
async def run(ctx, bot):
result = await bot.api_client.call(bot.api_path("sign", "/api/sign"), method="POST")
await ctx.reply(ServerAPI.to_text(result))
场景 B:给新功能写代码(不勾选) —— 新增一个按钮 my:hello(或文本指令
hello),因为没有同名内置逻辑,不勾选也会走这段代码:
async def run(ctx, bot):
await ctx.reply("你好,这是自定义功能回复")
内置功能的 data 与指令名对照表
下面这些 data / 指令名在 plugins/ 里有内置逻辑。
不勾选「覆盖内置功能」时,按钮点击与指令都一律走内置逻辑;勾选后才能用功能代码接管。
「内置指令名」为空表示该功能只有按钮、没有注册文本指令(可在功能里另填 command 给它加一个)。
| 功能 | 按钮 data | 内置指令名 | 指令别名 |
|---|---|---|---|
| 绑定 | menu:register | 绑定 | — |
| 签到 | menu:sign | 签到 | — |
| 个人信息 | menu:info | 个人信息 | 我的信息 |
| Shizuku 教程 | menu:shizuku | Shizuku教程 |
shizuku、shizuku教程、shizuku授权教程 |
| 无障碍教程 | menu:wza | 无障碍教程 |
无障碍、无障碍授权教程 |
| 在线状态 | menu:online | — | — |
| 我的 ID | menu:myid | 我的ID | — |
| 更多功能 | menu:more | — | — |
| 商城 | menu:store | — | — |
| 商店 | menu:shop | — | — |
| 返回主菜单 | menu:home | — | — |
另有不带按钮的内置指令:菜单(别名 menu、帮助菜单)、
解绑。后台功能管理页会在
data 输入框旁显示「内置:签到」这类小标签,提示这个 data 有内置逻辑。
menu:home 会让「返回主菜单」失效(用户点了不再回主菜单,而是跑你的代码);
接管绑定功能会让绑定流程走不下去。
改坏了想恢复:把这段功能代码删掉,或取消勾选「覆盖内置功能」,保存后即恢复内置行为。 每次保存前还会自动把上一版菜单备份到
data/menu.json.bak。
不确定影响时,先给一个新 data(如 my:xxx)或新指令写功能,不要动内置功能。
二、代码契约
必须定义一个名为 run 的入口函数,必须是 async 函数,参数固定两个
(名字可以改,但顺序不能变):
async def run(ctx, bot):
await ctx.reply("你好,这是自定义功能回复")
触发方式不同,参数来源也不同:
- 菜单按钮触发:
ctx.args为空(按钮没有参数)。建议用getattr(ctx, "args", "")兜底,同一段代码两种触发都能用。 - 文本指令触发:用户发「指令名 参数」时,指令名之后的剩余文本(去掉首尾空白)
放在
ctx.args里。例如用户在聊天框发「天气 北京」,ctx.args就是"北京";只发「天气」则是空字符串。指令名参数(中间不加空格)也能匹配。
功能项上的可选字段:label(显示文字)、desc(描述)、data
(按钮回调 key)、link(链接项,必须 https)、command / aliases
(文本指令名与别名)、show_in_menu(布尔,为 false 时不在菜单卡片显示,仍可用指令触发)、
code(这段代码)与 override(布尔,对应「覆盖内置功能」,
仅在同时有 code 时有效)。command 与 aliases 合计的指令名
在同一份功能里不能与其它功能重复(后台保存时会报错指出重复的名字)。
ctx—— 本次触发的上下文,类型是bot/context.py的Context(群里的按钮点击拿到的是它的子类InteractionContext)。发消息、判断群聊/私聊、 取用户 openid、读指令参数都靠它。bot—— 机器人实例(bot/core.py的QQBot), 可以调服务器接口、读写 data 数据、看配置。
除这两个参数外,代码里还能直接用下面这些名字(已经预置好,不用 import):
| 名字 | 是什么 | 常见用法 |
|---|---|---|
ctx | 消息上下文 | await ctx.reply("文字")、await ctx.reply_card(card) |
bot | 机器人实例 | await bot.api_client.call(...)、bot.storage.update(...) |
bind | 绑定功能组(plugins/account.py) |
绑定流程复用,见下方「bind 可用函数」 |
mailer | 邮件发送器(bot/mailer.py 的实例 bot.mailer) |
mailer.configured、await mailer.send_code(邮箱, 验证码) |
Card | 卡片构建器 | Card("正文"),最多 5 行按钮 |
Button | 按钮构建器 | Button("文字", "menu:home") |
ServerAPI | 服务器接口客户端类 | ServerAPI.to_text(接口返回) 转成给人看的文本 |
menu_items | 菜单数据模块 | menu_items.load() 取当前功能列表 |
texts | 回复文案模块 | texts.load("menu_title")、texts.save({...}) |
log | 日志对象 | log.info("..."),会写进 botpy.log |
asyncio | 异步库 | await asyncio.sleep(1) |
time | 时间模块 | time.strftime("%Y-%m-%d") |
bind 可用函数(绑定功能组)
菜单按钮、文本指令和功能代码共用同一份绑定实现,不用自己拼流程:
| 函数 | 作用 |
|---|---|
await bind.start_bind(bot, ctx) |
开始绑定:开绑定锁(3 分钟)+ 回绑定说明卡。等价于点内置「绑定」按钮 / 发「绑定」指令 |
bind.flow_card() |
返回绑定说明卡(只有一句提示 BIND_PROMPT + 「返回主菜单」按钮),不发送 |
bind.open_bind_lock(bot, openid) |
只开绑定锁(不发消息);想自己写提示文案时用 |
await bind.send_code(bot, ctx, email) |
校验邮箱格式、发送验证码并开验证锁;返回是否可用(新发或复用未过期验证码) |
await bind.handle_account(bot, ctx) |
绑定锁内收到账号文本的处理(按 ctx.content 解析 QQ号/邮箱) |
await bind.handle_code(bot, ctx) |
验证锁内收到验证码的处理(读 ctx.content) |
await bind.verify_code(bot, ctx, code=None) |
校验验证码并完成绑定;code 缺省时取 ctx.args。返回是否绑定成功 |
bind.is_bound(bot, openid) | 是否已绑定,返回 True / False |
bind.bound_email(bot, openid) | 已绑定的邮箱,未绑定返回 None |
mailer 可用成员
| 成员 | 说明 |
|---|---|
mailer.configured | SMTP 是否已配置(未配置时发不了验证码) |
await mailer.send_code(邮箱, 验证码) |
发送绑定验证码邮件,成功返回 True。通常直接调 bind.send_code 即可,不必自己发信 |
ctx 的常用成员
| 成员 | 说明 |
|---|---|
await ctx.reply(文字) | 回一条消息(群聊自动 @ 触发的人) |
await ctx.reply_card(Card(...)) | 回一张带按钮的卡片 |
ctx.args | 文本指令触发时「指令名」之后的剩余文本;按钮触发时为空 |
ctx.command_name | 本次命中的指令名(按钮触发时不设置) |
ctx.is_group | 是否群聊(False 表示私聊) |
ctx.group_openid | 群 openid,私聊时为 None |
ctx.user_openid | 触发用户的 openid,可用来区分每个人 |
ctx.member_openid | 群成员 openid(群管理接口用),私聊等于 user_openid |
ctx.is_admin | 是否管理员(超级管理员或后台添加的管理员) |
ctx.content | 按钮触发时是按钮的 data;指令触发时是整条消息内容 |
bot 的常用成员
| 成员 | 说明 |
|---|---|
await bot.api_client.call(path, method="GET", params=None, json=None) |
调用 config.yaml 里 server_api.base_url 下的接口;
成功返回解析后的数据,失败返回 None |
bot.api_path("接口名", "/默认路径") |
读取「基本配置」里 server_api.endpoints 配置的接口路径 |
bot.storage.update(函数) |
读写 data/store.json:文件锁内「重新读盘 → 改 → 写回」,
跨进程安全(机器人后台同时在写也不会互相覆盖)。传入的函数收到整个数据字典,
返回值就是 update() 的返回值 |
bot.storage.data | 当前内存里的数据字典(只读用;要写请用 update) |
bot.config | config.yaml 解析出的字典(只读) |
三、完整示例
示例 1:回复一段文本
async def run(ctx, bot):
await ctx.reply("你好,这是自定义功能回复")
示例 2:回复带按钮的卡片
async def run(ctx, bot):
card = Card("自定义功能卡片\r点下面的按钮可以返回主菜单")
card.add_row(Button("返回主菜单", "menu:home"))
await ctx.reply_card(card)
卡片限制:整张卡片最多 5 行按钮,每行最多 5 个。行数超了会报错, 用户会收到「功能执行失败」。
示例 3:调用服务器接口并把返回发给用户
async def run(ctx, bot):
# 接口名/路径来自 config.yaml 的 server_api.endpoints(可在「基本配置」里维护)
result = await bot.api_client.call(bot.api_path("online", "/api/online"))
card = Card(ServerAPI.to_text(result))
card.add_row(Button("返回主菜单", "menu:home"))
await ctx.reply_card(card)
ServerAPI.to_text() 会优先取返回 JSON 里的
text/msg/message/content 字段,
取不到就把 JSON 格式化成代码块;接口没返回时给一句「服务器接口暂时无法访问」。
示例 4:读取/写入 data 里的自定义数据
async def run(ctx, bot):
key = "clicks:" + (ctx.user_openid or "")
def _apply(data):
data[key] = int(data.get(key, 0)) + 1
return data[key]
# storage.update 在文件锁内"重新读盘-改-写",跨进程安全
count = bot.storage.update(_apply)
await ctx.reply("本功能你已点击 " + str(count) + " 次")
数据存在 data/store.json 的顶层。放管理员列表/群设置以外的自定义键没问题;
不要在 update() 之外直接改 bot.storage.data 后 save(),
那会用旧快照整份覆盖机器人刚写入的数据。
示例 5:改「绑定」按钮的回复文案
内置的绑定说明卡刻意只有一句话(BIND_PROMPT,见 plugins/account.py)。
想自定义回复内容,用功能代码接管绑定功能(勾选「覆盖内置功能」):
async def run(ctx, bot):
bind.open_bind_lock(bot, ctx.user_openid) # 必须先开锁,否则用户直接发账号不会被受理
card = Card("绑定遇简助手\r请直接发送你的遇简APP账号(QQ号或邮箱)")
card.add_row(Button("返回主菜单", "menu:home"))
await ctx.reply_card(card)
# 想在卡片之外再补一句提示,直接回消息:
# await ctx.reply("绑定后即可签到、查询订单")
texts.save() 只接受已注册的 key:menu_title、
welcome_bot、welcome_member。
示例 6:改绑定流程代码(用 bind.* 组合)
下面是一个「发码」新指令:跳过开锁,直接把验证码发到用户指定的邮箱。数据 + 指令配置如下,
功能代码只用了 bind.send_code:
async def run(ctx, bot):
# 文本指令触发时 ctx.args 是指令名之后的文本;按钮触发时为空
email = (getattr(ctx, "args", "") or "").strip()
if not email:
await ctx.reply("用法:发码 你的邮箱")
return
# bind.send_code 会校验邮箱、发验证码并开验证锁,用户随后直接发验证码即可
await bind.send_code(bot, ctx, email)
其它常见组合(伪代码,按需取用):
# 查看绑定状态后再决定下一步
if bind.is_bound(bot, ctx.user_openid):
email = bind.bound_email(bot, ctx.user_openid)
await ctx.reply("你已绑定:" + str(email))
else:
await bind.start_bind(bot, ctx)
# 绑定锁 / 验证锁内收到消息时,交给绑定流程处理(读 ctx.content)
await bind.handle_account(bot, ctx) # 当账号处理
await bind.handle_code(bot, ctx) # 当验证码处理
# 把「验证 验证码」的语义拿来用(code 缺省取 ctx.args)
ok = await bind.verify_code(bot, ctx)
示例 6 的菜单项建议:不勾选「在菜单中显示」(只做指令)、command 填
发码、aliases 填 sendcode, 重新发码(逗号或空格分隔)。
示例 7:文本指令 + 按钮双触发的完整功能
功能配置:显示文字「天气」、data my:weather、
command 天气、aliases tq, 查天气,
勾选「在菜单中显示」。
async def run(ctx, bot):
# 菜单按钮点击:ctx.args 为空 → 用默认城市;文本指令「天气 北京」:ctx.args 为 "北京"
city = (getattr(ctx, "args", "") or "").strip() or "北京"
result = await bot.api_client.call(bot.api_path("weather", "/api/weather"),
params={"city": city})
card = Card(f"{city} 天气\r{ServerAPI.to_text(result)}")
card.add_row(Button("返回主菜单", "menu:home"))
await ctx.reply_card(card)
这样用户既能点菜单里的「天气」按钮,也能直接发「天气 上海」查指定城市, 两种触发共用同一段代码。
示例 8:接管内置按钮或内置指令(勾选「覆盖内置功能」)
async def run(ctx, bot):
# 把内置的「签到」换成走自己服务器的签到(按钮 menu:sign 与指令「签到」都会走这里)。
# 必须勾选「覆盖内置功能」,否则点击/发指令仍走内置签到逻辑。
result = await bot.api_client.call(bot.api_path("sign", "/api/sign"), method="POST")
await ctx.reply(ServerAPI.to_text(result))
四、常见错误与排查
| 现象 | 原因 | 怎么处理 |
|---|---|---|
| 保存时提示「第 N 项『xx』功能代码错误:第 M 行语法错误:...」 | Python 语法写错了(括号、引号、缩进不配对) | 按提示的行号改;行号是代码框里的行号。也可先点「校验代码」单独检查,不必保存 |
| 提示「缺少入口函数:必须定义 async def run(ctx, bot):」 | 没写 run,或写成了同步的 def run,或 run 被写在别的函数里面 |
run 要定义在最外层,并且是 async def |
| 保存时提示「指令名/别名『xx』重复」 | 同一个指令名/别名被两个功能使用(或同一功能的 command 与 aliases 同名) |
改名或删掉重复的那个 |
| 保存时提示「勾选了『在菜单中显示』就必须填写按钮 data」 | 功能只填了 command,但仍勾选在菜单显示 | 取消勾选「在菜单中显示」,或补上按钮 data |
| 保存时提示「是链接按钮,不能填写…」 | 链接项填了 command/aliases/功能代码 | 链接项只跳转链接,不能配触发逻辑;要逻辑请把类型改成「功能(按钮/指令)」 |
| 用户收到「功能执行超时 [E2003],请稍后再试」 | 代码执行超过 15 秒(死循环、asyncio.sleep 太久、接口长时间不返回) |
检查循环退出条件;接口调用把超时交给 api_client 处理,不要自己写长时间等待 |
| 用户收到「功能执行失败 [E2001],请联系管理员」 | 代码运行中抛了异常(变量名写错、接口返回 None 后直接取字段、卡片按钮超过 5 行等) |
看日志里的完整堆栈定位(见下方「日志在哪看」) |
| 用户收到「该按钮功能配置有误 [E2002],请联系管理员」 | 代码被改成了语法错误版本,或运行时 run 不是协程函数 |
回到功能管理点「校验代码」修正 |
| 点击按钮/发指令完全没反应,日志里也没有记录 | 命中了已注册的内置逻辑且未勾选「覆盖内置功能」(内置逻辑里可能本来就不回消息), 或指令名与功能项对不上 | 要接管内置功能就勾上「覆盖内置功能」;否则换个没被内置使用的 data / 指令名(如 my:xxx);
确认指令名与功能项在同一条上 |
| 发指令没反应,但点按钮正常 | 功能项的 command 为空,或拼写与用户发的不一致 |
补上 command(可加 aliases),保存后即时生效 |
| 保存时提示「第 N 项『xx』勾选了覆盖内置功能但未填写功能代码」 | 勾了「覆盖内置功能」但代码框是空的(override 必须配代码) |
补上代码,或取消勾选「覆盖内置功能」。后台保存时会自动把这种空代码项的勾去掉 |
| 代码改了但触发还是老行为 | 没点「保存功能」,或该功能有内置逻辑而未勾选「覆盖内置功能」 | 「校验代码」只检查不保存;要生效必须点「保存功能」;接管内置逻辑需勾选「覆盖内置功能」 |
接管内置功能后原来的功能没了(如 menu:home 不再返回主菜单) |
勾选「覆盖内置功能」后内置逻辑彻底不执行,这是预期行为 | 删掉这段功能代码或取消勾选,保存后即恢复内置行为 |
| 想退回修改前的功能 | — | 每次保存前会自动把上一版备份到 data/menu.json.bak(只留一份),
把它改名回 data/menu.json 即可回滚 |
错误码对照
| 错误码 | 含义 |
|---|---|
| E2001 | 功能代码执行时抛异常 |
| E2002 | 功能代码语法/结构不合法,或 run 不可调用 |
| E2003 | 功能代码执行超过 15 秒被中断 |
| E1001 | 内置按钮回调(plugins/ 里的 handler)执行出错 |
日志在哪看
- 后台「状态日志」标签页:直接看机器人运行日志(
botpy.log), 功能代码里的log.info(...)和出错的完整堆栈都在这里。 - 服务器上:项目目录的
botpy.log(会按天轮转成botpy.log.日期), 或journalctl -u qqbot-webadmin(后台自身日志)。
log.info("进入我的功能"),保存后触发一次,
日志里能看到就说明代码在跑,问题出在后面;看不到就说明触发没落到这段代码上。
五、安全与限制
config.yaml、启停机器人进程,权限上并没有变多,
所以这里不做沙箱。请务必保管好后台访问令牌(config.yaml 的 webadmin.token),
并确认后台只对可信的人开放。代码以明文存在 data/menu.json 里。
- 15 秒超时:执行超过 15 秒会被强制中断,用户收到超时提示,机器人不受影响。
- 不要阻塞事件循环:机器人是单事件循环,代码里写
time.sleep()、 同步网络请求、大循环会卡住所有群的消息。要等待用await asyncio.sleep(...), 要请求接口用await bot.api_client.call(...)。 - 异常不回显给用户:出错只写日志,用户只看到固定的 E2001/E2002/E2003 文案, 避免把接口地址、参数泄露到群里。
- 不要在这里写常驻任务(
while True、定时轮询):触发是一次性的, 常驻逻辑应该写成plugins/里的插件。 - 改动即时生效:每次触发都会读取当前
data/menu.json里的代码, 不存在「启动时缓存」;保存功能后触发一次就是新逻辑。 - 接管内置功能有风险:勾选「覆盖内置功能」后原来
plugins/里的逻辑 彻底不执行(例如接管menu:home会让「返回主菜单」失效)。 改坏了删掉代码或取消勾选即可恢复。 - 保存前自动备份:上一版菜单会覆盖式备份到
data/menu.json.bak。
六、相关接口与文件
| 位置 | 作用 |
|---|---|
GET /api/menu | 读取当前功能列表 |
PUT /api/menu | 保存功能列表(保存前校验、备份 data/menu.json.bak) |
POST /api/menu/check_code | 只校验功能代码语法/结构,不保存(「校验代码」按钮) |
GET /api/menu/templates | 内置功能的参考实现代码(「参考实现」按钮),数据来自 bot/feature_templates.py |
POST /api/menu/sync_panels | 同步 QQ 指令面板(「同步指令面板」按钮) |
bot/menu_code.py | 功能代码的校验(validate)与执行(run_code) |
bot/plugin.py | 按钮 / 指令的分发顺序(override → 内置 → code 兜底)、ctx.args |
bot/menu_items.py | 功能数据的 load/save 与各字段说明 |
bot/feature_templates.py | 内置功能的参考实现代码(后台「参考实现」按钮的数据源) |
plugins/account.py | 绑定功能组(bind 暴露的函数) |
webadmin/main.py | /api/menu 保存校验 + 备份、check_code 与 templates 接口 |
webadmin/static/index.html | 功能管理页的编辑器、模板、校验与参考实现按钮 |