plumebot-sdk 是为 plumebot(QQ 机器人宿主)编写第三方插件的官方 Go SDK。
它基于 HashiCorp go-plugin 的 net/rpc 变体,把插件做成独立子进程:宿主与插件是隔离的进程,二者只通过本地管道通信(net/rpc + gob 序列化)。插件写好、编译成一个可执行文件,宿主用一条命令就能拉起并调用它。
宿主 (宿主进程) 插件 (独立子进程)
┌──────────────────┐ ┌──────────────────┐
│ NewClient(exePath)│──spawn──▶│ main 里 Serve() │
│ client.Execute() │──rpc──▶│ Impl.Execute() │
│ ... │◀──gob──│ return Result │
│ client.Close() │──kill──▶│ 退出 │
└──────────────────┘ └──────────────────┘
插件侧零权限:它只声明「我想要的指令集」(PluginResult),真正执行的是宿主。宿主是唯一的执行者。
第三方插件只需要依赖本 module,不需要 import 宿主的任何 internal 包。
go mod init github.com/yourname/my-plugin
go get github.com/plumebot/plumebot-sdk插件要实现 plugin.Plugin 接口,并在 main 里调用 plugin.Serve 把自己注册成插件服务端。
package main
import (
"context"
"fmt"
"github.com/plumebot/plumebot-sdk/plugin"
)
// myPlugin 实现 plugin.Plugin 接口。
type myPlugin struct{}
// Execute 收到一次命令调用,返回宿主需要执行的指令集。
func (m *myPlugin) Execute(ctx context.Context, req plugin.PluginRequest) (plugin.PluginResult, error) {
// req.Command 是命中的命令名(不含 /),req.Args 是命令参数。
switch req.Command {
case "ping":
return plugin.PluginResult{
Reply: &plugin.Reply{
Quote: true, // 引用触发消息
Segments: []plugin.Segment{
{Kind: plugin.SegmentKindText, Text: "pong!"},
},
},
}, nil
default:
return plugin.PluginResult{}, nil // 未命中,不回复
}
}
func main() {
// 注册并阻塞运行,直到宿主断开。
plugin.Serve(&myPlugin{})
}从
main你只需要理解三件事:
- 实现
Execute—— 你的业务逻辑;- 返回
PluginResult—— 你「想要的」指令集;- 调用
Serve(impl)—— 交给 SDK 处理握手与 RPC。
go build -o my-plugin.exe .编译产物就是宿主要拉起的那个 exePath。宿主不关心插件是不是 Go 写的——只要它按 SDK 的握手协议工作就行。
宿主侧调用 plugin.NewClient 拉起插件子进程,得到可调用的客户端句柄。
client, err := plugin.NewClient("./my-plugin.exe")
if err != nil {
panic(err)
}
defer client.Close() // 停止插件子进程
res, err := client.Execute(ctx, plugin.PluginRequest{
Proto: 1,
Command: "ping",
Args: []string{},
})
if err != nil {
// 插件宕机 / 超时等
log.Println("调用失败:", err)
}
// 校验插件返回的指令集(可选,宿主也应做白名单校验)。
if err := plugin.ValidatePluginResult(res); err != nil {
log.Println("插件返回非法指令集:", err)
}
// res.Reply 就是插件希望宿主发送的回复。| 方法 | 说明 |
|---|---|
NewClient(exePath string) (*Client, error) |
按路径拉起插件子进程,返回客户端句柄 |
(*Client).Execute(ctx, req) (PluginResult, error) |
单次调用,带默认超时兜底(30s) |
(*Client).Close() |
停止插件子进程 |
Client 实现了 plugin.Plugin,因此宿主可以把它当普通插件接口使用。
type PluginRequest struct {
Proto int // 协议版本,当前为 1
Command string // 命中的命令名(不含 /)
Args []string // 命令参数
Session Session // 会话身份:GroupID 空 = 私聊
MessageID string // 触发消息 ID,供引用回复
}type Session struct {
GroupID string // 群 ID(私聊时为空)
UserID string // 当前说话人 QQ 号
}GroupID非空 → 群聊;GroupID为空 → 私聊。- 插件可以据此区分群聊/私聊场景。
插件零权限,它只声明意图,宿主是唯一执行者。
type PluginResult struct {
Reply *Reply // 单条回复(多段混排)
Actions []GroupAction // 附加动作(群管理等)
}可以两者都返回,也可以都不返回(空 PluginResult{} = 不回复也不动作)。
type Reply struct {
Quote bool // 引用触发消息
At string // "" = 不 @;"sender" = @ 触发者;否则为具体 user_id
Segments []Segment // 至少 1 段
}多段可混排文本/图片/表情:
type Segment struct {
Kind SegmentKind // text | image | face
Text string // Kind==text 时的文本
Image *ImageRef // Kind==image 时必填
Face int // Kind==face 时的 QQ 表情 id
}SegmentKind |
说明 | 需填字段 |
|---|---|---|
SegmentKindText |
纯文本 | Text |
SegmentKindImage |
图片 | Image(指向 *ImageRef) |
SegmentKindFace |
QQ 表情 | Face(表情 id) |
type ImageRef struct {
Source ImageSource // path | url | base64
Value string // 按 Source 解释
}ImageSource |
Value 的解释 |
|---|---|
ImageSourcePath |
本地图片路径 |
ImageSourceURL |
图片 URL |
ImageSourceBase64 |
base64 编码的图片数据 |
对齐宿主群管理能力集:
type GroupAction struct {
Op GroupOp // mute | unmute | kick | set_card
Target string // 目标 user_id,非空
Duration int // Op==mute 时的禁言时长(秒),>0
Card string // Op==set_card 时的名片
}GroupOp |
说明 | 附加字段 |
|---|---|---|
GroupOpMute |
禁言 | Duration(秒,>0) |
GroupOpUnmute |
解除禁言 | — |
GroupOpKick |
踢出 | — |
GroupOpSetCard |
改群名片 | Card(新名片内容) |
Go 虽然有这些强类型常量,但允许把任意字符串强转成
SegmentKind/GroupOp等。所以宿主不要只依赖类型系统,应调用ValidatePluginResult或自己做白名单校验(见第 4 节、第 6 节)。
ValidatePluginResult 做只读的结构校验(枚举合法、必填字段非空),不是业务逻辑。业务规则(如禁言时长上限、权限判定)归宿主领域层。
if err := plugin.ValidatePluginResult(res); err != nil {
log.Println("非法指令集:", err) // 带位置描述,如 actions[0]: 缺少目标用户
}校验项包括:
- 段类型必须是
text/image/face之一; reply至少要有 1 段;image段必须带Image引用,且Source合法、Value非空;face段的Face > 0;- 群动作的
Op合法、Target非空; mute的Duration > 0;set_card的Card非空。
package main
import (
"context"
"strings"
"github.com/plumebot/plumebot-sdk/plugin"
)
type pingPlugin struct{}
func (p *pingPlugin) Execute(_ context.Context, req plugin.PluginRequest) (plugin.PluginResult, error) {
name := req.Command + " " + strings.Join(req.Args, " ")
switch req.Command {
case "say":
// 回显 + @ 触发者 + 引用消息
return plugin.PluginResult{
Reply: &plugin.Reply{
Quote: true,
At: "sender",
Segments: []plugin.Segment{{Kind: plugin.SegmentKindText, Text: name}},
},
}, nil
case "pic":
// 发一张本地图片
return plugin.PluginResult{
Reply: &plugin.Reply{
Segments: []plugin.Segment{{
Kind: plugin.SegmentKindImage,
Image: &plugin.ImageRef{Source: plugin.ImageSourcePath, Value: "/tmp/cat.png"},
}},
},
}, nil
case "mute":
// 禁言触发者 60 秒 + 回复
return plugin.PluginResult{
Reply: &plugin.Reply{
Segments: []plugin.Segment{{Kind: plugin.SegmentKindText, Text: "已禁言"}},
},
Actions: []plugin.GroupAction{
{Op: plugin.GroupOpMute, Target: req.Session.UserID, Duration: 60},
},
}, nil
}
return plugin.PluginResult{}, nil
}
func main() {
plugin.Serve(&pingPlugin{})
}package host
import (
"context"
"log"
"github.com/plumebot/plumebot-sdk/plugin"
)
// 每个命令对应一个插件进程。
var be = map[string]string{
"ping": "./plugins/ping-plugin.exe",
"mute": "./plugins/mod-plugin.exe",
}
func dispatch(ctx context.Context, req plugin.PluginRequest) {
exe, ok := be[req.Command]
if !ok {
return // 没有插件处理这个命令
}
client, err := plugin.NewClient(exe)
if err != nil {
log.Println("拉起插件失败:", err)
return
}
defer client.Close() // 用完即杀,避免常驻
res, err := client.Execute(ctx, req)
if err != nil {
log.Println("调用插件失败:", err)
return
}
if err := plugin.ValidatePluginResult(res); err != nil {
log.Println("插件返回非法指令集:", err)
return
}
// 这里把 res.Reply 发给群里,把 res.Actions 执行的群管理动作。
sendAndApply(req.MessageID, res)
}宿主与插件都 import 本包,但角色不同:
- 插件侧:
main里调用Serve(impl)。Serve启动 go-plugin 服务端并阻塞,直到宿主断开。真实的impl通过RPCServer翻译层接收net/rpc调用。 - 宿主侧:调用
NewClient(exePath)拉起插件子进程,Client经过RPCClient翻译层把带ctx的惯用调用包装成net/rpc消息。
由于 net/rpc 的方法签名是 (args, *reply) error 且不支持 ctx,SDK 做了两件手工翻译:
RPCServer.Execute:把net/rpc调用翻译回真实方法;RPCClient.Execute:用goroutine + select模拟 ctx 超时/取消,宿主侧再用defaultCallTimeout(30s)兜底。
var Handshake = goplugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "PLUMEBOT_PLUGIN",
MagicCookieValue: "pb1",
}握手是防误执行非插件程序的 UX 特性、非安全特性。go-plugin 会把 RPC 服务端注册为固定名 "pb",客户端统一调用 "pb.Execute"。
插件协议包内自带了往返测试(go-plugin 的 helper 进程模式):以 TestHelperProcess 作为「插件进程」被测试子进程方式拉起,验证 gob 往返还原。
go test ./...新增插件实现时,可以仿照 plugin_test.go:
// TestHelperProcess 不是普通测试:GO_WANT_HELPER_PROCESS=1 时以插件身份 Serve 并阻塞。
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
Serve(&echoImpl{})
os.Exit(0)
}- 多回复:
PluginResult.Reply是单条回复(多段混排)。宿主若需要发送多条独立消息,由宿主把多个Reply分别发送,或由宿主在 sender 层面扩展。 - 协议版本:
PluginRequest.Proto当前为1,插件与宿主必须版本一致,否则兼容性由宿主处理。 - 最小依赖:第三方插件只需依赖本 module(
entity+plugin),无需import宿主 internal 包。协议类型经 gob 序列化,类型名 = 包路径 + 类型名,因此宿主与插件必须引用同一 module 路径(宿主内replace到本地目录即可)。 - 超时:单次调用默认超时
30s(csstats类插件需要真实 HTTP 查询 + LLM 点评)。宿主 B-019 的 per-plugin 超时策略后置。调用方传入的 ctx 更紧则优先。