功能代码编写帮助

适用:Web 后台 →「功能管理」→ 任一功能 →「功能代码」。保存后即时生效,无需重启机器人。

一、功能是什么

后台「功能管理」里的一项 = 一个功能。一个功能可以同时用两种方式触发,也可以只用其中一种:

触发方式后台字段说明
菜单按钮 data 在主菜单卡片上显示一个按钮,点击后按 data 回调。要显示就必须填 data;留空表示这个功能不做按钮。
文本指令 command + aliases 用户发「指令名 参数」触发;aliases 是别名数组,效果与主指令名相同。留空表示不注册指令。

两种触发方式可以同时配置(双触发):一个功能既是菜单按钮、又是文本指令,共用同一段功能代码。 只做文本指令、不想占用菜单位置时,取消勾选「在菜单中显示」(show_in_menu: false), 这样不必填 data,功能仍可用指令触发。

每个功能还可以写一段 Python 代码(code 字段),触发时执行。触发后的查找顺序:

  1. 若该功能有代码且勾选了「覆盖内置功能」(override: true) → 执行代码并结束, 内置按钮 / 内置指令都不再执行;
  2. 否则先看这次触发是否命中内置逻辑:按钮 data 命中 @pm.button、 指令名命中 @pm.command → 用内置逻辑;
  3. 没有命中内置逻辑时,该功能有代码就执行这段代码;
  4. 都没有:按钮只记一条 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:shizukuShizuku教程 shizuku、shizuku教程、shizuku授权教程
无障碍教程menu:wza无障碍教程 无障碍、无障碍授权教程
在线状态menu:online——
我的 IDmenu: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("你好,这是自定义功能回复")

触发方式不同,参数来源也不同:

功能项上的可选字段:label(显示文字)、desc(描述)、data (按钮回调 key)、link(链接项,必须 https)、command / aliases (文本指令名与别名)、show_in_menu(布尔,为 false 时不在菜单卡片显示,仍可用指令触发)、 code(这段代码)与 override(布尔,对应「覆盖内置功能」, 仅在同时有 code 时有效)。command 与 aliases 合计的指令名 在同一份功能里不能与其它功能重复(后台保存时会报错指出重复的名字)。

除这两个参数外,代码里还能直接用下面这些名字(已经预置好,不用 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.configuredSMTP 是否已配置(未配置时发不了验证码)
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.configconfig.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)执行出错

日志在哪看

排查习惯:先在代码里加一句 log.info("进入我的功能"),保存后触发一次, 日志里能看到就说明代码在跑,问题出在后面;看不到就说明触发没落到这段代码上。

五、安全与限制

功能代码 = 以机器人身份执行任意 Python 代码。 能进这个后台的人本来就能改 config.yaml、启停机器人进程,权限上并没有变多, 所以这里不做沙箱。请务必保管好后台访问令牌(config.yaml 的 webadmin.token), 并确认后台只对可信的人开放。代码以明文存在 data/menu.json 里。

六、相关接口与文件

位置作用
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功能管理页的编辑器、模板、校验与参考实现按钮