diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index b582c98..b81270e 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -470,7 +470,6 @@ func (h *Handler) subscribeChatEvents() { } m := ChatMsg{ Role: "assistant", - Content: content, Source: channel, Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339), Attachment: att, @@ -478,8 +477,23 @@ func (h *Handler) subscribeChatEvents() { // 附件消息不把本地路径当正文展示(如 "/tmp/homeagent.png"),置空 if att != nil { m.Content = "" + } else { + m.Content = content } - h.addChatMsg(m) + // 已持 chatMu:直接操作 chatHistory + persist,不可调 addChatMsg + //(内部会重入加锁导致死锁——output_send__webui 发图 60s 超时的根因) + h.chatHistory = append(h.chatHistory, m) + if len(h.chatHistory) > maxChatHistory { + drop := len(h.chatHistory) - maxChatHistory + h.chatHistory = h.chatHistory[drop:] + if h.pendingIdx >= 0 { + h.pendingIdx -= drop + if h.pendingIdx < 0 { + h.pendingIdx = -1 + } + } + } + h.persistChatLocked() h.chatMu.Unlock() return } diff --git a/third_party/homeagent-sdk/example/qq/plugin.go b/third_party/homeagent-sdk/example/qq/plugin.go new file mode 100644 index 0000000..ff4f02b --- /dev/null +++ b/third_party/homeagent-sdk/example/qq/plugin.go @@ -0,0 +1,2330 @@ +package main + +import ( + "bytes" + "context" + "crypto/hmac" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + "math" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type ForwardRule struct { + GroupID int64 `json:"group_id"` + Host string `json:"host"` + Port int `json:"port"` + Password string `json:"password"` + Template string `json:"template"` +} + +func rconSend(host string, port int, password, cmd string) error { + 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) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + + buf := make([]byte, 4096) + // Login + pkt := rconPacket(1, 3, password) + if _, err := conn.Write(pkt); err != nil { + return fmt.Errorf("rcon login write: %w", err) + } + if _, err := io.ReadFull(conn, buf[:12]); err != nil { + return fmt.Errorf("rcon login read: %w", err) + } + // Command + pkt = rconPacket(2, 2, cmd) + if _, err := conn.Write(pkt); err != nil { + return fmt.Errorf("rcon cmd write: %w", err) + } + n, err := io.ReadFull(conn, buf[:12]) + if err != nil && err != io.ErrUnexpectedEOF { + return fmt.Errorf("rcon cmd read: %w (n=%d)", err, n) + } + return nil +} + +func rconPacket(id, typ int32, body string) []byte { + b := []byte(body) + b = append(b, 0) // null terminator + b = append(b, 0) // padding + length := 4 + 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)) + copy(pkt[12:], b) + return pkt +} + +type DownloadTask struct { + FileID string `json:"file_id"` + Filename string `json:"filename"` + Status string `json:"status"` + Path string `json:"path,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt string `json:"created_at"` +} + +type Plugin struct { + name string + sdk *sdk.PluginSDK + listenAddr string + napcatURL string + remoteDir string + filesDir string + webhookToken string + adminIDs []int64 + botID int64 + botNickname string + dmPolicy string + groupPolicy string + httpClient *http.Client + allowFrom map[int64]struct{} + groupAllowFrom map[int64]struct{} + srv *http.Server + agentfsDir string + downloadMu sync.Mutex + downloadTasks []*DownloadTask + typingMu sync.Mutex + typingMap map[int64]*typingState +} + +type typingState struct { + userID int64 + stopCh chan struct{} +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.SetAutoRestart(true) + p.sdk = s + + 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: "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"}}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_allow_from", Default: "", Type: "string", DisplayName: "群聊白名单", Description: "允许接入的群号列表,逗号分隔", Category: "qq"}) + 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.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", "")) + p.groupAllowFrom = parseIDSet(getSetting[string](settings, "group_allow_from", "")) + p.filesDir = strings.TrimRight(getSetting[string](settings, "files_dir", "/home/newqqagent/agentfs/merged/qq_files"), "/") + p.agentfsDir = strings.TrimRight(getSetting[string](settings, "agentfs_dir", "/home/newqqagent/agentfs/merged"), "/") + p.remoteDir = strings.TrimRight(getSetting[string](settings, "remote_dir", "/home/program/qq-workspace/remote"), "/") + os.MkdirAll(p.remoteDir, 0755) + + p.httpClient = &http.Client{Timeout: 30 * time.Second} + + // 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s) + p.fetchBotInfo() + if p.botID == 0 { + log.Printf("[qq] warning: 获取 Bot 身份失败,群 @ 检查将拒绝所有未提及消息") + } + + 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 += "。" + } + + // ---- 注册输出通道 ---- + s.RegisterOutputChannel("qq", sdk.CapText|sdk.CapFile|sdk.CapImage|sdk.CapAudio, + `发送QQ群聊/私聊消息,支持文字、语音、图片、文件。 +meta JSON 格式: +{ + "group_id": 123456, // 群号(与 user_id 二选一) + "user_id": 123456, // QQ号(与 group_id 二选一) + "reply_to": 12345 // 可选,回复指定消息 ID +} +type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图片URL)/ file(文件URL)`, + sdk.ChannelDef{}, p.handleChannelOutput) + + // ---- 注册输入通道(记忆计算层行为) ---- + inputCleaner := func(text string) string { + // 从中断模板中提取语义内容:消息来源和发送者昵称 + // 模板: 来自「昵称」的私聊/群聊消息(message_id=N)... + // 模板: 【重要!老大消息】来自「昵称」... + cleaned := text + // 去掉模板前缀 + if strings.HasPrefix(cleaned, "【重要!老大消息】") { + cleaned = strings.TrimPrefix(cleaned, "【重要!老大消息】") + } + // 提取 "来自「XXX」" 中的昵称作为关键词 + if start := strings.Index(cleaned, "来自「"); start >= 0 { + if end := strings.Index(cleaned[start:], "」"); end >= 0 { + nick := cleaned[start+len("来自「") : start+end] + cleaned = nick + } + } + return cleaned + } + s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner}) + + // 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化 + cleaner := func(output string) string { + var r struct{ Content string } + if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" { + return r.Content + } + return output + } + + // ---- 消息 ---- + p.regTool(s, sdk.ToolDef{ + Name: tp + "get_message", Description: botInfo + "通过 message_id 从 NapCat 实时获取消息正文、发送者、附件等信息。message_id 从中断消息的 message_id=N 获取,或从 reply_to 的 message_id 获取。", + NoMemory: false, + Cleaner: cleaner, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID(从中断消息的 message_id=N 或 reply_to.message_id 获取)"}, + }, "required": []string{"message_id"}, + }, + }, p.handleGetMessage) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "send_file", Description: "发送文件/图片到QQ(私聊或群聊)。文件先复制到remote目录供NapCat容器访问。", + Parameters: 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,默认)"}, + }, + }, + NoMemory: true, + }, p.handleSendFile) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "get_history", Description: "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容。如果消息包含文件,会额外返回 files 字段(含 file_id 和 name),可用 qq_download_file 工具下载。", + NoMemory: false, + Cleaner: cleaner, + Parameters: 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, sdk.ToolDef{ + Name: tp + "get_groups", Description: "获取QQ群列表,可按关键词搜索群名", + NoMemory: false, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, + }, + }, + }, p.handleGetGroups) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "get_friends", Description: "获取QQ好友列表,可按昵称/备注关键词搜索", + NoMemory: false, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, + }, + }, + }, p.handleGetFriends) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "get_recent_contacts", Description: "查看最近有消息的联系人和群聊,返回最近消息概览(含消息数、最后一条消息内容)。可用于发现有谁发过消息但未处理。", + NoMemory: false, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "count": map[string]interface{}{"type": "integer", "description": "获取数量,默认10"}, + }, + }, + }, p.handleGetRecentContacts) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "resolve_name", Description: "将QQ号或群号解析为可读的用户昵称或群名称", + NoMemory: false, + Parameters: 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, sdk.ToolDef{ + Name: tp + "resolve_nickname", Description: "按昵称/备注/群名片搜索QQ用户,返回匹配的QQ号和详细信息。支持搜索好友列表或指定群成员。", + NoMemory: false, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(昵称/备注/群名片)"}, + "group_id": map[string]interface{}{"type": "integer", "description": "所在群号(可选),不传则搜索好友列表"}, + }, "required": []string{"keyword"}, + }, + }, p.handleResolveNickname) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "get_group_member_info", Description: "获取QQ群成员详细信息", + NoMemory: false, + Parameters: 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, sdk.ToolDef{ + 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号(踢人/禁言/设名片等需要)"}, + "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": "禁言分钟数(ban),0=解禁"}, + "count": map[string]interface{}{"type": "integer", "description": "消息条数(msg-history),默认10"}, + "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list-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"}, + }, + }, + NoMemory: true, + }, p.handleGroupManage) + + p.regTool(s, sdk.ToolDef{ + 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": "好友请求flag(approve-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"}, + }, + }, + NoMemory: true, + }, p.handleFriendAction) + + // ---- 文件 ---- + p.regTool(s, sdk.ToolDef{ + Name: tp + "get_group_files", Description: "查询群文件列表、搜索文件、下载文件到本地。操作: list列出, search搜索, download下载", + NoMemory: false, + 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"}, + "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list指定文件夹)"}, + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(search)"}, + "file_id": map[string]interface{}{"type": "string", "description": "文件ID(download)"}, + "filename": map[string]interface{}{"type": "string", "description": "保存文件名(download可选)"}, + }, + }, + }, p.handleGetGroupFiles) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "download_file", Description: "从聊天记录下载文件到本地。file_id 从 qq_get_message/qq_get_history 的 files 字段获取。群文件建议提供 group_id,私聊文件建议提供 user_id 以提高成功率。异步下载,完成后推送通知。", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "file_id": map[string]interface{}{"type": "string", "description": "文件 ID(从 qq_get_message 或 qq_get_history 的 files 字段获取)"}, + "url": map[string]interface{}{"type": "string", "description": "文件下载 URL(可选,qq_get_message 返回的 url 字段)"}, + "group_id": map[string]interface{}{"type": "integer", "description": "群号(可选,群文件下载)"}, + "user_id": map[string]interface{}{"type": "integer", "description": "私聊对象QQ号(可选,私聊文件下载)"}, + "filename": map[string]interface{}{"type": "string", "description": "保存文件名(可选,默认用原文件名)"}, + }, "required": []string{"file_id"}, + }, + NoMemory: true, + }, p.handleDownloadFile) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "get_download_tasks", Description: "查看所有下载任务及状态(running/done/failed),包含文件名、保存路径、错误信息等", + NoMemory: false, + Cleaner: cleaner, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{}, + }, + }, p.handleGetDownloadTasks) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "upload_group_file", Description: "上传文件到QQ群(通过base64编码发送,同时出现在群消息和群文件柜)", + Parameters: 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"}, + }, + NoMemory: true, + }, p.handleUploadGroupFile) + + // ---- 文档/视频/网页工具 ---- + p.regTool(s, sdk.ToolDef{ + Name: tp + "read_document", Description: "读取文档内容文本。支持 PDF、DOCX、DOC、XLSX、XLS、PPTX、PPT、TXT、CSV、MD 格式。使用 libreoffice + pandoc 转换提取文本,返回前 20000 字符。适合处理用户发来的文档文件。", + NoMemory: false, + Cleaner: cleaner, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "path": map[string]interface{}{"type": "string", "description": "文档文件路径(已保存到本地的文件路径)"}, + }, "required": []string{"path"}, + }, + }, p.handleReadDocument) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "video_download", Description: "下载视频到本地。支持 B站、YouTube 等主流视频网站(通过 yt-dlp)。先调用 info_only 查看视频信息,再下载。下载后文件保存在 agentfs 目录。", + NoMemory: false, + Cleaner: cleaner, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string", "description": "视频分享链接"}, + "info_only": map[string]interface{}{"type": "boolean", "description": "仅获取视频信息(标题、时长、清晰度列表),不下"}, + }, "required": []string{"url"}, + }, + }, p.handleVideoDownload) + + // ---- 附加 ---- + p.regTool(s, sdk.ToolDef{ + Name: tp + "send_like", Description: "给QQ好友点赞/戳一戳", + Parameters: 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"}, + }, + NoMemory: true, + }, p.handleSendLike) + + s.RegisterStage(sdk.StageBeforeToolcall, p.beforeOwnToolcall, sdk.StageScopeOwnTools) + + // ---- 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, 15) + return nil +} + +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() + p.srv.Shutdown(ctx) + } + return nil +} + +func (p *Plugin) regTool(s *sdk.PluginSDK, def sdk.ToolDef, handler sdk.ToolHandler) { + s.RegisterTool(def.Name, def, 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 := p.httpClient.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 +} + +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) + continue + } + // 兼容历史坏数据:科学计数法存库的值(如 2.198972886e+09) + if f, err := strconv.ParseFloat(part, 64); err == nil && f > 0 && f == math.Trunc(f) { + out = append(out, int64(f)) + } + } + 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{}) + 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] + 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 + } + 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"` + MessageType string `json:"message_type,omitempty"` + MessageID int64 `json:"message_id"` + 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 + } + // 群消息到达即记录(用于排查 napcat→webhook 链路漏报/丢弃) + if evt.MessageType == "group" { + log.Printf("[qq] webhook recv group msg id=%d from=%d in=%d raw=%.100s", + evt.MessageID, evt.UserID, evt.GroupID, evt.RawMessage) + } + + rawCQ := evt.RawMessage + text := rawCQ + if text == "" { + if s, ok := evt.Message.(string); ok { + text = s + } + } + nickname := "" + if evt.Sender != nil { + nickname = evt.Sender.Nickname + if evt.Sender.Card != "" { + nickname = evt.Sender.Card + } + } + + if evt.MessageType == "private" { + if !p.isDMAllowed(evt.UserID) { + w.WriteHeader(http.StatusOK) + return + } + } + if evt.MessageType == "group" { + if !p.isGroupAllowed(evt.GroupID) { + log.Printf("[qq] group msg from %d rejected: policy=%s", evt.GroupID, p.groupPolicy) + w.WriteHeader(http.StatusOK) + return + } + // 群消息必须 @ 机器人才响应 + if p.botID == 0 { + log.Printf("[qq] bot ID unknown, rejecting group message from %d", evt.GroupID) + w.WriteHeader(http.StatusOK) + return + } + if !p.isAtBot(evt.Message) { + // 诊断:@ 解析失败时打印 at 段原文与 botID,定位漏报问题 + log.Printf("[qq] group msg from %d/%d not @bot (botID=%d, raw=%.120s)", + evt.GroupID, evt.UserID, p.botID, rawCQ) + w.WriteHeader(http.StatusOK) + return + } + } + + tp := p.name + "_" + outputTool := "output_send__" + p.name + var interrupt string + if evt.MessageType == "group" { + interrupt = fmt.Sprintf("来自「%s」在群「%s」的消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。如果消息包含引用回复,使用%sget_history(group_id=%d)查看上下文。使用%s回复群聊", nickname, "群聊", evt.MessageID, tp, evt.MessageID, tp, evt.GroupID, outputTool) + } else { + interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。使用%s回复对方", nickname, evt.MessageID, tp, evt.MessageID, outputTool) + } + 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 + if json.Unmarshal([]byte(rulesRaw), &rules) == nil { + for _, rule := range rules { + if evt.GroupID == rule.GroupID { + mcMsg := fmt.Sprintf("%s 说 %s", nickname, text) + go func(r ForwardRule, msg string) { + if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil { + log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err) + } + }(rule, mcMsg) + } + } + } + } + + if evt.MessageType == "private" { + p.startTyping(evt.UserID) + } + + 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) { + msgID, err := convInt64(args["message_id"]) + if err != nil { + return map[string]interface{}{ + "content": "需要提供 message_id 参数", + "not_found": true, + }, nil + } + + raw, err := p.napcat("get_msg", map[string]interface{}{"message_id": msgID}) + if err != nil { + return map[string]interface{}{ + "content": fmt.Sprintf("查询 NapCat 失败: %s", err), + "message_id": msgID, + "not_found": true, + }, nil + } + rawStr, _ := rawString(raw) + if rawStr == "" { + return map[string]interface{}{ + "content": "NapCat 返回空响应", + "message_id": msgID, + "not_found": true, + }, nil + } + + var resp struct { + Data *struct { + MessageID int64 `json:"message_id"` + UserID int64 `json:"user_id"` + GroupID int64 `json:"group_id"` + Time int64 `json:"time"` + MessageType string `json:"message_type"` + RawMessage string `json:"raw_message"` + Message interface{} `json:"message"` + Sender *struct { + Nickname string `json:"nickname"` + Card string `json:"card"` + } `json:"sender"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(rawStr), &resp); err != nil || resp.Data == nil { + return map[string]interface{}{ + "content": "解析 NapCat 响应失败", + "message_id": msgID, + "not_found": true, + }, nil + } + + d := resp.Data + nickname := "" + if d.Sender != nil { + nickname = d.Sender.Nickname + if d.Sender.Card != "" { + nickname = d.Sender.Card + } + } + + content := d.RawMessage + var files []map[string]interface{} + var replyToID int64 + var hasImage, hasFile bool + if segs, ok := d.Message.([]interface{}); ok { + var parts []string + for _, seg := range segs { + segMap, _ := seg.(map[string]interface{}) + if segMap == nil { + continue + } + typ, _ := segMap["type"].(string) + segData, _ := segMap["data"].(map[string]interface{}) + if segData == nil { + continue + } + switch typ { + case "text": + if t, _ := segData["text"].(string); t != "" { + parts = append(parts, t) + } + case "at": + if qq, _ := segData["qq"].(string); qq != "" { + parts = append(parts, "@"+qq) + } + case "reply": + if idStr, ok := segData["id"].(string); ok { + replyToID, _ = strconv.ParseInt(idStr, 10, 64) + } else if id, ok := segData["id"].(float64); ok { + replyToID = int64(id) + } + parts = append(parts, fmt.Sprintf("[回复id=%d]", replyToID)) + case "file": + hasFile = true + fid, _ := segData["file_id"].(string) + if fid == "" { + fid, _ = segData["file"].(string) + } + name, _ := segData["name"].(string) + if name == "" { + name, _ = segData["file"].(string) + } + fileURL, _ := segData["url"].(string) + if fid != "" { + entry := map[string]interface{}{ + "file_id": fid, + "name": name, + } + if fileURL != "" { + entry["url"] = fileURL + } + files = append(files, entry) + } + case "image": + hasImage = true + } + } + if len(parts) > 0 { + content = strings.Join(parts, " ") + } + } + + loc := "私聊" + if d.MessageType == "group" { + loc = "群聊" + } + result := map[string]interface{}{ + "content": content, + "message_id": d.MessageID, + "user_id": d.UserID, + "group_id": d.GroupID, + "nickname": nickname, + "message_type": d.MessageType, + "type": loc, + "time": time.Unix(d.Time, 0).Format("2006-01-02 15:04:05"), + } + if replyToID > 0 { + result["reply_to"] = map[string]interface{}{"message_id": replyToID} + } + if hasImage { + result["has_image"] = true + } + if hasFile { + result["has_file"] = true + } + if len(files) > 0 { + result["files"] = files + } + + // 异步标记已读 + go func() { + if d.MessageType == "group" && d.GroupID > 0 { + p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID}) + } else if d.UserID > 0 { + p.napcat("mark_private_msg_as_read", map[string]interface{}{"user_id": d.UserID}) + } + }() + + return result, nil +} + +// handleChannelOutput — output_send(channel="qq") 的处理器 +// args 包含 payload, type, (可选 meta) +func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, error) { + payload, _ := args["payload"].(string) + rawType, _ := args["type"].(string) + meta, _ := args["meta"].(string) + log.Printf("[qq] handleChannelOutput type=%s payload_len=%d meta=%s", rawType, len(payload), meta) + if payload == "" || rawType == "" { + return nil, fmt.Errorf("payload 和 type 参数不能为空") + } + + // 解析 meta + var groupID, userID int64 + var replyTo int64 + if meta != "" { + var m struct { + GroupID int64 `json:"group_id"` + UserID int64 `json:"user_id"` + ReplyTo int64 `json:"reply_to"` + } + if err := json.Unmarshal([]byte(meta), &m); err == nil { + groupID = m.GroupID + userID = m.UserID + replyTo = m.ReplyTo + } + } + if groupID == 0 && userID == 0 { + return nil, fmt.Errorf("meta 中需要 group_id 或 user_id 字段。用 output_send__qq_help 查看格式说明") + } + + if userID > 0 { + p.stopTyping(userID) + } + + switch rawType { + case "text": + text := p.sensitiveFilter(payload) + msg := map[string]interface{}{"message": text} + if groupID != 0 { + msg["group_id"] = groupID + } else { + msg["user_id"] = userID + } + if replyTo > 0 { + msg["reply_to"] = replyTo + } + if groupID != 0 { + return p.napcat("send_group_msg", msg) + } + return p.napcat("send_private_msg", msg) + + case "voice", "audio": + text := p.sensitiveFilter(payload) + audioFile, err := p.ttsToFile(text) + if err != nil { + return nil, fmt.Errorf("语音生成失败: %w", err) + } + os.MkdirAll(p.remoteDir, 0755) + dest := filepath.Join(p.remoteDir, filepath.Base(audioFile)) + data, err := os.ReadFile(audioFile) + if err != nil { + return nil, fmt.Errorf("读取音频文件失败: %w", err) + } + if err := os.WriteFile(dest, data, 0644); err != nil { + return nil, fmt.Errorf("写入共享目录失败: %w", err) + } + os.Remove(audioFile) + uri := fmt.Sprintf("file:///app/files/%s", filepath.Base(dest)) + cqMsg := fmt.Sprintf("[CQ:record,file=%s]", uri) + msg := map[string]interface{}{"message": cqMsg} + if groupID != 0 { + msg["group_id"] = groupID + } else { + msg["user_id"] = userID + } + if groupID != 0 { + return p.napcat("send_group_msg", msg) + } + return p.napcat("send_private_msg", msg) + + case "image", "file": + // 收敛到 output 通道:payload 支持本地路径或 http(s) URL。 + // 本地路径拷入 NapCat 共享目录转 file:// URI(与 voice 分支同模式), + // 此后 agent 发本地文件不再需要单独的 upload_group_file 工具。 + uri := payload + if !strings.HasPrefix(payload, "http://") && !strings.HasPrefix(payload, "https://") && + !strings.HasPrefix(payload, "file://") { + if _, err := os.Stat(payload); err != nil { + return nil, fmt.Errorf("%s 文件不存在: %s", rawType, payload) + } + os.MkdirAll(p.remoteDir, 0755) + dest := filepath.Join(p.remoteDir, sanitizeFilename(filepath.Base(payload))) + data, err := os.ReadFile(payload) + if err != nil { + return nil, fmt.Errorf("读取文件失败: %w", err) + } + if err := os.WriteFile(dest, data, 0644); err != nil { + return nil, fmt.Errorf("写入共享目录失败: %w", err) + } + uri = "file:///app/files/" + filepath.Base(dest) + } + cqTag := "file" + if rawType == "image" { + cqTag = "image" + } + msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)} + if groupID != 0 { + msg["group_id"] = groupID + } else { + msg["user_id"] = userID + } + if groupID != 0 { + return p.napcat("send_group_msg", msg) + } + return p.napcat("send_private_msg", msg) + + default: + return nil, fmt.Errorf("不支持的 type: %s(枚举值: text/voice/image/file)", rawType) + } +} + +func (p *Plugin) buildOutputHelp() string { + return `【参数】 +payload — 消息载荷。type=text时直接填文字,type=voice时填文字(自动转语音),type=image/file时填URL +meta — JSON 元数据,必含 group_id(群聊)或 user_id(私聊),可选 reply_to +type — text / voice / image / file + +【示例】 +群聊文字:output_send__qq(payload="你好", meta="{\"group_id\":123456789}", type="text") +私聊语音:output_send__qq(payload="你好", meta="{\"user_id\":123456789}", type="voice") +发送图片:output_send__qq(payload="https://example.com/img.jpg", meta="{\"group_id\":123456789}", type="image") + +【注意】 +- type=text 时 payload 直接是文字,无需 JSON 包裹 +- type=voice 时 payload 是文字内容,自动转语音发送 +- type=image/file 时 payload 是 URL 或路径` +} + +// ttsToFile 用 edge-tts 将文本转为音频文件,返回临时文件路径 +func (p *Plugin) ttsToFile(text string) (string, error) { + // 清理文本中的特殊字符 + clean := strings.Map(func(r rune) rune { + if r == '"' || r == '\n' || r == '\r' { + return ' ' + } + return r + }, text) + clean = strings.TrimSpace(clean) + if clean == "" { + clean = " " + } + + tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("qq_tts_%d.mp3", time.Now().UnixNano())) + cmd := exec.Command("edge-tts", + "--voice", "zh-CN-XiaoxiaoNeural", + "--text", clean, + "--write-media", tmpFile, + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("edge-tts: %w\nstderr: %s", err, stderr.String()) + } + if _, err := os.Stat(tmpFile); os.IsNotExist(err) { + return "", fmt.Errorf("edge-tts 未生成输出文件") + } + return tmpFile, nil +} + +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 = sanitizeFilename(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") + } + + rawResp, err := p.napcat(endpoint, params) + if err != nil { + return nil, err + } + rawStr, _ := rawString(rawResp) + if rawStr == "" { + return map[string]interface{}{"messages": []interface{}{}, "note": "未获取到历史消息"}, nil + } + + // 解析 NapCat 响应,提取消息列表 + var resp struct { + Data *struct { + Messages []interface{} `json:"messages"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(rawStr), &resp); err != nil || resp.Data == nil { + return map[string]interface{}{"raw_response": rawStr, "note": "解析 NapCat 响应失败"}, nil + } + + // 格式化消息为可读文本,并提取文件信息 + var lines []string + var files []map[string]interface{} + for _, m := range resp.Data.Messages { + msg, ok := m.(map[string]interface{}) + if !ok { + continue + } + sender := "" + if s, ok := msg["sender"].(map[string]interface{}); ok { + if nick, _ := s["nickname"].(string); nick != "" { + sender = nick + } + if card, _ := s["card"].(string); card != "" { + sender = card + } + } + msgText, _ := msg["raw_message"].(string) + if msgText == "" { + if segs, ok := msg["message"].([]interface{}); ok { + var parts []string + for _, seg := range segs { + segMap, _ := seg.(map[string]interface{}) + if segMap == nil { + continue + } + typ, _ := segMap["type"].(string) + segData, _ := segMap["data"].(map[string]interface{}) + if segData == nil { + continue + } + switch typ { + case "text": + if t, _ := segData["text"].(string); t != "" { + parts = append(parts, t) + } + case "file": + name, _ := segData["name"].(string) + if name == "" { + name, _ = segData["file"].(string) + } + fid, _ := segData["file_id"].(string) + fileURL, _ := segData["url"].(string) + if name != "" { + parts = append(parts, "[文件:"+name+"]") + } + if fid != "" { + entry := map[string]interface{}{ + "file_id": fid, + "name": name, + "position": len(lines), + } + if fileURL != "" { + entry["url"] = fileURL + } + files = append(files, entry) + } + case "image": + parts = append(parts, "[图片]") + case "video": + parts = append(parts, "[视频]") + } + } + if len(parts) > 0 { + msgText = strings.Join(parts, " ") + } + } + } + if msgText == "" { + continue + } + ts := "" + if t, ok := msg["time"].(float64); ok { + ts = time.Unix(int64(t), 0).Format("2006-01-02 15:04") + } + line := msgText + if sender != "" { + line = sender + ": " + msgText + } + if ts != "" { + line = "[" + ts + "] " + line + } + lines = append(lines, line) + } + + if len(lines) == 0 { + return map[string]interface{}{ + "messages": []interface{}{}, + "note": "未找到历史消息,可能群内暂无消息记录", + }, nil + } + + result := map[string]interface{}{ + "messages": lines, + "count": len(lines), + } + if len(files) > 0 { + result["files"] = files + } + return result, 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) handleGetRecentContacts(args map[string]interface{}) (interface{}, error) { + count := 10 + if c, err := convInt64(args["count"]); err == nil && c > 0 { + count = int(c) + } + return p.napcat("get_recent_contact", map[string]interface{}{"count": count}) +} + +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) handleResolveNickname(args map[string]interface{}) (interface{}, error) { + keyword, _ := args["keyword"].(string) + if keyword == "" { + return nil, fmt.Errorf("keyword is required") + } + keyword = strings.ToLower(keyword) + + 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 { + return nil, err + } + raw, _ := rawString(v) + return filterMemberList(raw, keyword) + } + + v, err := p.napcat("get_friend_list", map[string]interface{}{}) + if err != nil { + return nil, err + } + raw, _ := rawString(v) + return filterFriendList(raw, keyword) +} + +func filterFriendList(raw, keyword string) (interface{}, error) { + var resp struct { + Data []struct { + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + Remark string `json:"remark"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + return raw, nil + } + var matches []map[string]interface{} + for _, f := range resp.Data { + if strings.Contains(strings.ToLower(f.Nickname), keyword) || + strings.Contains(strings.ToLower(f.Remark), keyword) { + matches = append(matches, map[string]interface{}{ + "user_id": f.UserID, + "nickname": f.Nickname, + "remark": f.Remark, + }) + } + } + if len(matches) == 0 { + return fmt.Sprintf("未找到昵称/备注包含 %q 的好友", keyword), nil + } + return matches, nil +} + +func filterMemberList(raw, keyword string) (interface{}, error) { + var resp struct { + Data []struct { + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + Card string `json:"card"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + return raw, nil + } + var matches []map[string]interface{} + for _, m := range resp.Data { + if strings.Contains(strings.ToLower(m.Nickname), keyword) || + strings.Contains(strings.ToLower(m.Card), keyword) { + matches = append(matches, map[string]interface{}{ + "user_id": m.UserID, + "nickname": m.Nickname, + "card": m.Card, + }) + } + } + if len(matches) == 0 { + return fmt.Sprintf("未找到昵称/名片包含 %q 的群成员", keyword), nil + } + return matches, nil +} + +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 + 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) + 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) + } + 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 { + 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) addDownloadTask(fileID, filename string) *DownloadTask { + p.downloadMu.Lock() + defer p.downloadMu.Unlock() + t := &DownloadTask{FileID: fileID, Filename: filename, Status: "running", CreatedAt: time.Now().Format("15:04:05")} + p.downloadTasks = append(p.downloadTasks, t) + if len(p.downloadTasks) > 100 { + p.downloadTasks = p.downloadTasks[len(p.downloadTasks)-100:] + } + return t +} + +func (p *Plugin) updateDownloadTask(t *DownloadTask, status, path, errMsg string) { + p.downloadMu.Lock() + defer p.downloadMu.Unlock() + t.Status = status + t.Path = path + t.Error = errMsg +} + +func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, error) { + fileID, _ := args["file_id"].(string) + if fileID == "" { + return nil, fmt.Errorf("file_id is required") + } + fileURL, _ := args["url"].(string) + filename, _ := args["filename"].(string) + groupID, _ := convInt64(args["group_id"]) + userID, _ := convInt64(args["user_id"]) + + task := p.addDownloadTask(fileID, filename) + + go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) { + savePath := "" + errMsg := "" + if furl != "" { + savePath = p.downloadURL(furl, fname) + } + if savePath == "" && gid > 0 { + savePath = p.downloadGroupFile(gid, fid, fname) + } + if savePath == "" && uid > 0 { + savePath = p.downloadPrivateFile(uid, fid, fname) + } + if savePath == "" { + savePath = p.downloadFromNapCat(fid, fname) + } + if savePath != "" { + p.updateDownloadTask(t, "done", savePath, "") + log.Printf("[qq] 文件下载完成: %s", savePath) + if p.sdk != nil { + p.sdk.InjectInterruptText(p.name, p.name, + fmt.Sprintf("文件下载完成: %s,保存在 %s", filepath.Base(savePath), savePath)) + } + } else { + errMsg = "下载失败,文件可能已过期" + p.updateDownloadTask(t, "failed", "", errMsg) + log.Printf("[qq] 文件下载失败: %s", fid) + } + }(task, fileID, filename, fileURL, groupID, userID) + + return map[string]interface{}{ + "status": "started", "file_id": fileID, "filename": filename, + "hint": "下载已后台启动。使用 qq_get_download_tasks 查看进度。"}, nil +} + +func (p *Plugin) handleGetDownloadTasks(args map[string]interface{}) (interface{}, error) { + p.downloadMu.Lock() + defer p.downloadMu.Unlock() + // 返回最近 50 条 + tasks := p.downloadTasks + if len(tasks) > 50 { + tasks = tasks[len(tasks)-50:] + } + return map[string]interface{}{ + "tasks": tasks, "total": len(p.downloadTasks), + "hint": "status 为 running 表示下载中,done 已完成,failed 已失败。用 qq_download_file 重新下载失败的任务。"}, nil +} + +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 = sanitizeFilename(name) + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + b64 := fmt.Sprintf("base64://%s", base64.StdEncoding.EncodeToString(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}) +} + +// ======== CQ Code / Message Segment Processing ======== + +func (p *Plugin) processMessageSegments(segments []interface{}, nickname string) string { + if len(segments) == 0 { + return "" + } + botIDStr := strconv.FormatInt(p.botID, 10) + var parts []string + + for _, seg := range segments { + s, ok := seg.(map[string]interface{}) + if !ok { + continue + } + typ, _ := s["type"].(string) + data, _ := s["data"].(map[string]interface{}) + if data == nil { + continue + } + + switch typ { + case "text": + if t, _ := data["text"].(string); t != "" { + parts = append(parts, t) + } + case "at": + qq, _ := data["qq"].(string) + if qq == "all" { + parts = append(parts, "@所有人") + } else if qq == botIDStr { + continue + } else { + parts = append(parts, "@"+qq) + } + case "face", "sface": + if id, _ := data["id"].(string); id != "" { + parts = append(parts, "[表情]") + } + case "file": + parts = append(parts, fmt.Sprintf("[%s发送了文件]", nickname)) + case "image": + parts = append(parts, fmt.Sprintf("[%s发送了图片]", nickname)) + case "video": + parts = append(parts, fmt.Sprintf("[%s发送了视频]", nickname)) + case "reply": + if id, ok := data["id"].(float64); ok { + parts = append(parts, fmt.Sprintf("[回复消息id=%.0f]", id)) + } + case "music": + if title, _ := data["title"].(string); title != "" { + parts = append(parts, fmt.Sprintf("[音乐:%s]", title)) + } else { + parts = append(parts, "[音乐]") + } + case "share": + title, _ := data["title"].(string) + urlStr, _ := data["url"].(string) + if title != "" && urlStr != "" { + parts = append(parts, fmt.Sprintf("[分享:%s %s]", title, urlStr)) + } else if urlStr != "" { + parts = append(parts, fmt.Sprintf("[分享:%s]", urlStr)) + } + default: + if typ != "" { + parts = append(parts, "["+typ+"]") + } + } + } + + return strings.TrimSpace(strings.Join(parts, " ")) +} + +func (p *Plugin) downloadGroupFile(groupID int64, fileID, filename string) string { + resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": groupID, "file_id": fileID}) + if err != nil { + return "" + } + respStr, _ := rawString(resp) + if respStr == "" { + return "" + } + var parsed struct { + Data struct { + URL string `json:"url"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(respStr), &parsed); err != nil || parsed.Data.URL == "" { + return "" + } + if p.filesDir == "" { + return "" + } + os.MkdirAll(p.filesDir, 0755) + if filename == "" { + filename = "group_file_" + fileID + } + savePath := filepath.Join(p.filesDir, sanitizeFilename(filename)) + httpResp, err := p.httpClient.Get(parsed.Data.URL) + if err != nil { + return "" + } + defer httpResp.Body.Close() + data, err := io.ReadAll(httpResp.Body) + if err != nil || len(data) == 0 { + return "" + } + os.WriteFile(savePath, data, 0644) + return savePath +} + +func (p *Plugin) downloadPrivateFile(userID int64, fileID, filename string) string { + resp, err := p.napcat("get_private_file_url", map[string]interface{}{"user_id": userID, "file_id": fileID}) + if err != nil { + return "" + } + respStr, _ := rawString(resp) + if respStr == "" { + return "" + } + var parsed struct { + Data *struct { + URL string `json:"url"` + } `json:"data"` + } + if json.Unmarshal([]byte(respStr), &parsed) != nil || parsed.Data == nil || parsed.Data.URL == "" { + return "" + } + return p.downloadURL(parsed.Data.URL, filename) +} + +func (p *Plugin) downloadFromNapCat(fileID, filename string) string { + if p.filesDir == "" || fileID == "" { + return "" + } + os.MkdirAll(p.filesDir, 0755) + raw, err := p.napcat("get_file", map[string]interface{}{"file_id": fileID}) + if err != nil { + return "" + } + rawStr, _ := rawString(raw) + if rawStr == "" { + return "" + } + var resp struct { + Data *struct { + File string `json:"file"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + Base64 string `json:"base64"` + URL string `json:"url"` + } `json:"data"` + } + if json.Unmarshal([]byte(rawStr), &resp) != nil || resp.Data == nil { + return "" + } + info := resp.Data + if info.FileName != "" { + filename = info.FileName + } + if info.Base64 != "" { + data, err := base64.StdEncoding.DecodeString(info.Base64) + if err == nil { + savePath := filepath.Join(p.filesDir, sanitizeFilename(filename)) + os.WriteFile(savePath, data, 0644) + return savePath + } + } + if info.URL != "" { + return p.downloadURL(info.URL, filename) + } + if info.File != "" { + savePath := filepath.Join(p.filesDir, sanitizeFilename(filename)) + if err := os.WriteFile(savePath, []byte(info.File), 0644); err == nil { + return savePath + } + } + return "" +} + +func (p *Plugin) downloadURL(fileURL, filename string) string { + if p.filesDir == "" { + return "" + } + os.MkdirAll(p.filesDir, 0755) + if filename == "" { + filename = "file_" + filepath.Base(fileURL) + } + savePath := filepath.Join(p.filesDir, sanitizeFilename(filename)) + dlResp, err := p.httpClient.Get(fileURL) + if err != nil { + return "" + } + defer dlResp.Body.Close() + data, err := io.ReadAll(dlResp.Body) + if err != nil || len(data) == 0 { + return "" + } + os.WriteFile(savePath, data, 0644) + return savePath +} + +func sanitizeFilename(name string) string { + name = filepath.Base(name) + name = strings.Map(func(r rune) rune { + if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { + return '_' + } + return r + }, name) + return name +} + +// ======== Tool Handlers: Document / Video / Web ======== + +func (p *Plugin) handleReadDocument(args map[string]interface{}) (interface{}, error) { + path, _ := args["path"].(string) + if path == "" { + return nil, fmt.Errorf("path is required") + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return map[string]interface{}{ + "content": fmt.Sprintf("文件不存在: %s", path), + }, nil + } + + ext := strings.ToLower(filepath.Ext(path)) + textContent := "" + + switch ext { + case ".txt", ".md", ".csv": + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + textContent = string(data) + case ".docx", ".doc", ".epub", ".html", ".htm": + textContent = p.readWithPandoc(path) + default: + // Try pandoc first, fallback to libreoffice + textContent = p.readWithPandoc(path) + if textContent == "" { + textContent = p.readWithLibreoffice(path) + } + if textContent == "" { + // last resort: read as plain text + data, err := os.ReadFile(path) + if err == nil { + textContent = string(data) + } + } + } + + if textContent == "" { + return map[string]interface{}{ + "content": fmt.Sprintf("无法提取文件内容: %s(不支持的文件格式或文件损坏)", path), + }, nil + } + + // 截断到 20000 字符 + origLen := len(textContent) + truncated := origLen > 20000 + if truncated { + textContent = textContent[:20000] + } + + result := textContent + if truncated { + result += fmt.Sprintf("\n\n...(内容过长,仅显示前 20000 字符,共 %d 字符)", origLen) + } + return map[string]interface{}{ + "content": result, + "file": path, + "truncated": truncated, + }, nil +} + +func (p *Plugin) readWithPandoc(path string) string { + var out bytes.Buffer + cmd := exec.Command("pandoc", path, "-t", "plain", "--wrap=none") + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return "" + } + return strings.TrimSpace(out.String()) +} + +func (p *Plugin) readWithLibreoffice(path string) string { + tmpDir, err := os.MkdirTemp("", "lo-doc-*") + if err != nil { + return "" + } + defer os.RemoveAll(tmpDir) + + cmd := exec.Command("libreoffice", "--headless", "--convert-to", "txt:Text", "--outdir", tmpDir, path) + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return "" + } + + // 找生成的 txt 文件 + entries, err := os.ReadDir(tmpDir) + if err != nil { + return "" + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".txt") { + data, err := os.ReadFile(filepath.Join(tmpDir, e.Name())) + if err == nil { + return strings.TrimSpace(string(data)) + } + } + } + return "" +} + +func (p *Plugin) handleVideoDownload(args map[string]interface{}) (interface{}, error) { + url, _ := args["url"].(string) + if url == "" { + return nil, fmt.Errorf("url is required") + } + infoOnly, _ := args["info_only"].(bool) + + outputDir := filepath.Join(p.agentfsDir, "videos") + os.MkdirAll(outputDir, 0755) + + if infoOnly { + var out bytes.Buffer + cmd := exec.Command("yt-dlp", "--dump-json", url) + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("yt-dlp info: %w", err) + } + var info struct { + Title string `json:"title"` + Duration int `json:"duration"` + Webpage string `json:"webpage_url"` + Formats []struct { + FormatID string `json:"format_id"` + Ext string `json:"ext"` + Width int `json:"width"` + Height int `json:"height"` + Filesize int64 `json:"filesize"` + Format string `json:"format"` + } `json:"formats"` + } + if err := json.Unmarshal(out.Bytes(), &info); err != nil { + return string(out.String()), nil + } + dur := "" + if info.Duration > 0 { + dur = fmt.Sprintf("%d分%d秒", info.Duration/60, info.Duration%60) + } + lines := []string{fmt.Sprintf("🎬 %s", info.Title)} + if dur != "" { + lines = append(lines, fmt.Sprintf(" 时长: %s", dur)) + } + lines = append(lines, fmt.Sprintf(" 链接: %s", info.Webpage)) + lines = append(lines, "") + for _, f := range info.Formats { + fs := "" + if f.Filesize > 0 { + fs = fmt.Sprintf(" (%.1f MB)", float64(f.Filesize)/1048576) + } + res := "" + if f.Width > 0 && f.Height > 0 { + res = fmt.Sprintf(" %dx%d", f.Width, f.Height) + } + lines = append(lines, fmt.Sprintf(" [%s] %s%s%s", f.FormatID, f.Format, res, fs)) + } + return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil + } + + // 下载 + outputTmpl := filepath.Join(outputDir, "%(title)s.%(ext)s") + var out bytes.Buffer + cmd := exec.Command("yt-dlp", "-o", outputTmpl, "--no-playlist", "--print", "after_move:filepath", url) + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("yt-dlp download: %w", err) + } + + // 解析 yt-dlp 输出的文件路径 + dlPath := strings.TrimSpace(out.String()) + if dlPath == "" { + return map[string]interface{}{ + "content": "下载完成,但无法获取文件路径", + }, nil + } + dlFilename := filepath.Base(dlPath) + var fileSize int64 = 0 + if fi, err := os.Stat(dlPath); err == nil { + fileSize = fi.Size() + } + return map[string]interface{}{ + "content": fmt.Sprintf("✅ 下载完成: %s\n 大小: %.1f MB\n 路径: %s", dlFilename, float64(fileSize)/1048576, dlPath), + "file": dlPath, + "filename": dlFilename, + }, nil +} + +// ======== 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 := p.httpClient.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 raw, nil +} + +func (p *Plugin) setInputStatus(userID int64, eventType int) (interface{}, error) { + return p.napcat("set_input_status", map[string]interface{}{ + "user_id": userID, + "event_type": eventType, + }) +} + +func (p *Plugin) startTyping(userID int64) { + p.typingMu.Lock() + if _, ok := p.typingMap[userID]; ok { + p.typingMu.Unlock() + return + } + ts := &typingState{userID: userID, stopCh: make(chan struct{})} + p.typingMap[userID] = ts + p.typingMu.Unlock() + go p.typingLoop(ts) +} + +func (p *Plugin) stopTyping(userID int64) { + p.typingMu.Lock() + ts, ok := p.typingMap[userID] + if ok { + delete(p.typingMap, userID) + } + p.typingMu.Unlock() + if ok { + close(ts.stopCh) + } +} + +func (p *Plugin) typingLoop(ts *typingState) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + timeout := time.After(30 * time.Second) + for { + select { + case <-ticker.C: + p.setInputStatus(ts.userID, 1) + case <-ts.stopCh: + return + case <-timeout: + p.stopTyping(ts.userID) + return + } + } +} + +// ======== Helpers ======== + +// rawString extracts a string from napcat's return type (json.RawMessage or string). +func rawString(v interface{}) (string, bool) { + switch r := v.(type) { + case string: + return r, true + case json.RawMessage: + return string(r), true + case []byte: + return string(r), true + } + return "", false +} + +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 != "" { + 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) +} + +func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{ + name: name, + allowFrom: make(map[int64]struct{}), + groupAllowFrom: make(map[int64]struct{}), + downloadTasks: make([]*DownloadTask, 0), + typingMap: make(map[int64]*typingState), + dmPolicy: "open", + groupPolicy: "open", + }, nil +}