Files
HomeAgent/third_party/homeagent-sdk/example/qq/plugin.go
root 50e5dec745 feat: converge dynamic plugins onto canonical homeagent-sdk
- Separate built-in plugin interface from external plugin interface
- Route dynamic plugin loading through homeagent-sdk/sdk using reflection
- Turn internal/sdk into an enhanced wrapper over canonical SDK types
- Vendor SDK repo snapshot under third_party/homeagent-sdk for stable builds
- Keep internal constructors/adapters for memory, knowledge, llm, settings
- Align dynamic QQ loading with canonical SDK chain
2026-07-06 19:27:51 +08:00

1052 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type SavedMessage struct {
LocalID int64 `json:"local_id"`
MessageID int64 `json:"message_id"`
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
GroupID int64 `json:"group_id,omitempty"`
GroupName string `json:"group_name,omitempty"`
MessageType string `json:"message_type"`
Text string `json:"text"`
Time int64 `json:"time"`
}
const maxMessages = 2000
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
messages []*SavedMessage
nextID int64
listenAddr string
napcatURL string
remoteDir string
filesDir string
adminID int64
botID int64
botNickname string
dmPolicy string
groupPolicy string
allowFrom map[int64]struct{}
groupAllowFrom map[int64]struct{}
srv *http.Server
groupNameCache map[int64]string
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{
name: name,
nextID: 1,
messages: make([]*SavedMessage, 0, maxMessages),
groupNameCache: make(map[int64]string),
allowFrom: make(map[int64]struct{}),
groupAllowFrom: make(map[int64]struct{}),
dmPolicy: "open",
groupPolicy: "open",
}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.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: "plugin.qq.admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号,收到其消息时标记【重要!老大消息】", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.group_allow_from", Default: "", Type: "string", DisplayName: "群聊白名单", Description: "允许接入的群号列表,逗号分隔", Category: "qq"})
settings := s.Settings()
p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580")
p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/")
p.adminID = getSetting[int64](settings, "admin", 0)
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", ""))
p.groupAllowFrom = parseIDSet(getSetting[string](settings, "group_allow_from", ""))
// 从 NapCat 自动获取 Bot 身份
p.fetchBotInfo()
tp := p.name + "_"
botInfo := ""
if p.botNickname != "" {
botInfo = fmt.Sprintf("你的QQ昵称是%s", p.botNickname)
if p.botID > 0 {
botInfo += fmt.Sprintf("QQ号是%d", p.botID)
}
botInfo += "。"
}
// ---- 消息 ----
p.regTool(s, tp+"get_message", botInfo+"获取通过中断通知的QQ消息正文。local_id来自中断文字中的id号。", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"local_id": map[string]interface{}{"type": "integer", "description": "本地消息ID"},
}, "required": []string{"local_id"},
}, p.handleGetMessage)
p.regTool(s, tp+"send_private_msg", "发送QQ私聊消息", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"},
"message": map[string]interface{}{"type": "string", "description": "消息内容"},
}, "required": []string{"user_id", "message"},
}, p.handleSendPrivate)
p.regTool(s, tp+"send_group_msg", "发送QQ群消息", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "目标群号"},
"message": map[string]interface{}{"type": "string", "description": "消息内容"},
}, "required": []string{"group_id", "message"},
}, p.handleSendGroup)
p.regTool(s, tp+"send_file", "发送文件/图片到QQ私聊或群聊。文件先复制到remote目录供NapCat容器访问。", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "目标群号与user_id二选一"},
"user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号与group_id二选一"},
"file": map[string]interface{}{"type": "string", "description": "本地文件路径"},
"name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"},
"as_image": map[string]interface{}{"type": "boolean", "description": "作为图片发送true还是作为文件false默认"},
},
}, p.handleSendFile)
p.regTool(s, tp+"get_history", "获取QQ群聊/私聊历史消息,用于回顾之前的对话上下文", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号与user_id二选一"},
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号私聊历史与group_id二选一"},
"count": map[string]interface{}{"type": "integer", "description": "拉取条数默认10"},
}, "required": []string{},
}, p.handleGetHistory)
// ---- 查询 ----
p.regTool(s, tp+"get_groups", "获取QQ群列表可按关键词搜索群名", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"},
},
}, p.handleGetGroups)
p.regTool(s, tp+"get_friends", "获取QQ好友列表可按昵称/备注关键词搜索", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"},
},
}, p.handleGetFriends)
p.regTool(s, tp+"resolve_name", "将QQ号或群号解析为可读的用户昵称或群名称", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号与group_id二选一"},
"group_id": map[string]interface{}{"type": "integer", "description": "群号与user_id二选一"},
},
}, p.handleResolveName)
p.regTool(s, tp+"get_group_member_info", "获取QQ群成员详细信息", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号"},
}, "required": []string{"group_id", "user_id"},
}, p.handleGetGroupMemberInfo)
// ---- 群管理 ----
p.regTool(s, tp+"group_manage", "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等破坏性操作必须先请示管理员确认后再执行。", 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号踢人/禁言/设名片等需要)"},
"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"},
"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"},
},
}, p.handleGroupManage)
p.regTool(s, tp+"friend_action", "QQ好友管理delete删除好友, block拉黑删好友+从所有群踢出+拒绝加群), approve-friend同意好友请求, reject-friend拒绝好友请求, list-friends列出好友。注意涉及删除/拉黑的操作必须请示管理员确认后再执行,未经授权不可操作。", 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可选"},
"group_id": map[string]interface{}{"type": "integer", "description": "仅从指定群踢出block配合"},
"confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"},
},
}, p.handleFriendAction)
// ---- 文件 ----
p.regTool(s, tp+"get_group_files", "查询群文件列表、搜索文件、下载文件到本地。操作: list列出, search搜索, download下载", 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"},
"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可选"},
},
}, p.handleGetGroupFiles)
p.regTool(s, tp+"upload_group_file", "上传文件到QQ群通过base64编码发送同时出现在群消息和群文件柜", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "目标群号"},
"file": map[string]interface{}{"type": "string", "description": "本地文件路径"},
"name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"},
}, "required": []string{"group_id", "file"},
}, p.handleUploadGroupFile)
// ---- 附加 ----
p.regTool(s, tp+"send_like", "给QQ好友点赞/戳一戳", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"},
"times": map[string]interface{}{"type": "integer", "description": "点赞次数1-20默认1"},
}, "required": []string{"user_id"},
}, p.handleSendLike)
p.regTool(s, tp+"ocr_image", "对QQ图片进行文字识别调用NapCat OCR / 本地Tesseract", map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"image": map[string]interface{}{"type": "string", "description": "图片路径本地路径或URL"},
"lang": map[string]interface{}{"type": "string", "description": "语言chi_sim+eng默认, eng, chi_sim, chi_tra"},
}, "required": []string{"image"},
}, p.handleOcrImage)
s.RegisterStageOwnTools(sdk.StageBeforeToolcall, p.beforeOwnToolcall)
// ---- HTTP server for NapCat webhook ----
mux := http.NewServeMux()
mux.HandleFunc("/", p.handleWebhook)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
})
p.srv = &http.Server{Addr: p.listenAddr, Handler: mux}
go func() {
log.Printf("[qq] webhook %s napcat=%s", p.listenAddr, p.napcatURL)
if err := p.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[qq] http: %v", err)
}
}()
log.Printf("[qq] plugin started: %s (%d tools)", p.name, 14)
return nil
}
func (p *Plugin) Stop() error {
if p.srv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
p.srv.Shutdown(ctx)
}
return nil
}
func (p *Plugin) regTool(s *sdk.PluginSDK, name, desc string, params map[string]interface{}, handler sdk.ToolHandler) {
s.RegisterTool(name, sdk.ToolDef{Name: name, Description: desc, Parameters: params}, handler)
}
// ======== Bot Identity ========
func (p *Plugin) fetchBotInfo() {
resp, err := p.rawNapcat("get_login_info", nil)
if err != nil {
log.Printf("[qq] fetch login info: %v", err)
return
}
var info struct {
Status string `json:"status"`
Data *struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(resp), &info); err != nil {
log.Printf("[qq] parse login info: %v", err)
return
}
if info.Data != nil {
p.botID = info.Data.UserID
p.botNickname = info.Data.Nickname
log.Printf("[qq] bot identity: %s (%d)", p.botNickname, p.botID)
}
}
// rawNapcat sends a request to NapCat and returns raw JSON string.
func (p *Plugin) rawNapcat(action string, params map[string]interface{}) (string, error) {
data, _ := json.Marshal(params)
url := fmt.Sprintf("%s/%s", p.napcatURL, action)
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("napcat %s: %w", action, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body), nil
}
// getSetting reads a setting from the SDK; returns fallback if unset or wrong type.
func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T {
v, err := s.Get(key)
if err != nil || v == nil {
return fallback
}
switch any(fallback).(type) {
case string:
if str, ok := v.(string); ok {
return any(str).(T)
}
case int64:
switch val := v.(type) {
case float64:
return any(int64(val)).(T)
case string:
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
return any(n).(T)
}
}
case float64:
switch val := v.(type) {
case float64:
return any(val).(T)
case string:
if n, err := strconv.ParseFloat(val, 64); err == nil {
return any(n).(T)
}
}
}
return fallback
}
func normalizePolicy(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "allowlist":
return "allowlist"
case "disabled":
return "disabled"
default:
return "open"
}
}
func parseIDSet(raw string) map[int64]struct{} {
out := make(map[int64]struct{})
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if n, err := strconv.ParseInt(part, 10, 64); err == nil {
out[n] = struct{}{}
}
}
return out
}
// isAtBot checks if the message contains an @-mention of the bot.
func (p *Plugin) isAtBot(msg interface{}) bool {
segments, ok := msg.([]interface{})
if !ok {
return false
}
botIDStr := strconv.FormatInt(p.botID, 10)
for _, seg := range segments {
s, ok := seg.(map[string]interface{})
if !ok {
continue
}
if s["type"] == "at" {
if data, ok := s["data"].(map[string]interface{}); ok {
if qq, ok := data["qq"]; ok {
switch v := qq.(type) {
case string:
if v == botIDStr || v == "all" {
return true
}
case float64:
if int64(v) == p.botID {
return true
}
}
}
}
}
}
return false
}
// ======== Webhook ========
func (p *Plugin) isDMAllowed(userID int64) bool {
switch p.dmPolicy {
case "disabled":
return false
case "allowlist":
_, ok := p.allowFrom[userID]
return ok
default:
return true
}
}
func (p *Plugin) isGroupAllowed(groupID int64) bool {
switch p.groupPolicy {
case "disabled":
return false
case "allowlist":
_, ok := p.groupAllowFrom[groupID]
return ok
default:
return true
}
}
func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error {
ctx.Lock()
defer ctx.Unlock()
if len(ctx.ToolCalls) == 0 {
return nil
}
tc := &ctx.ToolCalls[0]
switch tc.Name {
case p.name + "_send_private_msg", p.name + "_send_group_msg":
if msg, ok := tc.Arguments["message"].(string); ok {
tc.Arguments["message"] = p.sensitiveFilter(msg)
}
case p.name + "_send_file", 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) {
if ok, _ := tc.Arguments["confirm"].(bool); !ok {
msg := fmt.Sprintf("QQ群管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd)
ctx.Response = &msg
return nil
}
}
}
if tc.Name == p.name+"_friend_action" {
cmd, _ := tc.Arguments["command"].(string)
if requiresConfirmFriendCommand(cmd) {
if ok, _ := tc.Arguments["confirm"].(bool); !ok {
msg := fmt.Sprintf("QQ好友管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd)
ctx.Response = &msg
return nil
}
}
}
return nil
}
func requiresConfirmGroupCommand(cmd string) bool {
switch cmd {
case "leave", "kick", "ban", "unban", "rename", "mute-all", "set-card", "set-admin", "set-title", "recall", "pin-msg", "folder-create":
return true
default:
return false
}
}
func requiresConfirmFriendCommand(cmd string) bool {
switch cmd {
case "delete", "block", "approve-friend", "reject-friend":
return true
default:
return false
}
}
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
body, _ := io.ReadAll(r.Body)
var evt struct {
PostType string `json:"post_type"`
MessageType string `json:"message_type,omitempty"`
UserID int64 `json:"user_id,omitempty"`
GroupID int64 `json:"group_id,omitempty"`
RawMessage string `json:"raw_message,omitempty"`
Message interface{} `json:"message,omitempty"`
Time int64 `json:"time"`
Sender *struct {
Nickname string `json:"nickname"`
Card string `json:"card,omitempty"`
} `json:"sender,omitempty"`
}
if json.Unmarshal(body, &evt) != nil || evt.PostType != "message" {
w.WriteHeader(http.StatusOK)
return
}
text := evt.RawMessage
if text == "" {
if s, ok := evt.Message.(string); ok {
text = s
}
}
if text == "" {
w.WriteHeader(http.StatusOK)
return
}
if evt.MessageType == "private" {
if !p.isDMAllowed(evt.UserID) {
w.WriteHeader(http.StatusOK)
return
}
}
if evt.MessageType == "group" {
if !p.isGroupAllowed(evt.GroupID) {
w.WriteHeader(http.StatusOK)
return
}
// 群消息必须 @ 机器人才响应
if p.botID > 0 && !p.isAtBot(evt.Message) {
w.WriteHeader(http.StatusOK)
return
}
}
nickname := ""
if evt.Sender != nil {
nickname = evt.Sender.Nickname
if evt.Sender.Card != "" {
nickname = evt.Sender.Card
}
}
p.mu.Lock()
localID := p.nextID
p.nextID++
msg := &SavedMessage{
LocalID: localID, UserID: evt.UserID, Nickname: nickname,
GroupID: evt.GroupID, MessageType: evt.MessageType, Text: text, Time: evt.Time,
}
groupName := ""
if evt.MessageType == "group" {
if n, ok := p.groupNameCache[evt.GroupID]; ok {
groupName = n
} else {
groupName = fmt.Sprintf("%d", evt.GroupID)
}
msg.GroupName = groupName
}
p.messages = append(p.messages, msg)
if len(p.messages) > maxMessages {
p.messages = p.messages[1:]
}
tp := p.name + "_"
var interrupt string
if evt.MessageType == "group" {
interrupt = fmt.Sprintf("来自%s的%s群聊消息通过id%d使用%sget_message工具获取消息正文。获取后必须使用%ssend_group_msg工具回复该群聊", nickname, groupName, localID, tp, tp)
} else {
interrupt = fmt.Sprintf("来自%s的私聊消息通过id%d使用%sget_message工具获取消息正文。获取后必须使用%ssend_private_msg工具回复对方", nickname, localID, tp, tp)
}
if p.adminID > 0 && evt.UserID == p.adminID {
interrupt = "【重要!老大消息】" + interrupt
}
p.mu.Unlock()
if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, p.name, interrupt)
}
w.WriteHeader(http.StatusOK)
}
// ======== Tool Handlers ========
func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, error) {
id, err := convInt64(args["local_id"])
if err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("无效的 local_id 参数请传入整数类型的消息ID"),
"error": err.Error(),
}, nil
}
p.mu.RLock()
defer p.mu.RUnlock()
for _, m := range p.messages {
if m.LocalID == id {
return m, nil
}
}
return map[string]interface{}{
"content": fmt.Sprintf("消息 %d 未找到。可能的原因:消息已被处理过期,或插件重启后本地缓存已清空。请使用 qq_get_history 工具从 NapCat 拉取历史消息。", id),
"local_id": id,
"not_found": true,
}, nil
}
func (p *Plugin) handleSendPrivate(args map[string]interface{}) (interface{}, error) {
uid, _ := convInt64(args["user_id"])
msg := p.sensitiveFilter(args["message"].(string))
return p.napcat("send_private_msg", map[string]interface{}{"user_id": uid, "message": msg})
}
func (p *Plugin) handleSendGroup(args map[string]interface{}) (interface{}, error) {
gid, _ := convInt64(args["group_id"])
msg := p.sensitiveFilter(args["message"].(string))
return p.napcat("send_group_msg", map[string]interface{}{"group_id": gid, "message": msg})
}
func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error) {
gid, gerr := convInt64(args["group_id"])
uid, uerr := convInt64(args["user_id"])
if gerr != nil && uerr != nil {
return nil, fmt.Errorf("need group_id or user_id")
}
filePath, _ := args["file"].(string)
if filePath == "" {
return nil, fmt.Errorf("need file path")
}
name, _ := args["name"].(string)
if name == "" {
name = filepath.Base(filePath)
}
name = p.sensitiveFilter(name)
asImage, _ := args["as_image"].(bool)
// copy to remote dir for NapCat container access
dest := filepath.Join(p.remoteDir, name)
srcData, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
if err := os.WriteFile(dest, srcData, 0644); err != nil {
return nil, fmt.Errorf("write remote: %w", err)
}
uri := fmt.Sprintf("file:///app/files/%s", name)
var cqMsg string
if asImage {
cqMsg = fmt.Sprintf("[CQ:image,file=%s]", uri)
} else {
cqMsg = fmt.Sprintf("[CQ:file,file=%s,title=%s]", uri, name)
}
params := map[string]interface{}{"message": cqMsg}
if gerr == nil {
params["group_id"] = gid
return p.napcat("send_group_msg", params)
}
params["user_id"] = uid
return p.napcat("send_private_msg", params)
}
func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, error) {
gid, gerr := convInt64(args["group_id"])
uid, uerr := convInt64(args["user_id"])
count := 10
if c, err := convInt64(args["count"]); err == nil && c > 0 {
count = int(c)
}
var endpoint string
var params map[string]interface{}
if gerr == nil {
endpoint = "get_group_msg_history"
params = map[string]interface{}{"group_id": gid, "count": count}
} else if uerr == nil {
endpoint = "get_friend_msg_history"
params = map[string]interface{}{"user_id": uid, "count": count}
} else {
return nil, fmt.Errorf("need group_id or user_id")
}
data, err := p.napcat(endpoint, params)
if err != nil {
return nil, err
}
return data, nil
}
func (p *Plugin) handleGetGroups(args map[string]interface{}) (interface{}, error) {
return p.napcat("get_group_list", map[string]interface{}{})
}
func (p *Plugin) handleGetFriends(args map[string]interface{}) (interface{}, error) {
return p.napcat("get_friend_list", map[string]interface{}{})
}
func (p *Plugin) handleResolveName(args map[string]interface{}) (interface{}, error) {
if uid, err := convInt64(args["user_id"]); err == nil {
return p.napcat("get_stranger_info", map[string]interface{}{"user_id": uid, "no_cache": true})
}
if gid, err := convInt64(args["group_id"]); err == nil {
return p.napcat("get_group_info", map[string]interface{}{"group_id": gid, "no_cache": true})
}
return nil, fmt.Errorf("need user_id or group_id")
}
func (p *Plugin) handleGetGroupMemberInfo(args map[string]interface{}) (interface{}, error) {
gid, _ := convInt64(args["group_id"])
uid, _ := convInt64(args["user_id"])
return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid})
}
func (p *Plugin) handleGroupManage(args map[string]interface{}) (interface{}, error) {
cmd, _ := args["command"].(string)
if cmd == "" {
return nil, fmt.Errorf("need command")
}
if requiresConfirmGroupCommand(cmd) {
if ok, _ := args["confirm"].(bool); !ok {
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil
}
}
switch cmd {
case "group-list":
return p.napcat("get_group_list", map[string]interface{}{})
case "group-info", "member-list", "member-info", "at-all-remain", "msg-history":
gid, _ := convInt64(args["group_id"])
if cmd == "msg-history" {
count := 10
if c, err := convInt64(args["count"]); err == nil && c > 0 {
count = int(c)
}
return p.napcat("get_group_msg_history", map[string]interface{}{"group_id": gid, "count": count})
}
if cmd == "member-info" {
uid, _ := convInt64(args["user_id"])
return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid})
}
if cmd == "at-all-remain" {
return p.napcat("get_group_at_all_remain", map[string]interface{}{"group_id": gid})
}
if cmd == "group-info" {
return p.napcat("get_group_info", map[string]interface{}{"group_id": gid})
}
return p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid})
case "list-files":
gid, _ := convInt64(args["group_id"])
folderID, _ := args["folder_id"].(string)
if folderID != "" {
return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID})
}
return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid})
case "pending-requests":
return p.napcat("get_group_system_msg", map[string]interface{}{})
case "leave":
gid, _ := convInt64(args["group_id"])
return p.napcat("set_group_leave", map[string]interface{}{"group_id": gid})
case "kick":
gid, _ := convInt64(args["group_id"])
uid, _ := convInt64(args["user_id"])
reject, _ := args["reject_add"].(bool)
return p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": reject})
case "ban":
gid, _ := convInt64(args["group_id"])
uid, _ := convInt64(args["user_id"])
minutes := 10
if m, err := convInt64(args["minutes"]); err == nil {
minutes = int(m)
}
return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": minutes * 60})
case "unban":
gid, _ := convInt64(args["group_id"])
uid, _ := convInt64(args["user_id"])
return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": 0})
case "rename":
gid, _ := convInt64(args["group_id"])
name, _ := args["name"].(string)
return p.napcat("set_group_name", map[string]interface{}{"group_id": gid, "group_name": name})
case "mute-all":
gid, _ := convInt64(args["group_id"])
enable, _ := args["enable"].(bool)
return p.napcat("set_group_whole_ban", map[string]interface{}{"group_id": gid, "enable": enable})
case "set-card":
gid, _ := convInt64(args["group_id"])
uid, _ := convInt64(args["user_id"])
card, _ := args["card"].(string)
return p.napcat("set_group_card", map[string]interface{}{"group_id": gid, "user_id": uid, "card": card})
case "set-admin":
gid, _ := convInt64(args["group_id"])
uid, _ := convInt64(args["user_id"])
enable, _ := args["enable"].(bool)
return p.napcat("set_group_admin", map[string]interface{}{"group_id": gid, "user_id": uid, "enable": enable})
case "set-title":
gid, _ := convInt64(args["group_id"])
uid, _ := convInt64(args["user_id"])
title, _ := args["title"].(string)
return p.napcat("set_group_special_title", map[string]interface{}{"group_id": gid, "user_id": uid, "special_title": title})
case "recall":
mid, _ := convInt64(args["message_id"])
return p.napcat("delete_msg", map[string]interface{}{"message_id": mid})
case "pin-msg":
mid, _ := convInt64(args["message_id"])
return p.napcat("set_essence_msg", map[string]interface{}{"message_id": mid})
case "folder-create":
gid, _ := convInt64(args["group_id"])
name, _ := args["name"].(string)
return p.napcat("create_group_file_folder", map[string]interface{}{"group_id": gid, "name": name})
default:
return nil, fmt.Errorf("unknown group_manage command: %s", cmd)
}
}
func (p *Plugin) handleFriendAction(args map[string]interface{}) (interface{}, error) {
cmd, _ := args["command"].(string)
if requiresConfirmFriendCommand(cmd) {
if ok, _ := args["confirm"].(bool); !ok {
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil
}
}
switch cmd {
case "list-friends":
return p.napcat("get_friend_list", map[string]interface{}{})
case "delete":
uid, _ := convInt64(args["user_id"])
return p.napcat("delete_friend", map[string]interface{}{"user_id": uid})
case "block":
uid, _ := convInt64(args["user_id"])
// 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})
}
}
}
}
}
return `{"status":"ok","message":"blocked"}`, nil
case "approve-friend":
flag, _ := args["flag"].(string)
remark, _ := args["remark"].(string)
return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": true, "remark": remark})
case "reject-friend":
flag, _ := args["flag"].(string)
return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": false})
default:
return nil, fmt.Errorf("unknown friend_action command: %s", cmd)
}
}
func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{}, error) {
gid, _ := convInt64(args["group_id"])
cmd, _ := args["command"].(string)
switch cmd {
case "list":
folderID, _ := args["folder_id"].(string)
if folderID != "" {
return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID})
}
return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid})
case "search":
return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid})
case "download":
fileID, _ := args["file_id"].(string)
filename, _ := args["filename"].(string)
if filename == "" {
filename = fmt.Sprintf("group_file_%s", fileID)
}
// get download URL
resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": gid, "file_id": fileID})
if err != nil {
return nil, err
}
respStr, ok := resp.(string)
if !ok {
return resp, nil
}
// parse URL from response
var parsed struct {
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(respStr), &parsed); err != nil || parsed.Data.URL == "" {
return resp, nil
}
dlURL := parsed.Data.URL
httpResp, err := http.Get(dlURL)
if err != nil {
return nil, fmt.Errorf("download: %w", err)
}
defer httpResp.Body.Close()
content, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, fmt.Errorf("read download: %w", err)
}
os.MkdirAll(p.filesDir, 0755)
savePath := filepath.Join(p.filesDir, filename)
if err := os.WriteFile(savePath, content, 0644); err != nil {
return nil, fmt.Errorf("save: %w", err)
}
return map[string]interface{}{
"status": "ok", "path": savePath, "filename": filename, "size": len(content),
}, nil
default:
return nil, fmt.Errorf("unknown get_group_files command: %s", cmd)
}
}
func (p *Plugin) handleUploadGroupFile(args map[string]interface{}) (interface{}, error) {
gid, _ := convInt64(args["group_id"])
filePath, _ := args["file"].(string)
name, _ := args["name"].(string)
if name == "" {
name = filepath.Base(filePath)
}
name = p.sensitiveFilter(name)
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
b64 := fmt.Sprintf("base64://%s", string(data))
resp, err := p.napcat("send_group_msg", map[string]interface{}{
"group_id": gid,
"message": []map[string]interface{}{
{"type": "file", "data": map[string]interface{}{"file": b64, "name": name}},
},
})
if err != nil {
return nil, err
}
return map[string]interface{}{"status": "ok", "file": name, "napcat": resp}, nil
}
func (p *Plugin) handleSendLike(args map[string]interface{}) (interface{}, error) {
uid, _ := convInt64(args["user_id"])
times := 1
if t, err := convInt64(args["times"]); err == nil && t > 0 && t <= 20 {
times = int(t)
}
return p.napcat("send_like", map[string]interface{}{"user_id": uid, "times": times})
}
func (p *Plugin) handleOcrImage(args map[string]interface{}) (interface{}, error) {
image, _ := args["image"].(string)
lang, _ := args["lang"].(string)
if lang == "" {
lang = "chi_sim+eng"
}
// If local file, copy to remote dir for NapCat
if !strings.HasPrefix(image, "http://") && !strings.HasPrefix(image, "https://") {
dest := filepath.Join(p.remoteDir, filepath.Base(image))
src, err := os.ReadFile(image)
if err == nil {
os.WriteFile(dest, src, 0644)
image = fmt.Sprintf("file:///app/files/%s", filepath.Base(image))
}
}
return p.napcat("ocr_image", map[string]interface{}{"image": image})
}
// ======== NapCat HTTP Client ========
func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) {
data, _ := json.Marshal(params)
url := fmt.Sprintf("%s/%s", p.napcatURL, action)
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("napcat %s: %w", action, err)
}
defer resp.Body.Close()
var raw json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("napcat decode %s: %w", action, err)
}
return string(raw), nil
}
// ======== Helpers ========
func main() {}
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`)
func (p *Plugin) sensitiveFilter(text string) string {
if p.remoteDir != "" {
text = strings.ReplaceAll(text, p.remoteDir, "[remote]")
}
if p.filesDir != "" {
text = strings.ReplaceAll(text, p.filesDir, "[files]")
}
text = reAPIKey.ReplaceAllString(text, "$1=***")
text = reSKKey.ReplaceAllString(text, "sk-***")
text = reInternalIP.ReplaceAllString(text, "[IP]")
return text
}
func convInt64(v interface{}) (int64, error) {
switch n := v.(type) {
case int64:
return n, nil
case float64:
return int64(n), nil
case int:
return int64(n), nil
case json.Number:
return n.Int64()
case string:
return strconv.ParseInt(n, 10, 64)
}
return 0, fmt.Errorf("cannot convert %T to int64", v)
}