Skip to content

Latest commit

 

History

History
1433 lines (1100 loc) · 47.7 KB

plugin-development.md

File metadata and controls

1433 lines (1100 loc) · 47.7 KB

插件编写指南

返回项目导航 · 界面截图 · API 与存储 · 适配器

SillyGirl 的插件系统基于外部脚本运行时。插件使用 JavaScript/NodeJS(.js)或 Python(.py),按发布者放在 /data/plugins/<发布者>/,通过顶部注释声明元数据,由框架自动加载、匹配,并通过 gRPC 与 Go 主程序通信。

插件市场与本地插件

目录

插件结构

一个最小插件由注释元数据和执行代码组成:

脚本插件按发布者使用一层目录,容器内路径为:

/data/plugins/
  package.json
  pnpm-lock.yaml
  node_modules/
  python_packages/
  smallfawn/
    normal.js
    sharedTools.js
  local/
    hello.py

插件市场安装路径为 /data/plugins/<GitHub Owner>/<插件文件>,本地创建的插件写入 /data/plugins/local/。普通插件和 [module: true] 模块插件(包括 sharedTools.js 一类公共代码)都放在发布者目录;同作者插件用 [depe: ["./sharedTools.js"]] 声明模块依赖。不同作者可以使用相同文件名,插件身份按 发布者/文件名 区分;模块解析不跨作者目录,也不接受 ../。

NodeJS 包依赖仍由所有插件共享 /data/plugins/package.json 和 /data/plugins/node_modules;Python 包依赖共享 /data/plugins/python_packages。旧版根目录 .js/.py 文件继续兼容加载,新安装和新建插件使用发布者目录。 Python 插件使用 Python 3.12。Docker 镜像已内置 Python 3.12、pipx、grpcio==1.83.0 和 protobuf==7.35.1;本地运行时需要自行安装 Python 3.12 和 pipx。

目录和身份约束:

  • 发布者目录只允许一层,脚本入口必须是 /data/plugins/<发布者>/<文件>.js|.py;嵌套目录不会被扫描。
  • 发布者名称会转成安全的小写目录名;空作者归入 local。Windows 保留名会追加 -publisher。
  • 同一发布者下同 stem 的 .js 和 .py 不能并存,例如 demo.js 与 demo.py 会因插件身份相同而被拒绝。
  • 编辑、保存、安装和目录 watcher 都会校验真实路径;通过符号链接跳出 /data/plugins 的脚本不会加载或写入。
  • Cron 脚本命令使用 node <发布者>/<文件>.js 或 python <发布者>/<文件>.py。旧的裸文件名仅在全局唯一时兼容解析,重名时必须写发布者路径。
/**
 * @title HelloWorld
 * @rule raw ^你好$
 * @status true
 */

s.reply("Hello World!");

Python 最小插件:

"""
* @title PythonHello
* @rule raw ^py你好$
"""

import asyncio
from sillygirl import sender as s


async def main():
    await s.reply("Hello World!")


asyncio.run(main())

Python 插件

Python 插件和 NodeJS 插件共用同一套元数据、规则匹配、配置表单、定时任务和插件市场逻辑。差别主要在运行时和 SDK 调用方式。

插件市场支持直接新增本地非公开插件:点击插件市场工具栏“新增插件”会打开源码编辑器,保存时必须包含新式 [title: xxx]、[name: 文件名]、[desc: xxx]、[version: vx.y.z],并至少包含 [rule: xxx] 或 [cron: xxx]/[on_start: true]/[web: true]/[module: true] 之一;系统会强制写入/修正为 [public: false]。已安装插件卡片图标也可打开同款源码编辑器,支持格式化、保存和删除。源码 [name] 必须与实际文件名一致,保存时不会再用元数据悄悄改分类。旧式 @title 注释仍兼容。

运行环境

项目 说明
Python 版本 固定使用 Python 3.12。Docker 镜像已内置;本地 go run 时需要机器上能执行 python3.12,或配置 SILLYGIRL_PYTHON_BIN
SDK 路径 程序会把 sillygirl.py、srpc_pb2.py、srpc_pb2_grpc.py 加入 PYTHONPATH,插件直接 from sillygirl import ...
gRPC 依赖 运行时自动准备 grpcio==1.83.0、protobuf==7.35.1
第三方依赖 通过新式 [depe: ["包名"]] 声明;安装、卸载和查看在 Admin 面板「依赖管理」里选择 Python;旧式 @depe 仍兼容
依赖管理 Python 依赖由 pipx 管理,统一安装到 /data/plugins/python_packages,所有 Python 插件共享
文件结构 插件市场安装到 /data/plugins/<发布者>/<插件名>.py;本地创建安装到 /data/plugins/local/<插件名>.py

本地运行时可以按需指定:

export SILLYGIRL_PYTHON_BIN=python3.12
export SILLYGIRL_PIPX=pipx

Windows PowerShell:

$env:SILLYGIRL_PYTHON_BIN = "py -3.12"
$env:SILLYGIRL_PIPX = "pipx"

异步调用约定

Python SDK 方法都是异步方法,必须在 async def main() 中 await:

content = await s.getMsg()
user_id = await s.getUserId()
await s.reply("收到:" + content)

不要写成:

content = s.getMsg()
s.reply("收到")

上面的写法只会得到 coroutine 对象,或者导致脚本未真正发送回复。

常用 API

from sillygirl import sender as s, Bucket, container, utils

content = await s.getMsg()
user_id = await s.getUserId()
chat_id = await s.getChatId()
platform = await s.getPlatform()
is_admin = await s.isAdmin()
city = await s.param("城市")
await s.reply("文本")
await s.pushAdmin("管理员通知")
await utils.restart()
update_result = await utils.update({"restart": True})

普通用户列表

user.getUserList() 提供普通用户及当前插件的授权和绑定状态。authorized 只表示当前插件的 smallcat:read 授权,运行时会从当前插件上下文判定,不接受插件 UUID 参数。

const { container, user } = require("sillygirl");

const users = await user.getUserList();
const openids = users
  .filter((user) => !user.disabled && user.authorized)
  .flatMap((user) => user.bindings.smallcat_openids);

const accounts = await new container.SmallCat({ id: 1 }).userList();
from sillygirl import container, user

users = await user.getUserList()
openids = [
    openid
    for user in users
    if not user["disabled"] and user["authorized"]
    for openid in user["bindings"]["smallcat_openids"]
]

accounts = await container.SmallCat({"id": 1}).userList()

user.getUserList() 的每个用户包含:

{
  "id": "USER_ID",
  "username": "ACCOUNT",
  "nickname": "昵称",
  "disabled": false,
  "authorized": true,
  "bindings": {
    "qq": "10001",
    "telegram": "20002",
    "smallcat_openids": ["OPENID"]
  }
}

未授权或已禁用用户仍会出现在列表中用于判断状态,但 bindings.smallcat_openids 会被运行时清空。插件未开放、status=false 或源码未检测到 SmallCat 调用时,所有用户的 authorized 都为 false;插件只能读取当前有效授权用户的 SmallCat openid。

Bucket 存储

from sillygirl import Bucket

db = Bucket("python-demo")

await db.set("count", 1)
count = await db.get("count", 0)
keys = await db.keys()
await db.delete("count")

不同插件建议使用不同 Bucket 名称,避免覆盖其他插件的数据。

配置表单

Python 插件支持和 NodeJS 一样的声明式配置。配置对象建议在文件顶层创建,这样插件安装后触发一次规则或设置 @on_start true 时,后台「插件配置」就能注册到表单。

from sillygirl import sender as s, plugin

config = plugin.Form({
    "apiBase": plugin.Form.string()
        .title("接口地址")
        .default("http://127.0.0.1:8081"),
    "token": plugin.Form.string()
        .title("Token")
        .format("password")
        .default(""),
    "feature_enabled": plugin.Form.boolean()
        .title("启用此功能")
        .default(False),
})


async def main():
    values = await config.get()
    if not values.get("token"):
        await s.reply("请先到后台插件配置填写 Token")
        return

依赖声明

第三方 Python 包写在新式 [depe: ...],值是 JSON 数组:

"""
# [title: HTTP 示例]
# [rule: raw ^请求测试$]
# [depe: ["requests"]]
"""

支持版本约束:

# [depe: ["requests==2.32.0", "beautifulsoup4"]]

注意:

  • os、sys、json、asyncio、time、pathlib 等标准库不要写进 [depe: ...]。
  • Python 依赖是共享安装,卸载依赖前确认没有其他插件还在使用。
  • 插件仓库的索引 dependencies 字段由插件注释里的新式 [depe: ...] 生成;没有索引时,SillyGirl 会保底读取脚本注释,也兼容旧式 @depe ...。

Python 定时任务

"""
* @title Python定时提醒
* @cron 0 9 * * *
"""

import asyncio
from sillygirl import sender as s


async def main():
    await s.reply("早上 9 点提醒")


asyncio.run(main())

@cron 只写表达式,不要在后面追加平台。Admin 面板里手动创建 python 插件名.py 定时任务时,也会把表达式写回脚本注释。

Python Web 常驻脚本

@web true 会让脚本作为常驻进程启动,端口和路由由脚本自己监听。下面示例只用标准库,不需要额外依赖:

"""
* @title PythonWeb示例
* @web true
"""

from http.server import BaseHTTPRequestHandler, HTTPServer
import json


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/health":
            self.send_response(404)
            self.end_headers()
            return
        body = json.dumps({"status": True, "message": "ok", "data": None}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


HTTPServer(("0.0.0.0", 3002), Handler).serve_forever()

内联客户端

Python 插件可以引入内联客户端,构造方式和 JS 一样,按后台页面编号选择实例:

ql = QingLong({"id": 1})
envs = await ql.getEnvs({"searchValue": "JD_COOKIE"})

sc = SmallCat({"id": 1})
code = await sc.getCode({"openid": "openid", "appid": "wx123"})

dd = DaiDai({"id": 1})
items = await dd.getEnvs({"keyword": "JD_COOKIE"})

所有客户端方法仍然需要 await。

插件文件可以包含多个 @rule,每条规则独立匹配:

/**
 * @title 多功能助手
 * @rule raw ^你好$
 * @rule raw ^再见$
 * @rule 天气 [城市]
 */

const content = s.getMsg();
if (content === "你好") {
  s.reply("Hello!");
} else if (content === "再见") {
  s.reply("Goodbye!");
} else {
  const city = s.param("城市");
  s.reply(`${city}今天天气晴朗!`);
}

注释元数据

字段 类型 必填 说明
title string 是 插件标题,显示在管理面板和插件市场
rule string 否 消息匹配规则,支持多行。详见规则匹配语法
priority number 否 匹配优先级,数字越大越优先,默认 0
status boolean 否 插件运行状态,false 时消息、Cron 和启动入口均不执行;默认 true
cron string 否 脚本定时任务表达式,例如 @cron 0 * * * *;只支持直接写 Cron 表达式
on_start boolean 否 true 时随系统启动执行一次,常用于初始化服务
web boolean 否 true 时作为 Web 常驻脚本启动;端口和路由由脚本内 Express 自己处理
module boolean 否 true 时表示为模块插件,不响应消息规则
carry boolean 否 写 @carry 或 @carry true 时可作为 Admin 面板「搬运」里的处理脚本;@carry false 表示关闭
version string 否 版本号,如 v1.0.1
author string 否 作者名
desc string 否 插件描述
depe JSON array 否 插件依赖声明。包名由 pnpm/pipx 安装;./xxx.js 或 ./xxx.py 表示插件市场里的依赖模块;旧式 @depe 兼容
icon string 否 插件图标 URL,未填写时使用默认苹果图标
public boolean 否 true 时允许发布到插件市场
admin boolean 否 true 时仅管理员可触发

新式方括号注释

新式注释使用 [key: value] 元数据。NodeJS 用 // [title: ...],Python 用 # [title: ...];//[title: ...] / #[title: ...] 不带空格也能解析。

NodeJS 新式写法:

// [title: 新式插件]
// [name: newPlugin]
// [desc: 新式说明]
// [rule: ^新式命令$]
// [version: v1.0.0]
// [author: admin]
// [class: 工具]
// [depe: ["axios"]]

Python 新式写法:

# [title: 新式Python插件]
# [name: newPythonPlugin]
# [desc: 新式说明]
# [rule: ^新式命令$]
# [cron: 12 8 * * *]
# [version: v1.0.0]
# [depe: ["requests"]]

兼容字段包括:title、name、desc、status、rule、cron、admin、priority、version、author、class、public、icon、module、carry、on_start、web、depe 等。新插件建议使用 [title: ...] / [rule: ...] / [depe: ...] 形式。

旧式 @ 注释兼容

旧式 @title ... / @rule ... / @depe ... 仍兼容读取,但新插件不再推荐使用。

/**
 * @title 旧式插件
 * @desc 旧式说明
 * @rule ^旧式命令$
 * @depe ["axios"]
 */
r"""
@title 旧式Python插件
@desc 旧式说明
@rule raw ^旧式命令$
@depe ["requests"]
"""

[param: {...}] 已废弃且不再解析;插件配置必须使用 Form:NodeJS 顶层 new plugin.Form({...}),Python 顶层 plugin.Form({...})。

元数据示例

/**
 * @title 每日早报
 * @rule raw ^早报$
 * @priority 10
 * @version v1.2.1
 * @author cdle
 * @desc 每天早上9点推送新闻早报
 * @depe ["axios"]
 * @icon https://example.com/icon.png
 * @public true
 */

Web 服务脚本

[web: true] / 旧式 @web true 只支持 true 或 false。写 [web: true] 后,SillyGirl 会在启动或脚本重载时把该脚本作为常驻脚本进程运行;HTTP 端口、路由前缀和监听逻辑全部由脚本自己决定。NodeJS 插件可以使用内置 http 模块或通过 [depe: ...] 自行声明 Web 框架;Python 插件可以使用 Python HTTP 框架或标准库自行监听。旧式 @web / @depe 仍兼容。

/**
 * @title Web 示例
 * @web true
 * @class 工具
 */

const http = require("http");

http
  .createServer((req, res) => {
    if (req.url !== "/health") {
      res.writeHead(404);
      res.end();
      return;
    }
    res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
    res.end(JSON.stringify({ status: true, message: "ok", data: null }));
  })
  .listen(3001, () => console.log("web plugin listening on 3001"));

搬运处理脚本

Admin 面板「搬运」只负责按平台、群号和工作机器人匹配消息,然后把消息交给选中的处理脚本。脚本里自己决定是否过滤、回复或转发。

/**
 * @title 搬运处理示例
 * @carry
 */

const { sender: s } = require("sillygirl");

const content = s.getMsg();
if (content.includes("关键词")) {
  s.reply("处理后的内容");
}

脚本定时任务

脚本头部写了 @cron 后,会直接显示在 Admin 面板「定时任务」列表,并由框架自动注册定时执行。

/**
 * @title 每小时执行
 * @cron 0 * * * *
 */

const { sender: s } = require("sillygirl");

console.log("当前触发平台:" + s.getPlatform());

如果在 Admin 面板「定时任务」里选择 node 插件名.js 或 python 插件名.py 创建任务,系统会自动把 Cron 表达式写回该脚本头部注释,避免生成重复的独立任务。

全局对象与 API

sender (s)

当前消息的 Sender 对象,是插件中最核心的交互入口。

NodeJS 插件里可以直接调用同步方法;Python 插件里 SDK 方法是异步方法,需要在 async def main() 中 await s.reply(...)、await s.getMsg()。

用户信息

s.getUserId()       // 获取用户ID(string)
s.getUserName()     // 获取用户昵称(string)
s.getChatId()       // 获取群聊ID,私聊时为空字符串(string)
s.getChatName()     // 获取群聊名称(string)
s.getMsgId()        // 获取消息ID(string)
s.getPlatform()     // 获取平台类型,如 "qq"、"web"(string)
s.getBotId()        // 获取当前机器人ID(string)
s.isAdmin()         // 判断用户是否为管理员(boolean)

内容操作

s.getMsg()          // 获取消息原始内容(推荐)
s.setMsg(text)      // 修改当前消息内容(影响后续插件匹配)
s.resume()          // 继续匹配后续规则(默认匹配成功即停止)

回复消息

s.reply("文本")     // 回复文本消息,返回 { message_id, error }
s.reply("文本1", "文本2")  // 多段回复

参数捕获

// 对于规则 "天气 [城市]"
s.param("城市")     // 通过名称获取捕获组
s.param(1)          // 通过索引获取第1个捕获组(从1开始)
s.get(1)            // param 的别名
s.getAllMatch()     // 获取所有匹配组(二维数组)

群管功能

s.kick(userId)          // 踢出群成员
s.unkick(userId)        // 取消踢出
s.ban(userId, duration) // 禁言,duration 为秒数
s.unban(userId)         // 解除禁言
s.recallMessage(messageId)  // 撤回消息

Bucket(name)

持久化键值存储,数据自动持久化到 BoltDB 或 Redis。

const bucket = Bucket("myapp");

// 基础读写
bucket.set("key", "value");
const value = bucket.get("key", "default_value");  // 支持默认值

// 类型自动转换
bucket.set("count", 100);        // 存储为数字
bucket.set("enabled", true);     // 存储为布尔值
bucket.set("data", { a: 1 });    // 存储为对象(自动 JSON 序列化)

// 监听变更
bucket.watch("key", (oldValue, newValue, key) => {
  console.log(`${key} changed: ${oldValue} -> ${newValue}`);
});

// 其他方法
bucket.keys();       // 获取所有键名(string[])
bucket.getAll();     // 获取所有键值(object)
bucket.delete("key"); // 删除键
bucket.empty();      // 清空 bucket
bucket.count();        // 获取键数量(number)

作用域说明:每个 Bucket 是独立的命名空间,不同插件建议使用不同的 Bucket 名称,避免键冲突。

plugin.Form 插件配置表单

SillyGirl 支持声明式插件配置。Node 插件使用顶层 new plugin.Form({...}) 注册并读取配置。plugin.Form.string() / plugin.Form.boolean() / plugin.Form.select() 等 helper 会生成配置表单结构;后台会在「插件设置」弹窗展示已注册的配置表单。

插件总开关读取顶部 status 注释元数据:新式写法为 // [status: true],旧式 @status true 也兼容。SillyGirl 核心会在消息、@cron 和 @on_start 三类入口执行前统一检查;值为 false 时跳过插件执行,未声明时默认启用。

定时任务页面中插件 @cron 行的“启用”开关使用独立的持久化任务状态,不修改脚本顶部的 status 注释。插件卡片的开启/关闭只修改 status;status=false 仍作为插件总开关阻止消息、Cron 和启动入口执行,而 Cron 行的开关只控制对应定时任务是否注册和执行。

/**
 * @title 配置示例
 * @rule raw ^配置测试$
 * @status true
 */

const { sender: s, plugin } = require("sillygirl");

const ConfigDB = new plugin.Form({
  host: plugin.Form.string()
    .title("服务地址")
    .description("例如 http://127.0.0.1:9090")
    .default("http://127.0.0.1:9090"),
  feature_enabled: plugin.Form.boolean()
    .title("启用此功能")
    .default(false),
  delTime: plugin.Form.number()
    .title("撤回时间")
    .description("0 表示不撤回")
    .default(0),
  mode: plugin.Form.select([
    { label: "普通", value: "normal" },
    { label: "快速", value: "fast" },
  ]).title("模式"),
});

ConfigDB.get();
if (!Object.keys(ConfigDB.userConfig).length) {
  s.reply("请先到后台「插件配置」完成配置");
} else {
  s.reply("当前 host: " + ConfigDB.userConfig.host);
}

new plugin.Form({...}) 的对象参数固定使用字段名到 plugin.Form.*() helper 的映射;不要传原生 JSON Schema。需要嵌套对象时只用 plugin.Form.object({...})。

支持的 helper:

helper 说明
plugin.Form.string() 字符串字段
plugin.Form.number() 数字字段
plugin.Form.integer() 整数字段
plugin.Form.boolean() 布尔开关
plugin.Form.array(item) 数组字段
plugin.Form.object(props) 对象字段
plugin.Form.select(options) 下拉/单选候选值,支持 [{ label, value }] 或 { value: label }
plugin.Form.defaults(fields) 读取表单默认值

支持的链式方法包括:

方法 说明
title(text) 字段标题
description(text) 字段说明
default(value) 默认值
options(values) 可选值
format(value) 字段格式,例如 password、textarea
widget(value) UI 组件提示,例如 radio、password、textarea
min(value) / max(value) 数字范围
required().err(message) 必填校验及错误信息
match(regex).err(message) 正则校验及错误信息

new plugin.Form(fields) 返回的配置实例属性和方法:

ConfigDB.jsonSchema   // 当前插件配置 schema
ConfigDB.userConfig   // 当前用户配置对象
ConfigDB.get()        // 从存储重新读取配置
ConfigDB.set()        // 保存 ConfigDB.userConfig
ConfigDB.set(obj)     // 保存指定配置对象

注意:配置 schema 会在插件顶层执行到 new plugin.Form({...}) 时注册。新插件首次安装后, 如果后台「插件配置」里还看不到它,先触发一次插件规则或把插件声明为 @on_start true。

Python 插件不用 new,也可以直接使用 plugin.Form.string() 这一套 helper:

from sillygirl import plugin

config = plugin.Form({
    "host": plugin.Form.string()
        .title("服务地址")
        .default("http://127.0.0.1:9090"),
    "feature_enabled": plugin.Form.boolean()
        .title("启用此功能")
        .default(False),
})


async def main():
    values = await config.get()
    print(values)

user.Form 普通用户表单

user.Form 单独定义 Home 普通用户提交的参数。数据按“用户 + 当前插件”隔离,以明文 JSON 存入 SillyGirl 存储桶,不执行加密或自动裁剪。

const { user } = require("sillygirl");

new user.Form({
  phone: user.Form.string()
    .title("手机号")
    .required().err("请输入手机号")
    .match(/^1[3-9]\d{9}$/).err("手机号格式错误"),
  remark: user.Form.string()
    .title("备注")
    .match(/^.{0,20}$/).err("备注最多20个字符")
    .default(""),
}).multiple(3).keyBy(["phone"]);

// test() 在服务端保存前运行,支持 async 和远程 API 校验。
new user.Form({
  token: user.Form.string()
    .required().err("请输入 Token")
    .test(async (value, ctx) => {
      const response = await fetch("https://api.example.com/verify", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ token: value, user: ctx.user.id }),
      });
      if (!response.ok) return "认证接口请求失败";
      const result = await response.json();
      return result.valid || "Token 未通过认证";
    }).err("Token 验证失败"),
});

const users = await user.getUserList({ withRecords: true });
const current = await user.getUser({ name: "ACCOUNT" });
from sillygirl import user

async def verify_token(value, ctx):
    import urllib.request
    # 可在这里请求远程 API;True 通过,False/字符串不通过。
    return True if value == "demo" else "Token 未通过认证"

user.Form({
    "phone": user.Form.string()
        .title("手机号")
        .required().err("请输入手机号")
        .match(r"^1[3-9]\d{9}$").err("手机号格式错误"),
    "token": user.Form.string()
        .test(verify_token).err("Token 验证失败"),
}).multiple(3).keyBy(["phone"])

users = await user.getUserList({"withRecords": True})
current = await user.getUser({"name": "ACCOUNT"})
  • .multiple(limit):允许每个普通用户保存多条记录,默认 1。
  • .keyBy(fields):相同键再次提交时更新原记录,避免重复;支持一个或多个字段。
  • user.getUserList():列出用户;传 { withRecords: true } 时包含当前插件的 records。
  • user.getUser(idOrName) / user.getUser({ id, name }):按 ID、用户名或昵称查询单个用户;重名时抛出 USER_AMBIGUOUS。
  • required/match/err 同时在前端和服务端执行,服务端结果为最终判定。
  • .test(callback) 只在服务端保存前执行,可传同步或异步函数;回调参数为 (value, ctx)。
  • ctx 包含 values(本次完整表单)、user(当前用户及绑定)、plugin 和 config(当前插件配置)。
  • 回调返回 true 表示通过;返回 false 使用紧跟的 .err(...);返回非空字符串时直接作为错误;抛出异常会返回远程验证异常。总执行超时为 10 秒。
  • 校验函数会脱离插件主流程单独执行,因此应写成自包含函数,只使用参数、运行时全局 API(如 Node fetch)或函数内部 import;不要依赖外层闭包变量。Python 推荐使用具名 def/async def。

container 容器入口

container 是容器面板统一入口:

const { container } = require("sillygirl");

const all = await container.getList();       // { smallcat, qinglong, daidai }
const qlList = await container.getList("qinglong");
const ql1 = new container.QingLong({ id: 1 });
const envs = await ql1.getEnvs();

container.getList() 返回后台已绑定的 smallcat / 青龙 / 呆呆容器数量和只读列表;container.QingLong、container.SmallCat、container.DaiDai 负责继续调用对应面板 API。

QingLong 内联客户端

container 顶层导出负责容器列表和面板客户端。container.QingLong 是青龙面板的脚本内联客户端。先在 Admin 面板左侧「青龙容器」中添加青龙面板,再在脚本里按页面表格编号创建实例。

const ql = new container.QingLong({ id: 1 });

构造参数必须是对象:

参数 类型 必填 说明
id number/string 是 青龙容器页面中的顺序编号,从 1 开始

实例基础属性:

ql.id       // 当前编号
ql.uuid     // 面板内部 UUID
ql.name     // 面板名称
ql.address  // 青龙地址

常用环境变量 API:

方法 对应青龙 Open API 参数 返回
getEnvs(options) GET /open/envs { searchValue?: string } 或搜索字符串 青龙返回的 data
getEnvById(id) GET /open/envs/:id 环境变量 ID 青龙返回的 data
createEnv(env) POST /open/envs 单个环境变量对象或数组 青龙返回的 data
updateEnv(env) PUT /open/envs 环境变量对象,需包含 id 青龙返回的 data
deleteEnvs(ids) DELETE /open/envs ID、ID 数组或逗号分隔字符串 青龙返回的 data
moveEnv(id, fromIndex, toIndex) PUT /open/envs/:id/move 环境变量 ID、原位置、新位置 青龙返回的 data
disableEnvs(ids) PUT /open/envs/disable ID、ID 数组或逗号分隔字符串 青龙返回的 data
enableEnvs(ids) PUT /open/envs/enable ID、ID 数组或逗号分隔字符串 青龙返回的 data
updateEnvNames(ids, name) PUT /open/envs/name ID 集合、新变量名 青龙返回的 data
systemNotify(title, content) PUT /open/system/notify 标题、内容 青龙返回的 data

通用调用:

ql.request(method, path, body, query);

request 会自动加青龙 Bearer token,并返回青龙原始响应对象,适合调用上表之外的 Open API。

示例:

const ql = new container.QingLong({ id: 1 });

const envs = ql.getEnvs({ searchValue: "JD_COOKIE" });
console.log("匹配数量", envs.length);

const created = ql.createEnv({
  name: "TEST_TOKEN",
  value: "123456",
  remarks: "脚本创建测试",
});

ql.disableEnvs([created[0].id]);
ql.enableEnvs([created[0].id]);
ql.deleteEnvs([created[0].id]);

注意:

  • new container.QingLong({ id: 1 }) 只接受对象参数,不支持 new container.QingLong(1)。
  • 编号按「青龙容器」页面当前列表顺序,从 1 开始。
  • 除 request 外,封装方法会在青龙业务 code != 200 或 HTTP 非 2xx 时抛出脚本异常。

SmallCat 内联客户端

container 顶层导出负责容器列表和面板客户端。container.SmallCat 是 smallcat 面板的脚本内联客户端。先在 Admin 面板左侧「smallcat」中添加地址和 api_auth,再在脚本里按页面表格编号创建实例。

const sc = new container.SmallCat({ id: 1 });

构造参数必须是对象:

参数 类型 必填 说明
id number/string 是 smallcat 页面中的顺序编号,从 1 开始

实例基础属性:

sc.id       // 当前编号
sc.uuid     // 面板内部 UUID
sc.name     // 面板名称
sc.address  // smallcat 地址

接口方法:

方法 对应 smallcat API 参数 返回
createQr(type) POST /api/qr/start 登录来源类型,如 1 原始 API 响应
createQr(options) POST /api/qr/start { type, openid?, proxyNodeId? } 等对象 原始 API 响应
checkQr(uuid) GET /api/qr/status?uuid=... 二维码 UUID 原始 API 响应
addUser(options) POST /api/accounts/add { code, displayName?, oauthState?, ... } 原始 API 响应
rescanUser(options) POST /api/accounts/rescan { openid, code, displayName?, oauthState? } 原始 API 响应
userList() GET /api/accounts 无 原始 API 响应
checkUsers(options) POST /api/accounts/status { openid },openid 可为字符串或数组 原始 API 响应
setUserRemark(options) POST /api/accounts/remark { openid, displayName } 原始 API 响应
setUserDisabled(options) POST /api/accounts/disable { openid, disabled } 原始 API 响应
deleteUser(options) POST /api/accounts/delete { openid } 原始 API 响应
proxyList() GET /api/proxies 无 原始 API 响应
testProxy(options) POST /api/proxies/test 代理节点对象 原始 API 响应
addProxy(options) POST /api/proxies/add 代理节点对象 原始 API 响应
deleteProxy(options) POST /api/proxies/delete { id } 原始 API 响应
creditBalance() GET /credits/balance 无 原始 API 响应
creditLedger(query?) GET /credits/ledger 查询对象或条数,如 { limit: 50 } / 50 原始 API 响应
getCode(options) POST /wx/code { openid, appid } 原始 API 响应
getSession(options) POST /wx/getsession { openid, appid } 原始 API 响应
refreshSession(options) POST /wx/refresh { openid, appid } 原始 API 响应
getUserInfo(options) POST /wx/getuserinfo { openid, appid } 原始 API 响应
getEncryptKey(options) POST /wx/encryptkey { openid, appid } 原始 API 响应
getPhoneNumber(options) POST /wx/getphonenumber { openid, appid } 原始 API 响应
cloud(options) POST /wx/cloud { openid, appid, function_name, data } 原始 API 响应
gateway(options) POST /wx/gateway { openid, appid, action, env } 或完整 domain 原始 API 响应
qrCodeAuth(options) POST /wx/qrcodeauth { openid, uuid } 原始 API 响应
oauth(options) POST /wx/oauth { openid, appid, redirect_uri, scope?, state?, component_appid? } 等 原始 API 响应
translateLink(options) POST /wx/translatelink { openid, link, scene? } 原始 API 响应
autoAuth(options) POST /wx/autoauth { openid } 原始 API 响应
appMsgExt(options) POST /wx/appmsgext { openid, article_url } 原始 API 响应
appMsgLike(options) POST /wx/appmsglike { openid, article_url } 原始 API 响应
request(method, path, body, query) 任意 smallcat API 自定义方法、路径、请求体、查询参数 原始 API 响应

smallcat 运行时不会改写 API 返回。脚本收到的就是 smallcat 原始 JSON,一般结构为:

{
  status: true,
  message: "成功",
  data: {}
}

示例:

const sc = new container.SmallCat({ id: 1 });

const qr = sc.createQr(1);
if (!qr.status) {
  s.reply("生成二维码失败:" + qr.message);
  return;
}

s.reply("扫码地址:" + qr.data.qrcodeUrl);

const checked = sc.checkQr(qr.data.uuid);
if (checked.data.state === "confirmed" && checked.data.wxCode) {
  const saved = sc.addUser({
    code: checked.data.wxCode,
    displayName: "备注",
  });
  s.reply(saved.message);
} else {
  s.reply("当前扫码状态:" + checked.data.state);
}

const users = sc.userList();
console.log(users.status, users.message, users.data && users.data.items);

const code = sc.getCode({
  openid: "用户 openid",
  appid: "wx1234567890abcdef",
});
console.log(code.status, code.message, code.data);

const session = sc.getSession({
  openid: "用户 openid",
  appid: "wx1234567890abcdef",
});
console.log(session.data && session.data.session);

const refreshed = sc.refreshSession({
  openid: "用户 openid",
  appid: "wx1234567890abcdef",
});
console.log(refreshed.data && refreshed.data.expireIn);

const userInfo = sc.getUserInfo({
  openid: "用户 openid",
  appid: "wx1234567890abcdef",
});
console.log(userInfo.status, userInfo.message, userInfo.data);

const phone = sc.getPhoneNumber({
  openid: "用户 openid",
  appid: "wx1234567890abcdef",
});
console.log(phone.status, phone.message, phone.data);

const oauth = sc.oauth({
  openid: "用户 openid",
  appid: "wx2f5d8f9715c59d10",
  redirect_uri: "https://example.com/callback",
  scope: "snsapi_userinfo",
  state: "STATE",
});
console.log(oauth.status, oauth.message, oauth.data);

const qrOAuth = sc.qrCodeAuth({
  openid: "用户 openid",
  uuid: "二维码 UUID",
});
console.log(qrOAuth.status, qrOAuth.message, qrOAuth.data);

注意:

  • new container.SmallCat({ id: 1 }) 只接受对象参数,不支持 new container.SmallCat(1)。
  • addUser 只接受对象参数,推荐写 sc.addUser({ code: "xxxxx", displayName: "备注" });重扫已有账号使用 rescanUser。
  • 只有网络失败、请求体编码失败、JSON 解析失败这类没有 smallcat 原始响应的情况,运行时才会返回 { status: false, message: "..." }。

DaiDai 内联客户端

container 顶层导出负责容器列表和面板客户端。container.DaiDai 是呆呆面板的脚本内联客户端。先在 Admin 面板左侧「呆呆面板」中添加地址、app_key、app_secret,再在脚本里按页面表格编号创建实例。

const dd = new container.DaiDai({ id: 1 });

构造参数必须是对象:

参数 类型 必填 说明
id number/string 是 呆呆面板页面中的顺序编号,从 1 开始

实例基础属性:

dd.id       // 当前编号
dd.uuid     // 面板内部 UUID
dd.name     // 面板名称
dd.address  // 呆呆面板地址

环境变量方法:

方法 对应呆呆面板 API 参数 返回
getEnvs(options) GET /api/envs { keyword?: string, page?: number, page_size?: number } 或搜索字符串 呆呆返回的 data
getEnvById(id) GET /api/envs/:id 环境变量 ID 呆呆返回的 data
createEnv(env) POST /api/envs 单个环境变量对象或数组 呆呆返回的 data
updateEnv(env) PUT /api/envs/:id 环境变量对象,建议包含 id 呆呆返回的 data
deleteEnv(id) DELETE /api/envs/:id 环境变量 ID 原始 API 响应
deleteEnvs(ids) DELETE /api/envs/batch ID、ID 数组或逗号分隔字符串 原始 API 响应
enableEnv(id) / disableEnv(id) PUT /api/envs/:id/enable / disable 环境变量 ID 呆呆返回的 data
enableEnvs(ids) / disableEnvs(ids) PUT /api/envs/batch/enable / disable ID 集合 原始 API 响应

任务方法:

方法 对应呆呆面板 API 参数 返回
getTasks(options) GET /api/tasks { keyword?: string, page?: number, page_size?: number } 或搜索字符串 呆呆返回的 data
getTaskById(id) GET /api/tasks/:id 任务 ID 呆呆返回的 data
createTask(task) POST /api/tasks 任务对象 呆呆返回的 data
updateTask(task) PUT /api/tasks/:id 任务对象,建议包含 id 呆呆返回的 data
deleteTask(id) DELETE /api/tasks/:id 任务 ID 原始 API 响应
runTask(id) / stopTask(id) PUT /api/tasks/:id/run / stop 任务 ID 原始 API 响应
enableTask(id) / disableTask(id) PUT /api/tasks/:id/enable / disable 任务 ID 原始 API 响应

通用调用:

dd.request(method, path, body, query);

request 会自动通过 /api/open-api/token 获取 Bearer token,并返回呆呆面板原始响应对象,适合调用上表之外的 API。

示例:

const dd = new container.DaiDai({ id: 1 });

const envs = dd.getEnvs({ keyword: "JD_COOKIE" });
console.log("匹配数量", envs.length);

const created = dd.createEnv({
  name: "TEST_TOKEN",
  value: "123456",
  remarks: "脚本创建测试",
});

dd.disableEnv(created.id);
dd.enableEnv(created.id);
dd.deleteEnv(created.id);

注意:

  • new container.DaiDai({ id: 1 }) 只接受对象参数,不支持 new container.DaiDai(1)。
  • 编号按「呆呆面板」页面当前列表顺序,从 1 开始。
  • 除 request 外,封装方法默认返回呆呆响应里的 data;HTTP 非 2xx 或 success: false 会抛出脚本异常。

Cron()

定时任务调度器,支持标准 Cron 表达式。

const task = Cron();

// 添加任务(6字段:秒 分 时 日 月 周)
const { id, error } = task.add("*/5 * * * * *", () => {
  console.log("每5秒执行一次");
});

// 添加任务(5字段:分 时 日 月 周,秒自动补0)
const { id, error } = task.add("0 9 * * *", () => {
  console.log("每天早上9点执行");
});

// 移除任务
task.remove(id);

注意:脚本头部写了 @cron 会自动同步到管理后台“定时任务”。

其他全局函数

sleep(ms)           // 同步阻塞睡眠(毫秒)
md5(str)            // MD5 哈希
uuid()              // 生成 UUID
running()           // 判断当前插件是否仍在运行(boolean)
fmt.Sprintf(format, ...args)  // 格式化字符串
fmt.Printf(format, ...args)   // 格式化输出
time.Now()          // 获取当前时间对象
time.Sleep(ms)      // 睡眠(毫秒)
time.Unix(sec)      // Unix 时间戳转时间对象
time.Parse(str, layout, locale)  // 解析时间字符串

规则匹配语法

规则(@rule)支持多种语法,框架会自动转换为正则表达式进行匹配。

基础规则

/**
 * @rule raw ^你好$      // 原始正则,完全匹配"你好"
 * @rule raw ^/help$     // 原始正则,匹配 "/help"
 * @rule 你好            // 自动转换为 ^你好$,完全匹配
 * @rule ^天气           // 以"天气"开头
 * @rule 帮助$           // 以"帮助"结尾
 */

参数捕获

/**
 * @rule 天气 [城市]              // 匹配"天气 北京",捕获"北京"
 * @rule [操作:登录,注册,退出]     // 匹配"登录"、"注册"或"退出"
 * @rule 查询 ?                   // ? 匹配任意非空白字符
 * @rule 搜索 *                   // * 匹配任意内容(包括空白)
 */

可选参数

/**
 * @rule 天气 [城市?]             // ? 表示可选,匹配"天气"或"天气 北京"
 */

优先级与冲突

当多条规则可能同时匹配一条消息时,框架按以下顺序决定执行:

  1. 监听规则(s.listen 注册的)优先于普通规则
  2. 高 priority 优先于低 priority
  3. 同一优先级下,先加载的插件优先

使用 s.resume() 可以让当前插件执行完毕后继续匹配后续规则:

/**
 * @title 日志中间件
 * @rule *
 * @priority 999
 */

console.log("收到消息:", s.getMsg());
s.resume();  // 继续让其他插件处理

消息监听与会话

s.listen() 是实现对话式交互的核心 API,支持等待用户后续输入并按规则匹配。

基础用法

/**
 * @title 注册流程
 * @rule raw ^注册$
 */

s.reply("请输入你的用户名:");
const result = s.listen({
  rules: ["raw ^(.+)$"],  // 捕获任意输入
  timeout: 30000,          // 30秒超时
  handle: (s2) => {
    const username = s2.param(1);
    s2.reply(`用户名 "${username}" 注册成功!`);
  },
});

if (!result) {
  s.reply("注册超时,请重试。");
}

监听选项

s.listen({
  rules: ["规则1", "规则2"],        // 匹配规则数组
  timeout: 10000,                   // 超时时间(毫秒)
  handle: (s2) => { ... },          // 匹配后的回调函数
  private: true,                    // 允许在私聊中触发
  group: true,                      // 允许在群聊中触发
  require_admin: false,             // 是否要求管理员权限
  allow_platforms: ["qq"],          // 限制平台
  prohibit_platforms: ["web"],      // 禁止平台
  allow_users: ["12345"],           // 仅允许指定用户
  allow_groups: ["67890"],          // 仅允许指定群组
  user_id: s.getUserId(),           // 仅监听当前用户
  chat_id: s.getChatId(),           // 仅监听当前群组
});

持久化监听

添加 "persistent" 参数可创建长期有效的监听器:

/**
 * @title 关键词监控
 * @rule raw ^启动监控$
 */

s.listen({
  rules: ["raw ^(.+)$"],
  timeout: 0,           // 0 表示永不超时
  handle: (s2) => {
    console.log("监控到消息:", s2.getMsg());
    // 返回空字符串或 undefined 会继续监听
  },
}, "persistent");

s.reply("监控已启动");

HoldOn 与 GoAgain

/**
 * @title 循环输入
 * @rule raw ^开始$
 */

function ask() {
  s.reply("请输入内容(输入'结束'停止):");
  const r = s.listen({
    rules: ["raw ^(.+)$"],
    timeout: 30000,
    handle: (s2) => {
      if (s2.param(1) === "结束") {
        s2.reply("已结束");
        return;
      }
      s2.reply("你输入了:" + s2.param(1));
      return s2.holdOn("开始");  // 重新触发当前插件
    },
  });
}
ask();

定时任务

定时任务可以在脚本头部声明 @cron,也可以在管理后台的“定时任务”中创建。

/**
 * @title 每日提醒
 * @cron 0 9 * * *
 */

s.reply("该起床啦!今天是 " + time.Now().Format("2006-01-02"));

Cron 表达式格式(5字段或6字段):

秒(可选) 分 时 日 月 周
字段 范围 特殊字符
秒 0-59 , - * /
分 0-59 , - * /
时 0-23 , - * /
日 1-31 , - * /
月 1-12 , - * /
周 0-6(0=周日) , - * /

示例:

表达式 说明
*/5 * * * * 每5分钟
0 */1 * * * 每小时
0 9 * * 1-5 工作日早上9点
0 0 1 * * 每月1号零点
*/30 * * * * * 每30秒

完整示例

示例 1:记忆名字

/**
 * @title 记忆名字
 * @rule raw ^我是谁$
 * @rule 我是[姓名]
 * @version v1.0.1
 * @author cdle
 */

const user = Bucket("user_names");
const name = s.param("姓名");

if (!name) {
  const stored = user.get(s.getUserId());
  if (stored) {
    s.reply(`你是 ${stored}`);
  } else {
    s.reply("我还不知道你是谁,告诉我吧:我是[你的名字]");
  }
} else {
  user.set(s.getUserId(), name);
  s.reply(`好的,我记住你了,${name}!`);
}

示例 2:倒计时提醒

/**
 * @title 倒计时
 * @rule 倒计时 [分钟:1,2,3,5,10] 分钟
 * @version v1.0.1
 */

const minutes = parseInt(s.param(1));
s.reply(`好的,${minutes}分钟后提醒你。`);

// 使用 Cron 不太适合一次性延时,这里用 sleep
// 注意:sleep 会阻塞,长时间任务建议用 cron 或其他方式
go(() => {
  sleep(minutes * 60 * 1000);
  s.reply("⏰ 时间到了!");
});

注:实际开发中长时间后台任务建议使用管理后台的“定时任务”或外部调度。

调试技巧

1. 使用终端模式

./sillyGirl -t

终端模式是开发插件的最快方式,修改插件后立即生效,无需重启。

2. 开启调试模式

在 Admin 面板将 sillyGirl.debug 设为 true,或在插件中:

console.log("调试信息:", someVariable);
console.debug("详细调试:", detailedInfo);
console.error("错误:", err);

3. 查看插件状态

在 Admin 面板的"插件"页面,可以查看:

  • 插件加载状态
  • 规则列表
  • 错误日志
  • 性能统计

4. 安全执行

插件运行异常时框架会自动捕获 panic,不会影响其他插件或核心服务。你可以在 Admin 面板查看具体的错误堆栈。

5. 模块复用

将公共逻辑抽取为 [module: true] 插件,使用方必须在 depe 中写明同运行时、同目录的模块文件名:

// [title: 工具模块]
// [module: true]

module.exports = {
  formatTime(value) {
    return String(value);
  },
};
// [title: 使用工具模块]
// [depe: ["./utils.js"]]

const utils = require("./utils.js");

模块依赖只根据 depe 判断,不扫描 require / import。路径必须指向同一发布者目录的平铺文件:NodeJS 仅接受 ./xxx.js,Python 仅接受 ./xxx.py;不接受 ../、绝对路径、嵌套目录或跨作者引用。安装普通插件时会先递归安装同作者的这些模块;模块被引用时禁止卸载;循环依赖会中止安装。

插件卡片执行“安装/升级”前会在同一个确认框列出当前插件的包依赖和模块依赖。确认后,后端按以下顺序处理:递归解析模块、为每个待安装脚本安装其 pnpm/pipx 包依赖、写入并加载模块,最后写入并加载目标插件。任一步失败都会停止后续安装,不会先加载缺少运行依赖的脚本。

旧目录迁移

旧版 /data/plugins/demo.js 和 /data/plugins/demo.py 仍可直接运行,不要求启动时搬迁。需要改成作者目录时,停用对应插件后把文件移动到 /data/plugins/<发布者>/,再重启或等待 watcher 重新加载;package.json、pnpm-lock.yaml、node_modules 和 python_packages 保持在 /data/plugins 顶层。不要把 sharedTools.js 放到顶层作为跨作者模块,模块应与引用它的插件位于同一个作者目录。迁移后重新保存相关 Cron 任务,使命令持久化为带发布者的路径。

迁移旧插件时不要依赖外部兼容脚本;插件应单文件运行,只从 sillygirl 导入 sender、Sender、Bucket、plugin、user、container、utils 等现有能力。