GoRoBot 开发文档
本文档面向插件开发者。阅读完可以做到:写出一个能收发消息、@人、发文件、管理群成员的插件,
并把它导出成可分发、可绑定机器码的加固包。
框架版本 1.0.20261020 · SDK 1.0 · ABI 3
快速开始
插件是一个普通的 .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 ""))
装载流程:
- 打开客户端「插件中心」,开启开发者模式(会要求设置一个开发者口令)。
- 载入上面这个
.py文件。宿主发现Plugin子类,实例化、注入self.sdk、 注册你 override 的回调,然后调用on_load()。 - 用另一个账号给已登录的飞书账号发条私聊,插件就会回复。
插件基类
类属性
| 属性 | 类型 | 说明 |
|---|---|---|
name | str | 插件显示名 |
version | str | 插件版本,建议语义化 |
author | str | 作者 |
description | str | 一句话描述 |
abi | int | 目标 ABI,当前为 3。宿主加载时会校验 |
settings_schema | list | 设置项声明,框架据此生成设置弹窗。
它和 settings_page() 都为空的插件不显示「设置」按钮 |
settings_ui | str / 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 / height | 900 / 700 |
独立窗口尺寸,下限 320×240、上限 3840×2160,越界自动收敛 |
wrap | true | 片段是否套框架的暗色外壳样式 |
inject_helper | true | 是否注入 window.grb;关掉后自己 fetch |
frameless | false | 无边框窗口,自己画标题栏与关闭按钮 |
always_on_top | false | 窗口置顶 |
resizable | true | 是否允许拖拽改尺寸 |
独立窗口需要宿主在桌面模式运行,
--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/settings | GET | 取 {schema, values, ui} |
/api/plugins/settings/save | POST | body {id, values},返回校验结果与修正字段 |
/api/plugins/settings/reset | POST | body {id},恢复 schema 默认值并落盘 |
/api/plugins/settings/page | GET | 设置页 HTML 实体(iframe / 窗口 / 新标签都用它) |
/api/plugins/settings/open | POST | body {id, mode},开独立窗口或浏览器标签 |
/api/plugins/settings/url | GET | 只取设置页地址,不打开任何东西 |
/api/plugins/settings/data | GET | ?id=&what=&… 只读数据桥 |
/api/plugins/settings/sources | GET | 可用数据源清单与参数名,做能力探测 |
页面里的 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_chats | count 默认 50 | 最近会话 | 12s |
groups | — | 我加入的群 | 15s |
group_chats | chat_type 可选 | 群会话拉取(可按类型过滤) | 15s |
chat_info | chat_id | 会话详情 | 12s |
group_detail | chat_id | 群详情(公告、设置等) | 20s |
members | chat_id | 群成员,role 1 群主 / 2 管理 / 3 成员 | 20s |
member_names | chat_id | 昵称 → user_id 映射 | 20s |
admins | chat_id | 群管理员 | 20s |
my_role | chat_id | 本账号在该群的权限角色 | 12s |
contacts | limit 默认 100 | 通讯录(含外部联系人、头像直链) | 15s |
search_users | query | 搜用户,可搜非好友 | 15s |
search_groups | query | 搜群 | 15s |
user_name | user_id、chat_id 可选 | 按 ID 反查昵称(群内优先用群名片) | 12s |
group_name | chat_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:四个标签页分别展示
账号、群、通讯录、群成员,点群可下钻看成员。
保存时发生了什么
- 逐字段校验:数字超范围收敛到边界,下拉不在选项内回落默认,
多选过滤非法项,颜色非法回落默认,布尔接受
"yes"/"1"/"on" - 写内存 + 原子落盘(先写
.tmp再替换,断电不留半个文件) - 调用
on_settings_changed(values)—— 回调抛异常不会撤销已保存的配置, 但会写进事件日志,方便排查「改了没反应」 - 界面提示本次改了哪些字段,被自动修正的字段会单独说明
消息对象
回调收到的 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) | list | mode: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) 元组。
| kind | value | 说明 |
|---|---|---|
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 便于预览 |
要点:
- 昵称解析走群成员表(
chat_member_names,60 秒缓存),按最长命中匹配,避免「小李」命中「李」。 - 解析不到时按原样当 user_id 传,不会发失败;要精确控制就用
@{user_id}或at=。 innerText会自动补上@名字,所以通知栏与未读预览也能看到 @ 了谁。- 不想让正文里的
@xxx被解析(本身就是文本内容),传parse_at=False。
上传、文件与图片
发文件 / 图片的入参形态
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() 取字节后自己转发。
上传行为
- 走飞书文件域
POST /uni_api/upload/file,multipart:data是元信息 JSON,file是内容。 - 实测单次全量上传 50MB 稳定(约 7 秒),未走分片链路;框架软上限 200MB,超过直接报错不白传。
- 超时按体积自适应(20 秒 + 1.5 秒/MB),网络类失败自动重试 2 次(退避 1s / 2s)。
- 消息里显示的文件名以「上传时的 name」为准。要让文件名不同,得在上传时指定:
upload_file(path, name="季度报告.pdf"),只改send_file的 name 无效。 - 视频是独立类型,时长需显式给:
send_video(..., duration_ms=8000),不给客户端显示 0 秒。
成员与群管理
| 方法 | 返回 | 说明 |
|---|---|---|
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}
}
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 是插件自己的目录,插件间互不可见。
下一步
- 把插件导出成
.grbp加固包分发给客户。 - 需要更多底层能力时用
raw_call(cmd, payload)直接打网关指令。 - 遇到行为异常先看客户端「消息日志」与去重统计,多数问题出在重复投递或回声上。