Skip to content

About

sdk for plumebot

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

plumebot-sdk

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),真正执行的是宿主。宿主是唯一的执行者。


1. 快速开始(插件侧)

第三方插件只需要依赖本 module,不需要 import 宿主的任何 internal 包。

1.1 初始化插件工程

go mod init github.com/yourname/my-plugin
go get github.com/plumebot/plumebot-sdk

1.2 实现插件

插件要实现 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 你只需要理解三件事:

  1. 实现 Execute —— 你的业务逻辑;
  2. 返回 PluginResult —— 你「想要的」指令集;
  3. 调用 Serve(impl) —— 交给 SDK 处理握手与 RPC。

1.3 编译成可执行文件

go build -o my-plugin.exe .

编译产物就是宿主要拉起的那个 exePath。宿主不关心插件是不是 Go 写的——只要它按 SDK 的握手协议工作就行。


2. 宿主侧接入

宿主侧调用 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,因此宿主可以把它当普通插件接口使用。


3. 协议详解

3.1 PluginRequest —— 宿主 → 插件

type PluginRequest struct {
	Proto     int      // 协议版本,当前为 1
	Command   string   // 命中的命令名(不含 /)
	Args      []string // 命令参数
	Session   Session  // 会话身份:GroupID 空 = 私聊
	MessageID string   // 触发消息 ID,供引用回复
}

3.2 Session —— 会话身份

type Session struct {
	GroupID string // 群 ID(私聊时为空)
	UserID  string // 当前说话人 QQ 号
}
  • GroupID 非空 → 群聊;GroupID 为空 → 私聊。
  • 插件可以据此区分群聊/私聊场景。

3.3 PluginResult —— 插件 → 宿主的指令集

插件零权限,它只声明意图,宿主是唯一执行者。

type PluginResult struct {
	Reply   *Reply        // 单条回复(多段混排)
	Actions []GroupAction // 附加动作(群管理等)
}

可以两者都返回,也可以都不返回(空 PluginResult{} = 不回复也不动作)。

3.4 Reply —— 一条回复

type Reply struct {
	Quote    bool      // 引用触发消息
	At       string    // "" = 不 @;"sender" = @ 触发者;否则为具体 user_id
	Segments []Segment // 至少 1 段
}

3.5 Segment —— 回复内容段(可混排)

多段可混排文本/图片/表情:

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)

3.6 ImageRef —— 图片来源

type ImageRef struct {
	Source ImageSource // path | url | base64
	Value  string      // 按 Source 解释
}
ImageSource Value 的解释
ImageSourcePath 本地图片路径
ImageSourceURL 图片 URL
ImageSourceBase64 base64 编码的图片数据

3.7 GroupAction —— 群管理动作

对齐宿主群管理能力集:

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 节)。


4. 校验插件返回的指令集

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 非空。

5. 完整示例

5.1 插件端:一个带图片和群管理的综合插件

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{})
}

5.2 宿主端:按命令分发到对应插件进程

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)
}

6. 工作原理(go-plugin net/rpc 变体)

宿主与插件都 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 做了两件手工翻译:

  1. RPCServer.Execute:把 net/rpc 调用翻译回真实方法;
  2. 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"。


7. 开发与测试

插件协议包内自带了往返测试(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)
}

8. 约定与约束

  • 多回复:PluginResult.Reply 是单条回复(多段混排)。宿主若需要发送多条独立消息,由宿主把多个 Reply 分别发送,或由宿主在 sender 层面扩展。
  • 协议版本:PluginRequest.Proto 当前为 1,插件与宿主必须版本一致,否则兼容性由宿主处理。
  • 最小依赖:第三方插件只需依赖本 module(entity + plugin),无需 import 宿主 internal 包。协议类型经 gob 序列化,类型名 = 包路径 + 类型名,因此宿主与插件必须引用同一 module 路径(宿主内 replace 到本地目录即可)。
  • 超时:单次调用默认超时 30s(csstats 类插件需要真实 HTTP 查询 + LLM 点评)。宿主 B-019 的 per-plugin 超时策略后置。调用方传入的 ctx 更紧则优先。

About

sdk for plumebot

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages