example: 新增 acp(ACP 代理通信)、vanblog(VanBlog 博客管理) 插件; 多插件工具调用检测与清理改进; weather 移除 onRemove/文本记忆; plugindev 精简

This commit is contained in:
JianFeeeee
2026-08-15 09:03:05 +08:00
parent b6e30f9279
commit cca9fdce9c
30 changed files with 2408 additions and 228 deletions

View File

@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"crypto/hmac"
"encoding/base64"
"encoding/binary"
"encoding/json"
@ -32,7 +33,7 @@ type ForwardRule struct {
}
func rconSend(host string, port int, password, cmd string) error {
addr := fmt.Sprintf("%s:%d", host, port)
addr := net.JoinHostPort(host, strconv.Itoa(port))
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return fmt.Errorf("rcon dial: %w", err)
@ -66,7 +67,7 @@ func rconPacket(id, typ int32, body string) []byte {
b = append(b, 0) // null terminator
b = append(b, 0) // padding
length := 4 + 4 + len(b)
pkt := make([]byte, 4+len(b))
pkt := make([]byte, 12+len(b))
binary.LittleEndian.PutUint32(pkt, uint32(length))
binary.LittleEndian.PutUint32(pkt[4:], uint32(id))
binary.LittleEndian.PutUint32(pkt[8:], uint32(typ))
@ -90,7 +91,8 @@ type Plugin struct {
napcatURL string
remoteDir string
filesDir string
adminID int64
webhookToken string
adminIDs []int64
botID int64
botNickname string
dmPolicy string
@ -119,7 +121,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号收到其消息时标记【重要!老大消息】", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号列表,逗号分隔。收到其消息时标记【重要!老大消息】", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
@ -127,13 +129,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{Key: "forward_rules", Default: "[]", Type: "string", DisplayName: "转发规则", Description: "JSON 数组,每项 {group_id,host,port,password,template}。匹配的群消息通过 RCON 转发到 Minecraft。template 支持 {nickname} {message} 占位", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "files_dir", Default: "/home/newqqagent/agentfs/merged/qq_files", Type: "string", DisplayName: "文件存储目录", Description: "从QQ接收的文件保存目录CQ file/image 自动下载到此目录)", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录主机路径。发文件时文件会复制到此目录NapCat内部映射为/app/files/", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "webhook_token", Default: "", Type: "string", DisplayName: "Webhook 令牌", Description: "NapCat 上报请求头 X-Webhook-Token 校验值,留空则不校验", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "agentfs_dir", Default: "/home/newqqagent/agentfs/merged", Type: "string", DisplayName: "AgentFS目录", Description: "文件读写的工作目录read_document/video_download 等工具的默认工作目录", Category: "qq"})
settings := s.Settings()
p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580")
p.webhookToken = getSetting[string](settings, "webhook_token", "")
p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/")
p.adminID = getSetting[int64](settings, "admin", 0)
p.adminIDs = parseIDList(getSetting[string](settings, "admin", ""))
p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open"))
p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open"))
p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", ""))
@ -279,7 +283,7 @@ type 枚举: text文字/ voice语音转文字后发送/ image
NoMemory: false,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号与group_id二选一"},
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号与group_id二选一"},
"group_id": map[string]interface{}{"type": "integer", "description": "群号与user_id二选一"},
},
},
@ -312,19 +316,19 @@ type 枚举: text文字/ voice语音转文字后发送/ image
Name: tp + "group_manage", Description: "QQ群综合管理。通过command参数执行各种操作leave退群, kick踢人, ban禁言, unban解禁, rename改名, mute-all全员禁言, set-card设名片, set-admin设管理, set-title设头衔, member-list成员列表, group-info群详情, member-info成员详情, at-all-remain@全体剩余, msg-history消息历史, recall撤回, pin-msg精华, list-files文件列表, pending-requests待处理请求, folder-create创建文件夹。注意leave/kick/ban/unban/mute-all/set-admin等破坏性操作必须先请示管理员确认后再执行。",
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"command": map[string]interface{}{"type": "string", "description": "操作命令"},
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号踢人/禁言/设名片等需要)"},
"command": map[string]interface{}{"type": "string", "description": "操作命令"},
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号踢人/禁言/设名片等需要)"},
"message_id": map[string]interface{}{"type": "integer", "description": "消息ID撤回/精华)"},
"name": map[string]interface{}{"type": "string", "description": "群名称rename或文件夹名folder-create"},
"card": map[string]interface{}{"type": "string", "description": "群名片set-card"},
"title": map[string]interface{}{"type": "string", "description": "群头衔set-title"},
"enable": map[string]interface{}{"type": "boolean", "description": "启用/禁用set-admin/mute-all"},
"minutes": map[string]interface{}{"type": "integer", "description": "禁言分钟数ban0=解禁"},
"count": map[string]interface{}{"type": "integer", "description": "消息条数msg-history默认10"},
"folder_id": map[string]interface{}{"type": "string", "description": "文件夹IDlist-files"},
"name": map[string]interface{}{"type": "string", "description": "群名称rename或文件夹名folder-create"},
"card": map[string]interface{}{"type": "string", "description": "群名片set-card"},
"title": map[string]interface{}{"type": "string", "description": "群头衔set-title"},
"enable": map[string]interface{}{"type": "boolean", "description": "启用/禁用set-admin/mute-all"},
"minutes": map[string]interface{}{"type": "integer", "description": "禁言分钟数ban0=解禁"},
"count": map[string]interface{}{"type": "integer", "description": "消息条数msg-history默认10"},
"folder_id": map[string]interface{}{"type": "string", "description": "文件夹IDlist-files"},
"reject_add": map[string]interface{}{"type": "boolean", "description": "踢出时拒绝加群kick"},
"confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 leave/kick/ban/unban/rename/mute-all/set-card/set-admin/set-title/recall/pin-msg/folder-create 时必须传 true"},
"confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 leave/kick/ban/unban/rename/mute-all/set-card/set-admin/set-title/recall/pin-msg/folder-create 时必须传 true"},
},
},
NoMemory: true,
@ -334,12 +338,12 @@ type 枚举: text文字/ voice语音转文字后发送/ image
Name: tp + "friend_action", Description: "QQ好友管理delete删除好友, block拉黑删好友+从所有群踢出+拒绝加群), approve-friend同意好友请求, reject-friend拒绝好友请求, list-friends列出好友。注意涉及删除/拉黑的操作必须请示管理员确认后再执行,未经授权不可操作。",
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"command": map[string]interface{}{"type": "string", "description": "操作: delete|block|approve-friend|reject-friend|list-friends"},
"user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"},
"flag": map[string]interface{}{"type": "string", "description": "好友请求flagapprove-friend/reject-friend需要"},
"remark": map[string]interface{}{"type": "string", "description": "好友备注approve-friend可选"},
"command": map[string]interface{}{"type": "string", "description": "操作: delete|block|approve-friend|reject-friend|list-friends"},
"user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"},
"flag": map[string]interface{}{"type": "string", "description": "好友请求flagapprove-friend/reject-friend需要"},
"remark": map[string]interface{}{"type": "string", "description": "好友备注approve-friend可选"},
"group_id": map[string]interface{}{"type": "integer", "description": "仅从指定群踢出block配合"},
"confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"},
"confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"},
},
},
NoMemory: true,
@ -352,12 +356,12 @@ type 枚举: text文字/ voice语音转文字后发送/ image
Cleaner: cleaner,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
"command": map[string]interface{}{"type": "string", "description": "操作: list|search|download"},
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
"command": map[string]interface{}{"type": "string", "description": "操作: list|search|download"},
"folder_id": map[string]interface{}{"type": "string", "description": "文件夹IDlist指定文件夹"},
"keyword": map[string]interface{}{"type": "string", "description": "搜索关键词search"},
"file_id": map[string]interface{}{"type": "string", "description": "文件IDdownload"},
"filename": map[string]interface{}{"type": "string", "description": "保存文件名download可选"},
"keyword": map[string]interface{}{"type": "string", "description": "搜索关键词search"},
"file_id": map[string]interface{}{"type": "string", "description": "文件IDdownload"},
"filename": map[string]interface{}{"type": "string", "description": "保存文件名download可选"},
},
},
}, p.handleGetGroupFiles)
@ -454,6 +458,15 @@ type 枚举: text文字/ voice语音转文字后发送/ image
}
func (p *Plugin) Stop() error {
p.typingMu.Lock()
for _, st := range p.typingMap {
select {
case <-st.stopCh:
default:
close(st.stopCh)
}
}
p.typingMu.Unlock()
if p.srv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -475,8 +488,8 @@ func (p *Plugin) fetchBotInfo() {
return
}
var info struct {
Status string `json:"status"`
Data *struct {
Status string `json:"status"`
Data *struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
} `json:"data"`
@ -563,6 +576,29 @@ func parseIDSet(raw string) map[int64]struct{} {
return out
}
func parseIDList(raw string) []int64 {
var out []int64
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if n, err := strconv.ParseInt(part, 10, 64); err == nil && n > 0 {
out = append(out, n)
}
}
return out
}
func (p *Plugin) isAdmin(userID int64) bool {
for _, id := range p.adminIDs {
if id == userID {
return true
}
}
return false
}
// isAtBot checks if the message contains an @-mention of the bot.
func (p *Plugin) isAtBot(msg interface{}) bool {
segments, ok := msg.([]interface{})
@ -616,7 +652,7 @@ func (p *Plugin) isGroupAllowed(groupID int64) bool {
case "allowlist":
_, ok := p.groupAllowFrom[groupID]
return ok
default:
default:
return true
}
}
@ -628,11 +664,6 @@ func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error {
return nil
}
tc := &ctx.ToolCalls[0]
if tc.Name == p.name+"_send_file" || tc.Name == p.name+"_upload_group_file" {
if file, ok := tc.Arguments["file"].(string); ok {
tc.Arguments["file"] = p.sensitiveFilter(file)
}
}
if tc.Name == p.name+"_group_manage" {
cmd, _ := tc.Arguments["command"].(string)
if requiresConfirmGroupCommand(cmd) {
@ -679,6 +710,10 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
if p.webhookToken != "" && !hmac.Equal([]byte(r.Header.Get("X-Webhook-Token")), []byte(p.webhookToken)) {
w.WriteHeader(http.StatusUnauthorized)
return
}
body, _ := io.ReadAll(r.Body)
var evt struct {
PostType string `json:"post_type"`
@ -745,10 +780,22 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
} else {
interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。使用%s回复对方", nickname, evt.MessageID, tp, evt.MessageID, outputTool)
}
if p.adminID > 0 && evt.UserID == p.adminID {
if p.isAdmin(evt.UserID) {
interrupt = "【重要!老大消息】" + interrupt
}
if text != "" {
text = stripCQRe.ReplaceAllString(text, "")
text = strings.TrimSpace(text)
}
if text == "" {
w.WriteHeader(http.StatusOK)
return
}
if highRiskRe.MatchString(text) {
interrupt = "【⚠️ 高危信息,谨慎处理】" + interrupt
}
if evt.MessageType == "group" && p.sdk != nil {
rulesRaw := getSetting[string](p.sdk.Settings(), "forward_rules", "[]")
var rules []ForwardRule
@ -943,7 +990,7 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
payload, _ := args["payload"].(string)
rawType, _ := args["type"].(string)
meta, _ := args["meta"].(string)
log.Printf("[qq] handleChannelOutput payload=%q type=%s meta=%s", payload, rawType, meta)
log.Printf("[qq] handleChannelOutput type=%s payload_len=%d meta=%s", rawType, len(payload), meta)
if payload == "" || rawType == "" {
return nil, fmt.Errorf("payload 和 type 参数不能为空")
}
@ -1108,7 +1155,7 @@ func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error
if name == "" {
name = filepath.Base(filePath)
}
name = p.sensitiveFilter(name)
name = sanitizeFilename(name)
asImage, _ := args["as_image"].(bool)
// copy to remote dir for NapCat container access
@ -1312,7 +1359,7 @@ func (p *Plugin) handleResolveNickname(args map[string]interface{}) (interface{}
}
keyword = strings.ToLower(keyword)
gid, groupErr := convInt64(args["group_id"])
gid, groupErr := convInt64(args["group_id"])
if groupErr == nil {
v, err := p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid})
if err != nil {
@ -1526,20 +1573,11 @@ func (p *Plugin) handleFriendAction(args map[string]interface{}) (interface{}, e
// delete friend
p.napcat("delete_friend", map[string]interface{}{"user_id": uid})
// kick from groups
if gid, err := convInt64(args["group_id"]); err == nil {
p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true})
} else {
grps, _ := p.napcat("get_group_list", map[string]interface{}{})
if list, ok := grps.([]interface{}); ok {
for _, g := range list {
if m, ok := g.(map[string]interface{}); ok {
if gid, ok := m["group_id"].(float64); ok {
p.napcat("set_group_kick", map[string]interface{}{"group_id": int64(gid), "user_id": uid, "reject_add_request": true})
}
}
}
}
gid, err := convInt64(args["group_id"])
if err != nil {
return map[string]interface{}{"isError": true, "content": "block 必须提供 group_id插件不会自动遍历所有群踢人"}, nil
}
p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true})
return `{"status":"ok","message":"blocked"}`, nil
case "approve-friend":
flag, _ := args["flag"].(string)
@ -1574,6 +1612,7 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{},
if filename == "" {
filename = fmt.Sprintf("group_file_%s", fileID)
}
filename = sanitizeFilename(filename)
// get download URL
resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": gid, "file_id": fileID})
if err != nil {
@ -1701,7 +1740,7 @@ func (p *Plugin) handleUploadGroupFile(args map[string]interface{}) (interface{}
if name == "" {
name = filepath.Base(filePath)
}
name = p.sensitiveFilter(name)
name = sanitizeFilename(name)
data, err := os.ReadFile(filePath)
if err != nil {
@ -2000,8 +2039,8 @@ func (p *Plugin) handleReadDocument(args map[string]interface{}) (interface{}, e
result += fmt.Sprintf("\n\n...(内容过长,仅显示前 20000 字符,共 %d 字符)", origLen)
}
return map[string]interface{}{
"content": result,
"file": path,
"content": result,
"file": path,
"truncated": truncated,
}, nil
}
@ -2218,6 +2257,8 @@ func rawString(v interface{}) (string, bool) {
var reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*\S+`)
var reSKKey = regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`)
var reInternalIP = regexp.MustCompile(`\b(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b`)
var stripCQRe = regexp.MustCompile(`\[CQ:[^\]]*\]|\[mirai:[^\]]*\]`)
var highRiskRe = regexp.MustCompile(`(假如你是|你现在是|请你(扮演|化作|假装|成为)|扮演(一个|一下)|把你自己(想象|当成)|你的(人设|设定)是|穿越(到|回)|你是从.{0,10}(来|穿越)|帮我编(个|一个)故事|写(个|一个)故事让|故事(中|里)的|觉得(这个|这台|这家)?(机器人|AI|助手|ai).{0,8}(怎么样|如何|好不好|评价)|评价(下|一下)?(这个|这台|这家)?(机器人|AI|助手|ai|gpt)|忽略(之前|所有)?(指令|规则|限制|禁令)|解除.{0,6}(限制|规则|约束)|越狱|绕过.{0,6}(限制|审查)|不用(遵守|管)(任何)?(规则|限制|指令)|无视(所有)?(规则|指令)|你是(一个|一只)自由的)`)
func (p *Plugin) sensitiveFilter(text string) string {
if p.remoteDir != "" {
@ -2260,9 +2301,3 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
groupPolicy: "open",
}, nil
}