GoRoBot 开发文档

本文档面向插件开发者。阅读完可以做到:写出一个能收发消息、@人、发文件、管理群成员的插件, 并把它导出成可分发、可绑定机器码的加固包。
框架版本 1.0.20261020 · SDK 1.0 · ABI 3

文档里所有接口都经过真实账号调用验证。标注 收费版 的能力在免费版会抛 SDKError,详见 免费版与收费版。

快速开始

插件是一个普通的 .py 文件,里面定义一个继承 Plugin 的类。

from gorobot_sdk import Plugin

class Hello(Plugin):
    name = "Hello"
    version = "1.0.0"
    author = "you"
    description = "最小可用插件"

    def on_load(self):
        self.log("已启动")

    def on_private_message(self, msg):
        self.reply(msg, "收到:" + (msg.get("content") or ""))

装载流程:

  1. 打开客户端「插件中心」,开启开发者模式(会要求设置一个开发者口令)。
  2. 载入上面这个 .py 文件。宿主发现 Plugin 子类,实例化、注入 self.sdk、 注册你 override 的回调,然后调用 on_load()。
  3. 用另一个账号给已登录的飞书账号发条私聊,插件就会回复。
登录不在插件里做。飞书账号的扫码 / Cookie 登录只在客户端「账号列表」页, 插件拿到的只是「已登录且在监听」的账号句柄。这是刻意设计,避免插件窃取凭证。

插件基类

类属性

属性类型说明
namestr插件显示名
versionstr插件版本,建议语义化
authorstr作者
descriptionstr一句话描述
abiint目标 ABI,当前为 3。宿主加载时会校验
settings_schemalist设置项声明,框架据此生成设置弹窗。 它和 settings_page() 都为空的插件不显示「设置」按钮
settings_uistr / dict设置页呈现方式:"dialog"(默认)/ "window" / "tab",或 dict 精细控制尺寸、外壳、无边框等

可覆写的方法

方法说明
settings_page(self)返回自定义设置页 HTML(片段或完整文档均可)。 不覆写或返回空串时,框架用 settings_schema 自动生成表单

回调钩子

只注册你真正重写的钩子,没写的不会挂到总线上。

钩子参数触发时机
on_load()—插件装载完成,SDK 已就绪
on_unload()—热卸载前,适合落盘与清理
on_message(msg)dict任意消息(含群聊与私聊,最宽泛)
on_group_message(msg)dict群聊消息
on_private_message(msg)dict单聊消息
on_self_message(msg)dict本账号自己发出的消息
on_event(evt)dict系统事件(账号上下线、授权变化等)
on_settings_changed(values)dict用户在框架里保存设置后调用(可选)

便捷方法

方法说明
self.reply(msg, text)回到该消息所在会话,自动带上 _account
self.reply_group(msg, text)同上,仅群聊时发送
self.log(text, level="INFO")写系统日志,带插件名归属
self.meta()插件元信息 dict
self.sdk该插件专属的 SDK 实例
self.config插件配置对象,读写设置项(详见下文「插件设置页」)

插件设置页

插件卡片上的「设置」按钮由框架自动生成。声明 settings_schema 后, 框架负责渲染表单、校验取值、原子落盘,并在保存后回调 on_settings_changed(), 插件不需要写任何前端代码;要更自由的界面,覆写 settings_page() 即可, 表单、图表、向导、整套自研 UI 都行。加固包(.grbp)里同样生效。

声明设置项

from gorobot_sdk import Plugin

class MyPlugin(Plugin):
    name = "群管助手"
    version = "1.2.0"

    settings_schema = [
        {"key": "prefix", "label": "回复前缀", "type": "text",
         "default": "🤖", "help": "回复时加在最前面的文字"},
        {"key": "rate", "label": "每分钟上限", "type": "number",
         "default": 5, "min": 1, "max": 60},
        {"key": "mode", "label": "工作模式", "type": "select", "default": "all",
         "options": [{"value": "all", "label": "所有群"},
                     {"value": "white", "label": "仅白名单"}]},
        {"key": "silent", "label": "静默模式", "type": "bool", "default": False},
        {"key": "keywords", "label": "触发词", "type": "textarea",
         "default": "ping\nhello", "rows": 5},
    ]

    def on_load(self):
        # 装载即可读到配置,自动带 schema 默认值
        self.log(f"前缀 = {self.config['prefix']!r}")

    def on_settings_changed(self, values):
        # 用户点了保存;此时新值已生效并落盘
        self.log(f"设置已更新:{values}")

支持的字段类型

type控件专属属性
text单行输入(默认)placeholder
textarea多行输入rows(2~20)、placeholder
number数字输入min max step
range滑杆 + 数值回显min max step
bool开关—
select下拉单选options
multiselect复选框组options,值为数组
password密钥输入,可点「显示」明文留空保存 = 不修改
color取色器值形如 #4d8bff

公共属性:key(必填,字母/数字/_/-/.)、 label、default、help。坏条目会被静默丢弃而非报错 —— 一个字段写错不影响其它字段,也不影响插件运行。

读写配置

写法说明
self.config["prefix"]读;键不存在时返回 schema 默认值,不会 KeyError
self.config.get("k", 1)读,带自定义兜底
self.config["k"] = v写内存(不落盘)
self.config.update({"a": 1})批量写内存
self.config.save()落盘(框架保存设置页时自动调用)
self.config.all()全部键值 dict
self.config.reset()恢复默认并落盘
两套配置别混用:self.config 是插件自己的配置 (存 plugin_data/<插件ID>/settings.json,卸载重装同 ID 会保留); sdk.get_setting() 读的是框架全局设置。两者互不相干。

自定义设置页(完全自由 UI)

声明式表单不够用时(图表、二维码、动态联动、多步骤向导、整套自研界面), 覆写 settings_page() 返回 HTML。插件决定全部 DOM 结构、CSS、JS 框架和交互, 框架不插手渲染,只负责把页面送到浏览器、并把配置读写接口摆好。

class Console(Plugin):
    name = "数据看板"

    # 开成独立原生窗口,不套框架的标题栏与侧栏
    settings_ui = {"mode": "window", "title": "数据看板",
                   "width": 1180, "height": 760}

    settings_schema = [
        {"key": "api", "label": "接口地址", "type": "text", "default": ""},
    ]

    def settings_page(self):
        # 返回完整文档:框架一个字节都不改,自带 CSS / 自带 CDN 都行
        return """<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>html,body{margin:0;background:#0b0e13;color:#e9edf4}</style>
</head><body>
  <main id="app"></main>
  <script>
    grb.get().then(cfg => {
      document.getElementById('app').textContent =
        '当前接口:' + (cfg.values.api || '未配置');
    });
  </script>
</body></html>"""

呈现方式 settings_ui

取值效果
"dialog"默认。框架弹窗里的 iframe,最轻,关掉即销毁
"window"独立原生窗口,有自己的标题栏与尺寸,不套框架界面
"tab"系统浏览器新标签页,彻底脱离框架进程的 UI

也可给 dict 精细控制,字符串简写 settings_ui = "window" 等价于 {"mode": "window"};未知字段忽略、非法 mode 回落 dialog —— 声明写错不会让设置页打不开。

键默认说明
mode"dialog"dialog / window / tab
title插件名窗口标题 / 浏览器标签标题,最长 120 字
width / height900 / 700 独立窗口尺寸,下限 320×240、上限 3840×2160,越界自动收敛
wraptrue片段是否套框架的暗色外壳样式
inject_helpertrue是否注入 window.grb;关掉后自己 fetch
framelessfalse无边框窗口,自己画标题栏与关闭按钮
always_on_topfalse窗口置顶
resizabletrue是否允许拖拽改尺寸
界面上的手动切换:卡片点「设置」按插件声明走;Shift + 点击强制用弹窗预览; 弹窗底部的「独立窗口 / 浏览器打开」可随时改开法。
独立窗口需要宿主在桌面模式运行,--server-only 或浏览器里跑时 自动降级为浏览器新标签页,不会留下永不显示的僵尸窗口。

两种返回方式的差别

插件返回框架做什么
片段
不以 <!DOCTYPE / <html 开头
套一层暗色主题外壳(wrap:false 则只补 charset 与视口), 再把 window.grb 放进 <head>
完整文档
<!DOCTYPE html> 开头
原样输出,一个字节都不改;仅当 inject_helper 为真(默认)时 往 </head> 前插一段 window.grb

连 grb 都不要的话,设 inject_helper: false,自己用 fetch 调下面这些接口即可(都走框架本机端口的同源请求,无需鉴权头,id 为插件 ID):

接口方法说明
/api/plugins/settingsGET取 {schema, values, ui}
/api/plugins/settings/savePOSTbody {id, values},返回校验结果与修正字段
/api/plugins/settings/resetPOSTbody {id},恢复 schema 默认值并落盘
/api/plugins/settings/pageGET设置页 HTML 实体(iframe / 窗口 / 新标签都用它)
/api/plugins/settings/openPOSTbody {id, mode},开独立窗口或浏览器标签
/api/plugins/settings/urlGET只取设置页地址,不打开任何东西
/api/plugins/settings/dataGET?id=&what=&… 只读数据桥
/api/plugins/settings/sourcesGET可用数据源清单与参数名,做能力探测

页面里的 window.grb

成员说明
grb.pid当前插件 ID
grb.mode当前呈现方式 dialog / window / tab
grb.get()await 得到 {schema, values, ui}
grb.save(values)保存,走框架校验与 on_settings_changed 回调
grb.reset()恢复全部默认值
grb.submit(values)dialog 模式交给父窗口(存完自动关弹窗); window / tab 模式自动退化为 grb.save()

在设置页里读数据

设置页是浏览器里的一段 HTML,没有 self.sdk。要显示「已登录账号」 「群列表」「群成员」这类数据,用框架注入的只读数据桥——后端用该插件的 sdk 实例 代查一次,把 JSON 递回页面。

// 一行拿到数据,返回就是插件钩子里看到的那种结构
const accounts = await grb.accounts();        // [{id, name, user_id, region, online}]
const groups   = await grb.groups();          // [{id, name, type, ownerId}]
const contacts = await grb.contacts(100);     // 通讯录(含外部联系人)
const members  = await grb.members(chatId);   // [{user_id, name, avatar, role}]

// 通用入口
const r = await grb.data('members', {chat_id: chatId});
// r = {ok, what, source, count, rows}

// 失败会抛异常,记得 catch
try { render(await grb.members(chatId)); }
catch (e) { showError(e.message); }          // 如「查询超时(20s)」
方法返回
grb.accounts()已登录账号列表,含在线状态
grb.chats(n)最近会话
grb.groups()我加入的群
grb.contacts(n)通讯录,含头像直链
grb.members(chatId)群成员,role 1 群主 / 2 管理 / 3 成员
grb.memberNames(chatId)昵称 → user_id 的映射表
grb.admins(chatId)群管理员
grb.chatInfo(chatId) / grb.groupDetail(chatId)会话 / 群详情
grb.searchUsers(q) / grb.searchGroups(q)搜索,可搜非好友
grb.framework() / grb.license()版本信息 / 授权状态
grb.data(what, params)通用入口,what 见下表 20 个数据源
grb.sources()可用数据源清单,做能力探测

数据源全清单(grb.data(what, params))

what参数内容超时
accounts / account_detail—已登录账号(含在线状态、user_id、region)8s
chats / recent_chatscount 默认 50最近会话12s
groups—我加入的群15s
group_chatschat_type 可选群会话拉取(可按类型过滤)15s
chat_infochat_id会话详情12s
group_detailchat_id群详情(公告、设置等)20s
memberschat_id群成员,role 1 群主 / 2 管理 / 3 成员20s
member_nameschat_id昵称 → user_id 映射20s
adminschat_id群管理员20s
my_rolechat_id本账号在该群的权限角色12s
contactslimit 默认 100通讯录(含外部联系人、头像直链)15s
search_usersquery搜用户,可搜非好友15s
search_groupsquery搜群15s
user_nameuser_id、chat_id 可选按 ID 反查昵称(群内优先用群名片)12s
group_namechat_id按 ID 反查群名12s
framework—框架版本信息3s
license—授权状态(版本 / 到期 / 免费版标记)3s
machine—本机机器码3s

返回统一为 {ok, what, source, count, rows}:ok 为假时 grb.data() 直接抛 Error(消息可直接展示给用户), 便捷方法则已经帮你取好了 rows。做能力探测用 await grb.sources(),每项形如 {what, method, params}。

安全边界:全部数据源都是只读,没有任何发消息 / 踢人 / 改群 / 上传的入口—— 插件要写操作请在自己的钩子里用 self.sdk。查询只能针对自己的插件实例, 不能拿别的 pid 越界。
每次调用带 3~20 秒超时(通讯录、群成员要打网关);raw 字段会被剥掉, 超长字符串截断到 4000 字,单次最多 2000 条,万人大群不会把页面卡死。

完整可运行示例见 sdk/example_data_page.py:四个标签页分别展示 账号、群、通讯录、群成员,点群可下钻看成员。

保存时发生了什么

  1. 逐字段校验:数字超范围收敛到边界,下拉不在选项内回落默认, 多选过滤非法项,颜色非法回落默认,布尔接受 "yes"/"1"/"on"
  2. 写内存 + 原子落盘(先写 .tmp 再替换,断电不留半个文件)
  3. 调用 on_settings_changed(values) —— 回调抛异常不会撤销已保存的配置, 但会写进事件日志,方便排查「改了没反应」
  4. 界面提示本次改了哪些字段,被自动修正的字段会单独说明

消息对象

回调收到的 msg 是一个 dict。常用字段:

字段说明
msg_id消息 ID,全局唯一,框架已用它做去重
chat_id会话 ID。回复、发消息都用它
chat_type / chat_type_name会话类型数值 / 名称(GROUP、P2P 等)
from_id发送者 user_id
create_time毫秒时间戳
msg_type / msg_type_name类型数值 / 名称,如 TEXT、IMAGE、FILE、MEDIA
content摘要文本。文本消息即原文,图片为 [图片] imageKey=…
content_data结构化内容 dict,如文件消息含 name/size/mime/key
media媒体信息 {"key","crypto","name","size"},下载时需要
root_id / parent_id回复链,用于线程内回复
at_me是否 @ 了本账号
position会话内序号,mark_read 与拉历史用
_account框架注入:该消息属于哪个飞书账号。回消息时务必带上
_echo框架注入:是否被判定为回声(一般不会喂给插件)
多账号场景下,每个回复都必须带 msg["_account"],否则会用默认账号发出, 出现「A 账号收到、B 账号回复」的错乱。

SDK · 框架信息与配置

方法返回说明
get_framework_version()dict{name, version, string, sdk, time}
get_license_info()dict授权状态、剩余时间、是否冻结
get_machine_id()str本机机器码(短码)
edition()dict当前版本与权益,见 免费版与收费版
is_pro() / is_free()bool版本快捷判断
log(text, level="INFO")None写系统日志
get_setting(key, default=None)Any读框架设置
set_setting(key, value)None写设置;app_*、license_*、manifest 等授权项禁改,会抛 SDKError
raw_call(cmd, payload, data)bytes逃生口:直接发网关指令,用于框架尚未封装的能力

SDK · 账号

只读。登录能力不在 SDK 里。

方法返回说明
get_accounts(only_online=False)list账号信息列表
get_account(account_id)dict单个账号信息,不存在返回 {}
require_account(account_id="")str未指定时返回第一个在线账号;没有可用账号抛 SDKError

SDK · 会话与群

方法返回说明
get_chats(mode="all", with_members=False)listmode:all / group / p2p
get_groups()list群列表
get_group_list(with_members=False)list群列表(可带成员)
get_p2p_chats()list单聊列表
sync_chats(only_group=False)list多源兜底全量同步会话
get_chat_info(chat_id)dict会话详情,含 lastMessagePosition
get_group_detail(chat_id, with_members=True)dict群详情
get_chat_members(chat_id)list成员/参与者,含昵称、头像、角色、加入时间
get_chat_admins(chat_id)list管理员 user_id 列表
get_chats_role(chat_id)int我在会话内的角色数值,-1 表示取失败
is_thread_chat(chat_id)bool是否话题群
chat_member_names(chat_id)dict{昵称: user_id},@ 解析用,60 秒缓存
get_chat_settings(chat_id)dict会话设置

SDK · 消息发送

方法返回说明
send_text(chat_id, text, account_id="", root_id=None, at=None, parse_at=True)str发文本,支持 @人,见下节
send_at(chat_id, text, at, ...)str文本 + @ 的便捷写法
send_emoji(chat_id, emoji_key, text=None)str发表情,可带前缀文字
send_rich(chat_id, parts, ...) 收费版str富文本混排,见下节
send_image(chat_id, image, ...) 收费版str发图片(type=5)
send_file(chat_id, file, name="", mime="", size=None, ...) 收费版str发文件(type=3)
send_video(chat_id, file, ...) 收费版str发视频(type=15)
send_media(chat_id, file, ...) 收费版str自动分流:图片→图片消息,视频→视频消息,其余→文件
recall_message(message_id)bool撤回自己发的消息
pull_history(chat_id, count=20)list按 lastMessagePosition 往前拉最近 N 条
pull_message_ids(chat_id, start_position=1, count=20)list只取 ID,返回 [(pos, msg_id)],比拉历史轻
get_messages_by_ids(message_ids)list按 ID 批量取
mark_read(chat_id, max_position)bool标记已读

返回值约定

发送类方法成功返回 message_id 字符串,失败返回 None; 被版本限制、节流拦截时抛 SDKError。建议统一这么写:

mid = self.sdk.send_text(chat_id, "hello", account_id=acc)
if not mid:
    self.log(f"发送失败:{chat_id}", "WARN")

富文本与 @人

富文本混排

send_rich 的 parts 是有序列表,每项可以是 dict 或 (kind, value) 元组。

kindvalue说明
text"你好"纯文本
at{"user_id":"7512…","name":"张三"} 或 "7512…"@某人,给 name 才显示名字
at_all空@所有人
emoji"JOYFUL"飞书表情 key,可用 emoji_search() 查
link{"url":"https://…","text":"点我"}超链接,显示文本可与 URL 不同
img"img_v3_…"图片元素,需先用 upload_image() 拿 key
self.sdk.send_rich(chat_id, [
    {"kind": "text",  "value": "公告:"},
    {"kind": "at",    "value": {"user_id": "7512049628787802140", "name": "张三"}},
    {"kind": "text",  "value": " 请查看 "},
    {"kind": "link",  "value": {"url": "https://example.com/doc", "text": "这份文档"}},
    {"kind": "emoji", "value": "JOYFUL"},
    {"kind": "img",   "value": image_key},
], account_id=acc)
这六类元素是同一条消息。飞书网页端没有独立富文本消息类型: 网关会直接拒绝 type=2 POST,所有内容都挂在 type=4 的 richText 元素里。

@人(三种写法)

# 1) 正文内嵌标记,框架自动解析(默认开启)
sdk.send_text(chat_id, "@张三 记得交报表")              # 按群成员昵称解析
sdk.send_text(chat_id, "@{7512049628787802140} 你好")   # 直接给 user_id
sdk.send_text(chat_id, "@all 十分钟后开会")             # @所有人

# 2) 显式传 at 参数
sdk.send_text(chat_id, "记得交报表", at="7512049628787802140")
sdk.send_text(chat_id, "大家好", at=["7512…", "7513…"])
sdk.send_text(chat_id, "全员注意", at="all")

# 3) 富文本里塞 at 元素,可与链接 / 图片混排
sdk.send_rich(chat_id, [
    {"kind": "at",   "value": {"user_id": "7512…", "name": "张三"}},
    {"kind": "text", "value": " 看下这个"},
])

配套方法:

方法说明
resolve_at(chat_id, token)把 user_id / 昵称 / "all" 解析成 {"user_id","name"}
parse_at_text(chat_id, text)只解析不发送,返回 parts 便于预览

要点:

上传、文件与图片

发文件 / 图片的入参形态

send_image / send_file / send_video / send_media 的 file 参数都支持:

形态说明
str / Path 本地路径推荐。文件名、MIME、大小自动带上,流式上传不整读内存
bytes / bytearray内存字节
文件对象(有 read())任意流,同样流式上传
str(file_v3_… / img_v3_…)已上传的 key,直接发不再上传,适合一图多发
dict {"key","name","mime","size"}已上传文件的完整描述
# 给路径就行
sdk.send_file(chat_id, r"C:\tmp\报告.pdf", account_id=acc)

# 一次上传,多处复用
key = sdk.upload_file(r"C:\tmp\报告.pdf")
sdk.send_file(chat_id, {"key": key, "name": "报告.pdf",
                        "mime": "application/pdf", "size": 102400}, account_id=acc)

# 不关心类型就交给 send_media
sdk.send_media(chat_id, r"C:\tmp\clip.mp4", account_id=acc)

# 只上传不发送
info = sdk.upload_auto(r"C:\tmp\a.png")   # {"file_key": …, "kind": "image", …}

上传与媒体方法

方法返回说明
upload_image(image, name="", mime="") 收费版str上传图片拿 img_v3_…
upload_file(file, name="", mime="") 收费版str上传文件拿 file_v3_…
upload_auto(file, name="", mime="") 收费版dict按扩展名自动选类型,返回含 kind
image_url(key, size="noop")str图片 / 表情 / 头像直链
file_url(file_key, message_id, chat_id)str文件 / 视频 / 语音下载直链
download_media(key, cipher)bytes按 imageKey 下载并解密
message_media(msg)bytes直接取消息里的图片字节
download_file(msg, to_path=None)bytes下载消息里的文件;给 to_path 走流式落盘
download_file_key(file_key, message_id, chat_id, to_path=None)bytes按 key 直接下载
save_file(msg, save_dir=None)str落盘并返回最终路径,重名自动加时间戳
image_url / file_url 只拼直链,不下载。 这两类链接必须带登录 cookie 才能取到,浏览器直接打开或放进 <img src> 都会失败。 要对外提供图片,用 message_media() 取字节后自己转发。

上传行为

成员与群管理

方法返回说明
get_members(chat_id) / get_group_members(...)list群成员列表
get_admins(chat_id)list管理员列表
invite(chat_id, user_ids) / add_group_members(...)bool拉人进群
kick(chat_id, user_id) / remove_group_member(...)bool踢人
leave_group(chat_id)bool退群
create_group(name, user_ids, description=None)str建群,返回 chat_id
rename_group(chat_id, name)bool改群名

搜索与通讯录

方法返回说明
search(query)list通用搜索,用户 / 机器人 / 群一起返回
search_users(query, include_bot=True)list只搜人,能搜到非好友的陌生人
search_groups(query)list只搜群
get_contacts(limit=50)list通讯录,含外部联系人
add_friend(user_id, message=None)dict加好友 / 给陌生人打招呼
create_chat(user_id)str与某人建单聊,返回 chat_id
get_user_name(user_id, chat_id)str取用户名
get_group_name(chat_id)str取群名

返回结构

搜索结果:{"type": "user" | "bot" | "group", "id", "name", "desc", "avatar_key"}, id 可直接用于 @、create_chat()、add_friend()、invite()。

通讯录:{"id", "user_id", "name", "avatar", "type"}。 user_id 是发消息 / @ / 加好友要用的;id 是头像 key;avatar 是头像直链。 服务端会把同一个人的信息拆成两类条目返回,SDK 已按 user_id 合并。

add_friend 返回 {"ok", "chat_id", "message_id", "reason"}:

r = sdk.add_friend("7171061198375518212", "你好,我是小助手")
# {'ok': True, 'chat_id': '7512049932271435795',
#  'message_id': '7698546570173664273', 'reason': '已建单聊并发送打招呼消息'}
网关侧没有可用的好友申请指令(SendUserInvitation 返回 command not found), 所以 add_friend 走网页端同样的路径:与对方建单聊 + 发一条打招呼消息。

日志、去重与注入

方法返回说明
query_messages(chat_id="", keyword="", limit=100)list查消息日志,与界面「消息日志」同一份库,已去重
dedup_stats()dict去重统计(内存 / 磁盘条数)
is_duplicate(message_id)bool该 ID 是否已出现过
emit_message(msg, account_id="")bool主动注入一条消息,走同一条去重闸门;返回是否首次出现
emoji_list(query="", limit=200)list表情表检索
emoji_name(key)str表情 key 转名称
emoji_search(keyword, limit=20)list按关键字搜表情,返回可用 key

装饰器式回调注册

宿主会自动注册被 override 的钩子;也可以在运行时手动挂载、解绑:

def handle(msg):
    self.log("收到:" + (msg.get("content") or ""))

self.sdk.on_message(handle)          # 运行中追加
self.sdk.unbind_all()                # 解绑全部(on_unload 时宿主会自动调用)

内置保护机制

这些在框架层,插件不用实现,但必须知道,否则会踩坑。

机制规则
消息去重 以 msg_id 为主键,LRU(默认 2 万)+ SQLite 24 小时 TTL。 重复投递不会二次分发给插件,也不会在消息日志里产生第二条记录
发送节流 同一会话 20 秒内最多发 8 条,超出抛 SDKError("发送节流:…疑似插件回环")
回声熔断 收到的内容与本框架 20 秒内在该会话发出的内容相同或互相包含,判定为回声, 只入日志不喂给插件回调
账号隔离 每个插件实例持有独立 SDK(plugin_id, plugin_name, data_dir),多账号互不干扰
配置隔离 授权相关设置项禁止插件修改;登录能力不下放给插件
版本配额 免费版禁发富文本 / 图片 / 文件 / 视频,调用时抛 SDKError

免费版与收费版

授权账号没有激活天数时按免费版运行;有剩余天数或永久授权则是收费版。

能力免费版收费版
同时登录的飞书账号1 个不限
send_text / send_at / send_emoji可用可用
send_rich禁可用
send_image / upload_image禁可用
send_file / upload_file / upload_auto禁可用
send_video / send_media禁可用
收消息、日志、群管、搜索、通讯录可用可用

插件里如何兼容

两种写法,任选。推荐第二种(先判断版本,能给出更友好的提示):

from gorobot_sdk import Plugin, SDKError

class Demo(Plugin):
    name = "版本自适应示例"
    version = "1.0.0"

    def on_group_message(self, msg):
        chat_id = msg["chat_id"]
        acc = msg.get("_account")
        ed = self.sdk.edition()          # {'tier': 'free'|'pro', 'features': {...}, ...}

        if ed["tier"] != "pro":
            self.sdk.send_text(chat_id, "[免费版] 发图需升级收费版", acc)
            return
        self.sdk.send_image(chat_id, r"C:\tmp\a.png", acc)

    def on_private_message(self, msg):
        try:
            self.sdk.send_file(msg["chat_id"], r"C:\tmp\报表.pdf", msg.get("_account"))
        except SDKError as e:
            self.sdk.log(f"发送被拦截:{e}", "WARN")
            self.sdk.send_text(msg["chat_id"], "当前版本不支持发文件", msg.get("_account"))

edition() 返回结构:

{
  "tier": "free",                       # free / pro / none
  "label": "免费版",
  "reason": "授权账号未激活天数,按免费版使用",
  "max_accounts": 1,                    # 0 表示不限
  "blocked_send": ["rich", "image", "file", "video", "media", "sticker", "audio"],
  "blocked_names": ["富文本消息", "图片", "文件", "视频", "媒体文件", "表情包", "语音"],
  "features": {"rich": False, "image": False, "file": False,
               "video": False, "multi_account": False}
}
要分发给免费版用户的插件,所有发图 / 发文件的调用都必须包 try/except 或先判断版本, 否则用户只会看到一个报错。tier 为 none 时表示未授权,此时插件根本不会被加载。

数据目录

self.sdk.data_dir 是宿主为该插件分配的独立目录 (<数据目录>/plugins/<插件ID>/),配置、缓存、下载产物都写这里。 卸载后仍保留,避免污染其它插件。

p = os.path.join(self.sdk.data_dir, "state.json")
with open(p, "w", encoding="utf-8") as f:
    json.dump(self.state, f, ensure_ascii=False)

打包成 exe 后,数据目录为 %LOCALAPPDATA%\GoRoBot\plugins\<插件ID>。

生命周期与分发

阶段操作
开发开启「开发者模式」(可设开发者口令),插件中心载入 .py 源码或文件
运行宿主发现继承 Plugin 的类 → 实例化 → 注入 sdk → 注册 override 的钩子 → 调 on_load()
热卸载on_unload() → unbind_all() 解绑全部回调
分发插件中心「导出加固包」生成 .grbp,可选绑定机器码(换机不可用)
恢复上次装载过的加固包在框架启动时自动恢复

导出加固包后,源码不再以明文存在;绑定机器码的包只能在同一台机器上运行, 适合交付给客户但不希望代码外流的场景。

完整插件示例

关键词应答 + @人 + 富文本公告 + 私聊找人 + 收到文件落盘。可直接运行。

import os
from gorobot_sdk import Plugin, SDKError


class Helper(Plugin):
    name = "小助手"
    version = "1.1.0"
    author = "me"
    description = "关键词应答 / @人 / 富文本公告 / 收文件落盘"

    def on_load(self):
        self.log("小助手已启动")

    def on_group_message(self, msg):
        text = (msg.get("content") or "").strip()
        acc = msg.get("_account")
        chat_id = msg["chat_id"]

        if text == "菜单":
            self.sdk.send_text(chat_id, "1) 帮助\n2) 状态", acc)

        elif text.startswith("艾特"):
            # 正文内嵌 @:按昵称解析;@all 全员
            who = text[2:].strip() or "all"
            self.sdk.send_text(chat_id, f"@{who} 你被叫到了", acc)

        elif text == "公告":
            # 富文本混排:@所有人 + 文本 + 链接 + 表情(收费版)
            try:
                self.sdk.send_rich(chat_id, [
                    {"kind": "at_all"},
                    {"kind": "text", "value": " 本周例会改到周五 15:00,详见 "},
                    {"kind": "link", "value": {"url": "https://example.com/notice",
                                               "text": "通知原文"}},
                    {"kind": "emoji", "value": "OK"},
                ], acc)
            except SDKError as e:
                self.sdk.send_text(chat_id, "公告:本周例会改到周五 15:00", acc)
                self.log(f"富文本降级:{e}", "WARN")

        elif text == "发文件":
            try:
                self.sdk.send_file(chat_id, r"C:\tmp\报告.pdf", acc)
            except SDKError:
                self.sdk.send_text(chat_id, "当前版本不支持发文件", acc)

        elif msg.get("msg_type_name") == "FILE":
            # 收到的文件直接落盘(大文件走流式,不整块进内存)
            saved = self.sdk.save_file(msg, self.sdk.data_dir, acc)
            self.log(f"收到文件:{saved}")

        elif msg.get("msg_type_name") == "IMAGE":
            data = self.sdk.message_media(msg, acc)
            if data:
                p = os.path.join(self.sdk.data_dir, f"{msg['msg_id']}.jpg")
                with open(p, "wb") as f:
                    f.write(data)

    def on_private_message(self, msg):
        acc = msg.get("_account")
        txt = msg.get("content") or ""

        if txt.startswith("找人"):
            # 搜索陌生人,把命中的前 3 个告诉用户
            hits = self.sdk.search_users(txt[2:].strip(), account_id=acc)[:3]
            if hits:
                lines = "\n".join(f"{u['name']}({u['type']}){u['id']}" for u in hits)
                self.reply(msg, "找到:\n" + lines)
            else:
                self.reply(msg, "没搜到")
        else:
            self.reply(msg, "收到:" + txt)

    def on_event(self, evt):
        if evt.get("source") == "accounts":
            self.log(f"账号事件:{evt.get('text')}")
注意 save_file 的第三个参数是 save_dir, self.sdk.data_dir 是插件自己的目录,插件间互不可见。

下一步

官方社区

加入 GoRoBot 交流群

使用问题、插件开发、版本更新与授权咨询都在群里。 遇到报错可以先翻开发文档,没解决就来群里问,通常很快有人回。

群号 1131331061
点击加入群聊 手机 QQ 点按钮直接进群