feat(qq): 权限门 + 单轮循环保险 + 极简发送回执 (v1.4.0)

回应 problem.md:核心把「发送成功」的富回执喂给模型,模型读成
「这步成功,继续下一步」而重复调用 output_send__qq。

- handleChannelOutput 成功只返回 "ok",不回传 NapCat 原始响应(含 message_id)
- 全局权限门 on_input/before_toolcall/post_action:工具白名单、私人资源边界、
  高风险命令 confirm 校验;before_toolcall 的 Response 只用于拒绝单个工具,
  post_action 负责清除以免被内核误当作结束推理的最终响应
- 单轮循环保险:max_qq_tool_calls=200 / max_qq_output_calls=20 /
  max_duplicate_qq_send=1,只拦参数完全相同的重复调用,不误杀必需的多次调用
- plg.json 1.2.0 → 1.4.0
This commit is contained in:
JianFeeeee
2026-09-10 20:36:43 +08:00
parent 5175e7d6e0
commit fd5a291df1
3 changed files with 694 additions and 39 deletions

View File

@ -2,7 +2,7 @@
"name": "qq",
"name_zh": "QQ消息",
"name_en": "qq",
"version": "1.2.0",
"version": "1.4.0",
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
"author": "HomeAgent",
"entry": "plugin.so",

View File

@ -85,6 +85,34 @@ type DownloadTask struct {
CreatedAt string `json:"created_at"`
}
type qqAuthContext struct {
active bool
owner bool
messageID int64
userID int64
groupID int64
isGroup bool
generation uint64
}
var qqMessageIDRe = regexp.MustCompile(`message_id=(-?\d+)`)
// 默认只开放公共信息与当前 QQ 会话所需能力。日历、邮件、记忆、知识库、
// 主机文件/命令、设备、配置、插件管理及 QQ 联系人/跨会话列表均不在白名单中。
const defaultPublicToolAllowlist = `["output_send__qq","output_list_channels","qq_get_message","qq_get_history","qq_mark_read","qq_get_group_member_info","qq_get_group_files","qq_video_download","weather_*","browser_search","browser_fetch","browser_render","ocr_*","multimodal_*","bili_*","music_*","ai_image_*"]`
const defaultGroupToolAllowlists = `{"*":["output_send__qq","output_list_channels","qq_get_message","qq_get_history","qq_mark_read","qq_get_group_member_info","qq_get_group_files","qq_video_download","weather_*","browser_search","browser_fetch","browser_render","ocr_*","multimodal_*","bili_*","music_*","ai_image_*"]}`
const (
// 单轮 QQ 触发的工具调用总数上限0 = 不限制)。只作跑飞兜底,
// 不应拦下正常的长时间多步任务。
defaultMaxQQToolCalls = 200
// 单轮 QQ 主动发送的不同消息条数上限0 = 不限制)。
defaultMaxQQOutputCalls = 20
// 单轮内同一条消息参数完全相同允许重复发送的次数0 = 不限制)。
defaultMaxDuplicateSend = 1
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
@ -93,7 +121,7 @@ type Plugin struct {
remoteDir string
filesDir string
webhookToken string
adminIDs []int64
ownerIDs []int64
botID int64
botNickname string
dmPolicy string
@ -108,12 +136,26 @@ type Plugin struct {
typingMu sync.Mutex
typingMap map[int64]*typingState
// msg_id → peer 映射 + 会话最新状态(<7 天兜底 get_history + list_chats
msgMu sync.Mutex
msgMap map[int64]msgRef // message_id → {peer, time}
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
}
authMu sync.RWMutex
auth qqAuthContext
authGeneration uint64
authByMessageID map[int64]qqAuthContext
lastDenial string
denialLocked bool
toolCallCount int
outputCallCount int
outputSignatures map[string]int
maxQQToolCalls int
maxQQOutputCalls int
maxDuplicateSend int
groupToolAllowlists map[int64][]string // 0 表示通配配置 "*"
privateToolAllowlist []string
// msg_id → peer 映射 + 会话最新状态(<7 天兜底 get_history + list_chats
msgMu sync.Mutex
msgMap map[int64]msgRef // message_id → {peer, time}
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
}
type typingState struct {
userID int64
@ -258,7 +300,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号列表,逗号分隔。收到其消息时标记【重要!老大消息】", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "owner", Default: "", Type: "string", DisplayName: "Bot 所有者 QQ", Description: "Bot 所有者 QQ 号列表,逗号分隔。所有者无论私聊或群聊均拥有完整工具权限", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "Bot 所有者 QQ旧配置", Description: "兼容旧版 admin 配置owner 为空时作为 Bot 所有者列表", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_tool_allowlists", Default: defaultGroupToolAllowlists, Type: "string", DisplayName: "群聊工具白名单", Description: "JSON 对象:群号到允许工具名/前缀*的数组;* 为未单独配置群的默认白名单。Bot 所有者不受限制", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "private_tool_allowlist", Default: defaultPublicToolAllowlist, Type: "string", DisplayName: "非所有者私聊工具白名单", Description: "JSON 数组,支持工具精确名和尾部 * 前缀。硬性私人资源工具不能由此白名单放行Bot 所有者不受权限限制", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_qq_tool_calls", Default: defaultMaxQQToolCalls, Type: "int", DisplayName: "单轮 QQ 工具调用上限", Description: "QQ 输入触发的单轮推理最多调用工具次数0=不限制);仅作跑飞兜底,不拦参数不同的必需调用", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_qq_output_calls", Default: defaultMaxQQOutputCalls, Type: "int", DisplayName: "单轮 QQ 发送上限", Description: "单轮最多主动发送的不同消息条数0=不限制);参数不同的消息不视为重复", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_duplicate_qq_send", Default: defaultMaxDuplicateSend, Type: "int", DisplayName: "单轮相同 QQ 发送上限", Description: "单轮内参数完全相同的 output_send__qq 允许重复的次数0=不限制);这才是循环保险的真正触发条件", 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"}})
@ -274,7 +322,16 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
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", ""))
ownerRaw := getSetting[string](settings, "owner", "")
if strings.TrimSpace(ownerRaw) == "" {
ownerRaw = getSetting[string](settings, "admin", "")
}
p.ownerIDs = parseIDList(ownerRaw)
p.groupToolAllowlists = parseGroupToolAllowlists(getSetting[string](settings, "group_tool_allowlists", defaultGroupToolAllowlists))
p.privateToolAllowlist = parseToolAllowlist(getSetting[string](settings, "private_tool_allowlist", defaultPublicToolAllowlist))
p.maxQQToolCalls = nonNegativeOrDefault(int(getSetting[int64](settings, "max_qq_tool_calls", int64(defaultMaxQQToolCalls))), defaultMaxQQToolCalls)
p.maxQQOutputCalls = nonNegativeOrDefault(int(getSetting[int64](settings, "max_qq_output_calls", int64(defaultMaxQQOutputCalls))), defaultMaxQQOutputCalls)
p.maxDuplicateSend = nonNegativeOrDefault(int(getSetting[int64](settings, "max_duplicate_qq_send", int64(defaultMaxDuplicateSend))), defaultMaxDuplicateSend)
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", ""))
@ -312,10 +369,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
`发送QQ群聊/私聊消息,支持文字、语音、图片、文件。
meta JSON 格式:
{
"group_id": 123456, // 群号(与 user_id 二选一)
"user_id": 123456, // QQ号(与 group_id 二选一)
"group_id": 123456, // 群号
"user_id": 123456, // QQ号
"reply_to": 12345 // 可选,回复指定消息 ID
}
路由规则:仅 group_id 发群;仅 user_id 发私聊;两者同时存在时发到 group_id并在消息头 @user_id。
type 枚举: text文字/ voice语音转文字后发送/ image图片URL/ file文件URL`,
sdk.ChannelDef{}, p.handleChannelOutput)
@ -599,7 +657,13 @@ type 枚举: text文字/ voice语音转文字后发送/ image
NoMemory: true,
}, p.handleSendLike)
s.RegisterStage(sdk.StageBeforeToolcall, p.beforeOwnToolcall, sdk.StageScopeOwnTools)
// 全局权限门:只有 QQ 当前输入需要受此插件约束Bot 所有者始终完整放行。
s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageBeforeToolcall, p.beforeToolcall, sdk.StageScopeGlobal)
// before_toolcall 的 Response 只用于拒绝当前工具。下一次模型补全到达时必须清掉,
// 否则内核会把它误当作“结束整个推理”的最终响应。
s.RegisterStage(sdk.StagePostAction, p.clearDeniedResponse, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageAfterOutput, p.afterOutputAuthContext, sdk.StageScopeGlobal)
// ---- HTTP server for NapCat webhook ----
mux := http.NewServeMux()
@ -757,8 +821,8 @@ func parseIDList(raw string) []int64 {
return out
}
func (p *Plugin) isAdmin(userID int64) bool {
for _, id := range p.adminIDs {
func (p *Plugin) isOwner(userID int64) bool {
for _, id := range p.ownerIDs {
if id == userID {
return true
}
@ -766,6 +830,261 @@ func (p *Plugin) isAdmin(userID int64) bool {
return false
}
func parseToolAllowlist(raw string) []string {
var patterns []string
if json.Unmarshal([]byte(raw), &patterns) != nil {
return nil
}
out := make([]string, 0, len(patterns))
for _, pattern := range patterns {
if pattern = strings.TrimSpace(pattern); pattern != "" {
out = append(out, pattern)
}
}
return out
}
func parseGroupToolAllowlists(raw string) map[int64][]string {
var encoded map[string][]string
if json.Unmarshal([]byte(raw), &encoded) != nil {
return map[int64][]string{}
}
out := make(map[int64][]string, len(encoded))
for key, patterns := range encoded {
var groupID int64
if key != "*" {
parsed, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64)
if err != nil || parsed <= 0 {
continue
}
groupID = parsed
}
clean := make([]string, 0, len(patterns))
for _, pattern := range patterns {
if pattern = strings.TrimSpace(pattern); pattern != "" {
clean = append(clean, pattern)
}
}
out[groupID] = clean
}
return out
}
func matchesToolAllowlist(name string, patterns []string) bool {
for _, pattern := range patterns {
if pattern == name {
return true
}
if strings.HasSuffix(pattern, "*") && strings.HasPrefix(name, strings.TrimSuffix(pattern, "*")) {
return true
}
}
return false
}
// nonNegativeOrDefault 保留 0表示“不限制”仅把负数纠正为默认值。
func nonNegativeOrDefault(value, fallback int) int {
if value < 0 {
return fallback
}
return value
}
// isHardPrivateTool 是不可由群/私聊白名单覆盖的私人资源边界。
// output_send__qq 及 QQ 当前会话工具在参数级另行约束,不在此处按名称误杀。
func isHardPrivateTool(name string) bool {
for _, prefix := range []string{
"calendar_", "email_", "mail_", "agentmail_", "memory_", "knowledge_",
"device_", "devicectl_", "terminal_", "shell_", "command_", "exec_",
"filesystem_", "agentfs_", "config_", "settings_", "plugin_", "plugins_",
} {
if strings.HasPrefix(name, prefix) {
return true
}
}
return matchesToolAllowlist(name, []string{
"read_file", "write_file", "edit_file", "delete_file", "list_files", "run_command",
"homeagent_config", "homeagent_restart", "output_send__email", "output_send__mail",
})
}
func argInt64(args map[string]interface{}, key string) (int64, bool) {
value, exists := args[key]
if !exists || value == nil {
return 0, false
}
parsed, err := convInt64(value)
return parsed, err == nil && parsed != 0
}
func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}, auth qqAuthContext) (bool, string) {
if !auth.active || auth.owner {
return true, ""
}
currentPeer := auth.userID
if auth.isGroup {
currentPeer = auth.groupID
}
if currentPeer == 0 {
return false, "可信 QQ 会话身份不完整"
}
matchCurrentPeer := func() bool {
groupID, hasGroup := argInt64(args, "group_id")
userID, hasUser := argInt64(args, "user_id")
if auth.isGroup {
return hasGroup && groupID == auth.groupID && !hasUser
}
return hasUser && userID == auth.userID && !hasGroup
}
switch name {
case p.name + "_get_history", p.name + "_mark_read":
if !matchCurrentPeer() {
return false, "只能访问当前 QQ 会话"
}
case p.name + "_get_message":
messageID, ok := argInt64(args, "message_id")
if !ok {
return false, "缺少有效 message_id"
}
if messageID == auth.messageID {
return true, ""
}
peerID, isGroup, _, found := p.lookupMsgRef(messageID)
if !found || isGroup != auth.isGroup || peerID != currentPeer {
return false, "message_id 不属于当前 QQ 会话"
}
case p.name + "_get_group_member_info", p.name + "_get_group_files":
groupID, ok := argInt64(args, "group_id")
if !auth.isGroup || !ok || groupID != auth.groupID {
return false, "只能访问当前 QQ 群的数据"
}
}
return true, ""
}
// activateAuthContext 只接收 OneBot 事件中的可信 ID。多个中断在同一推理轮合并时
// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。
// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。
func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup bool) {
p.authMu.Lock()
defer p.authMu.Unlock()
if p.authByMessageID == nil {
p.authByMessageID = make(map[int64]qqAuthContext)
}
p.authGeneration++
next := qqAuthContext{
active: true, owner: p.isOwner(userID), messageID: messageID, userID: userID,
groupID: groupID, isGroup: isGroup, generation: p.authGeneration,
}
if messageID != 0 {
p.authByMessageID[messageID] = next
if len(p.authByMessageID) > 2048 {
cutoff := p.authGeneration - 1024
for id, auth := range p.authByMessageID {
if auth.generation < cutoff {
delete(p.authByMessageID, id)
}
}
}
}
if !p.auth.active {
p.auth = next
return
}
if p.auth.userID == userID && p.auth.groupID == groupID && p.auth.isGroup == isGroup {
p.auth.owner = p.auth.owner && next.owner
p.auth.messageID = next.messageID
p.auth.generation = next.generation
return
}
// 不同可信来源被内核合并到同一推理时,只在双方都是所有者时保留完整权限。
bothOwners := p.auth.owner && next.owner
sameGroup := p.auth.isGroup && next.isGroup && p.auth.groupID == groupID
p.auth.owner = bothOwners
p.auth.messageID = 0
p.auth.userID = 0
p.auth.isGroup = sameGroup
if sameGroup {
p.auth.groupID = groupID
} else {
p.auth.groupID = 0
}
p.auth.generation = next.generation
}
func messageIDFromInput(raw string) int64 {
match := qqMessageIDRe.FindStringSubmatch(raw)
if len(match) != 2 {
return 0
}
id, _ := strconv.ParseInt(match[1], 10, 64)
return id
}
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
ctx.RLock()
source, _ := ctx.Extra["input_source"].(string)
raw := ctx.RawMessage
ctx.RUnlock()
p.authMu.Lock()
defer p.authMu.Unlock()
if source != p.name {
p.auth = qqAuthContext{}
p.resetTurnGuardLocked()
return nil
}
if messageID := messageIDFromInput(raw); messageID != 0 {
if auth, ok := p.authByMessageID[messageID]; ok {
p.auth = auth
delete(p.authByMessageID, messageID)
p.resetTurnGuardLocked()
return nil
}
}
// QQ 来源却无法精确匹配可信 OneBot 事件时必须强制降权,不能复用上一条消息的身份。
p.auth = qqAuthContext{active: true}
p.resetTurnGuardLocked()
return nil
}
func (p *Plugin) resetTurnGuardLocked() {
p.lastDenial = ""
p.denialLocked = false
p.toolCallCount = 0
p.outputCallCount = 0
p.outputSignatures = make(map[string]int)
}
func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error {
p.authMu.Lock()
p.auth = qqAuthContext{}
p.resetTurnGuardLocked()
p.authMu.Unlock()
return nil
}
func (p *Plugin) currentToolAllowed(name string) (bool, qqAuthContext) {
p.authMu.RLock()
auth := p.auth
var patterns []string
if auth.active && !auth.owner {
if auth.isGroup {
patterns, _ = p.groupToolAllowlists[auth.groupID]
if patterns == nil {
patterns = p.groupToolAllowlists[0]
}
} else {
patterns = p.privateToolAllowlist
}
}
p.authMu.RUnlock()
if !auth.active || auth.owner {
return true, auth
}
return matchesToolAllowlist(name, patterns), auth
}
// isAtBot checks if the message contains an @-mention of the bot.
func (p *Plugin) isAtBot(msg interface{}) bool {
segments, ok := msg.([]interface{})
@ -824,19 +1143,110 @@ func (p *Plugin) isGroupAllowed(groupID int64) bool {
}
}
func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error {
func (p *Plugin) setDenial(ctx *sdk.StageContext, message string) {
ctx.Response = &message
p.authMu.Lock()
p.lastDenial = message
p.authMu.Unlock()
}
func (p *Plugin) clearPreviousDenial(ctx *sdk.StageContext) {
p.authMu.Lock()
last := p.lastDenial
p.lastDenial = ""
p.authMu.Unlock()
if last != "" && ctx.Response != nil && *ctx.Response == last {
ctx.Response = nil
}
}
func (p *Plugin) clearDeniedResponse(ctx *sdk.StageContext) error {
ctx.Lock()
defer ctx.Unlock()
p.clearPreviousDenial(ctx)
return nil
}
func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
ctx.Lock()
defer ctx.Unlock()
p.clearPreviousDenial(ctx)
if len(ctx.ToolCalls) == 0 {
return nil
}
tc := &ctx.ToolCalls[0]
allowed, auth := p.currentToolAllowed(tc.Name)
if !auth.active {
return nil
}
p.authMu.Lock()
p.toolCallCount++
toolCount := p.toolCallCount
denialLocked := p.denialLocked
if tc.Name == "output_send__"+p.name {
p.outputCallCount++
signatureBytes, _ := json.Marshal(tc.Arguments)
signature := string(signatureBytes)
p.outputSignatures[signature]++
duplicateCount := p.outputSignatures[signature]
distinctCount := len(p.outputSignatures)
// 循环保险只拦“参数完全相同的重复调用”。参数不同的必需调用一律放行,
// 否则多次 cmd_run / update_schedule / 多条不同消息都会被误杀。
if p.maxDuplicateSend > 0 && duplicateCount > p.maxDuplicateSend {
p.authMu.Unlock()
msg := fmt.Sprintf("QQ 循环保险已阻止重复发送:本轮第 %d 次出现参数完全相同的消息;请勿重复发送同一内容", duplicateCount)
p.setDenial(ctx, msg)
return nil
}
if p.maxQQOutputCalls > 0 && distinctCount > p.maxQQOutputCalls {
p.authMu.Unlock()
msg := fmt.Sprintf("QQ 循环保险已阻止本次发送:单轮主动发送的不同消息数已达上限 %d0=不限制,可在插件配置调整)", p.maxQQOutputCalls)
p.setDenial(ctx, msg)
return nil
}
}
if p.maxQQToolCalls > 0 && toolCount > p.maxQQToolCalls {
p.authMu.Unlock()
msg := fmt.Sprintf("QQ 循环保险已阻止工具调用:单轮工具调用总数已达上限 %d0=不限制,可在插件配置调整)", p.maxQQToolCalls)
p.setDenial(ctx, msg)
return nil
}
p.authMu.Unlock()
if denialLocked && tc.Name != "output_send__"+p.name {
msg := fmt.Sprintf("QQ 权限策略已锁止本轮后续工具 %s仅允许发送一次权限说明", tc.Name)
p.setDenial(ctx, msg)
return nil
}
if !auth.owner && isHardPrivateTool(tc.Name) {
p.authMu.Lock()
p.denialLocked = true
p.authMu.Unlock()
msg := fmt.Sprintf("QQ 权限策略拒绝私人资源工具 %s该限制不能由群聊或私聊白名单覆盖请不要重试", tc.Name)
p.setDenial(ctx, msg)
return nil
}
if !allowed {
scope := "非所有者私聊"
if auth.isGroup {
scope = fmt.Sprintf("群聊 %d", auth.groupID)
}
msg := fmt.Sprintf("QQ 权限策略拒绝工具 %s%s 的工具白名单未包含该工具;请不要重试,改为直接说明权限限制", tc.Name, scope)
p.setDenial(ctx, msg)
return nil
}
if argsAllowed, reason := p.sessionToolArgsAllowed(tc.Name, tc.Arguments, auth); !argsAllowed {
msg := fmt.Sprintf("QQ 权限策略拒绝工具 %s%s请不要改用其他会话 ID 重试", tc.Name, reason)
p.setDenial(ctx, msg)
return nil
}
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
p.setDenial(ctx, msg)
return nil
}
}
@ -846,7 +1256,7 @@ func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error {
if requiresConfirmFriendCommand(cmd) {
if ok, _ := tc.Arguments["confirm"].(bool); !ok {
msg := fmt.Sprintf("QQ好友管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd)
ctx.Response = &msg
p.setDenial(ctx, msg)
return nil
}
}
@ -978,8 +1388,8 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
} else {
interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d, user_id=%d)。先用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(user_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复对方", nickname, evt.MessageID, evt.UserID, tp, evt.MessageID, tp, evt.UserID, tp, outputTool)
}
if p.isAdmin(evt.UserID) {
interrupt = "【重要!老大消息】" + interrupt
if p.isOwner(evt.UserID) {
interrupt = "【重要!Bot 所有者消息】" + interrupt
}
if text != "" {
@ -1012,6 +1422,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
}
}
// 必须在注入前记录 OneBot 可信来源;权限判断绝不依赖昵称、正文或模型参数。
p.activateAuthContext(evt.MessageID, evt.UserID, evt.GroupID, evt.MessageType == "group")
if evt.MessageType == "private" {
p.startTyping(evt.UserID)
}
@ -1320,6 +1733,8 @@ func (p *Plugin) getMsgFromNapcat(msgID int64) (interface{}, error) {
// handleChannelOutput — output_send(channel="qq") 的处理器
// args 包含 payload, type, (可选 meta)
// 成功时只返回极简标记,不回传 NapCat 完整响应——避免"已发送"类富回执喂给模型
// 造成"看到成功→继续发下一条"的回声循环issue: output loop echo
func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
rawType, _ := args["type"].(string)
@ -1352,10 +1767,15 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
p.stopTyping(userID)
}
var sendErr error
switch rawType {
case "text":
text := p.sensitiveFilter(payload)
msg := map[string]interface{}{"message": text}
message := interface{}(text)
if groupID != 0 && userID != 0 {
message = messageWithMention(userID, map[string]interface{}{"type": "text", "data": map[string]interface{}{"text": text}})
}
msg := map[string]interface{}{"message": message}
if groupID != 0 {
msg["group_id"] = groupID
} else {
@ -1365,9 +1785,10 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
msg["reply_to"] = replyTo
}
if groupID != 0 {
return p.napcat("send_group_msg", msg)
_, sendErr = p.napcat("send_group_msg", msg)
} else {
_, sendErr = p.napcat("send_private_msg", msg)
}
return p.napcat("send_private_msg", msg)
case "voice", "audio":
text := p.sensitiveFilter(payload)
@ -1387,21 +1808,23 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
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}
message := interface{}(cqMsg)
if groupID != 0 && userID != 0 {
message = messageWithMention(userID, map[string]interface{}{"type": "record", "data": map[string]interface{}{"file": uri}})
}
msg := map[string]interface{}{"message": message}
if groupID != 0 {
msg["group_id"] = groupID
} else {
msg["user_id"] = userID
}
if groupID != 0 {
return p.napcat("send_group_msg", msg)
_, sendErr = p.napcat("send_group_msg", msg)
} else {
_, sendErr = p.napcat("send_private_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://") {
@ -1423,34 +1846,48 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
if rawType == "image" {
cqTag = "image"
}
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)}
message := interface{}(fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri))
if groupID != 0 && userID != 0 {
message = messageWithMention(userID, map[string]interface{}{"type": cqTag, "data": map[string]interface{}{"file": uri}})
}
msg := map[string]interface{}{"message": message}
if groupID != 0 {
msg["group_id"] = groupID
} else {
msg["user_id"] = userID
}
if groupID != 0 {
return p.napcat("send_group_msg", msg)
_, sendErr = p.napcat("send_group_msg", msg)
} else {
_, sendErr = p.napcat("send_private_msg", msg)
}
return p.napcat("send_private_msg", msg)
default:
return nil, fmt.Errorf("不支持的 type: %s枚举值: text/voice/image/file", rawType)
}
if sendErr != nil {
return nil, sendErr
}
// 成功:返回极简标记。不再回传 NapCat 原始响应(含 message_id 等)给模型,
// 避免模型把"发送成功"当成"上一步完成,继续下一步"的信号驱动循环。
return "ok", nil
}
func (p *Plugin) buildOutputHelp() string {
return `【参数】
payload — 消息载荷。type=text时直接填文字type=voice时填文字自动转语音type=image/file时填URL
meta — JSON 元数据,含 group_id群聊或 user_id私聊可选 reply_to
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="你好", meta="{\"group_id\":123456789,\"user_id\":987654321}", type="text")
发送图片output_send__qq(payload="https://example.com/img.jpg", meta="{\"group_id\":123456789}", type="image")
【注意】
- group_id 与 user_id 同时存在时始终发送到 group_id并在消息头 @user_id
- type=text 时 payload 直接是文字,无需 JSON 包裹
- type=voice 时 payload 是文字内容,自动转语音发送
- type=image/file 时 payload 是 URL 或路径`
@ -2557,6 +2994,13 @@ func (p *Plugin) handleVideoDownload(args map[string]interface{}) (interface{},
// ======== NapCat HTTP Client ========
func messageWithMention(userID int64, content interface{}) []interface{} {
return []interface{}{
map[string]interface{}{"type": "at", "data": map[string]interface{}{"qq": strconv.FormatInt(userID, 10)}},
content,
}
}
func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) {
data, _ := json.Marshal(params)
url := fmt.Sprintf("%s/%s", p.napcatURL, action)
@ -2675,12 +3119,19 @@ func convInt64(v interface{}) (int64, error) {
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",
name: name,
allowFrom: make(map[int64]struct{}),
groupAllowFrom: make(map[int64]struct{}),
authByMessageID: make(map[int64]qqAuthContext),
outputSignatures: make(map[string]int),
maxQQToolCalls: defaultMaxQQToolCalls,
maxQQOutputCalls: defaultMaxQQOutputCalls,
maxDuplicateSend: defaultMaxDuplicateSend,
groupToolAllowlists: parseGroupToolAllowlists(defaultGroupToolAllowlists),
privateToolAllowlist: parseToolAllowlist(defaultPublicToolAllowlist),
downloadTasks: make([]*DownloadTask, 0),
typingMap: make(map[int64]*typingState),
dmPolicy: "open",
groupPolicy: "open",
}, nil
}

204
example/qq/plugin_test.go Normal file
View File

@ -0,0 +1,204 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func newPermissionTestPlugin(t *testing.T) *Plugin {
t.Helper()
instance, err := NewPluginFactory("qq", nil)
if err != nil {
t.Fatal(err)
}
return instance.(*Plugin)
}
func toolCallContext(name string, args map[string]interface{}) *sdk.StageContext {
return &sdk.StageContext{ToolCalls: []sdk.ToolCall{{Name: name, Arguments: args}}}
}
func TestOwnerBypassesQQPermissionBoundary(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
ctx := toolCallContext("calendar_list", nil)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("owner call rejected: %s", *ctx.Response)
}
}
func TestPrivateResourceCannotBeAllowlisted(t *testing.T) {
p := newPermissionTestPlugin(t)
p.privateToolAllowlist = append(p.privateToolAllowlist, "calendar_*")
p.auth = qqAuthContext{active: true, userID: 10001}
ctx := toolCallContext("calendar_list", nil)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "私人资源工具") {
t.Fatalf("expected private-resource denial, got %#v", ctx.Response)
}
}
func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, messageID: 88, userID: 10001, groupID: 20002, isGroup: true}
ctx := toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20003)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "当前 QQ 会话") {
t.Fatalf("cross-group history not rejected: %#v", ctx.Response)
}
ctx = toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20002)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("current-group history rejected: %s", *ctx.Response)
}
}
func TestUnmatchedQQInputIsDowngraded(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
ctx := &sdk.StageContext{
RawMessage: "来自未知事件(message_id=404)",
Extra: map[string]interface{}{"input_source": "qq"},
}
if err := p.onInputAuthContext(ctx); err != nil {
t.Fatal(err)
}
if !p.auth.active || p.auth.owner || p.auth.userID != 0 {
t.Fatalf("unmatched input reused prior privilege: %+v", p.auth)
}
}
func TestDuplicateQQOutputIsStopped(t *testing.T) {
p := newPermissionTestPlugin(t)
p.maxDuplicateSend = 1
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
args := map[string]interface{}{"payload": "same", "type": "text", "meta": `{"user_id":123}`}
ctx := toolCallContext("output_send__qq", args)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("first send rejected: %s", *ctx.Response)
}
ctx = toolCallContext("output_send__qq", args)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "循环保险") {
t.Fatalf("duplicate send not stopped: %#v", ctx.Response)
}
}
func TestGroupAndUserRouteAddsLeadingMention(t *testing.T) {
var path string
var request map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","retcode":0,"data":{"message_id":1}}`))
}))
defer server.Close()
p := newPermissionTestPlugin(t)
p.napcatURL = server.URL
p.httpClient = server.Client()
_, err := p.handleChannelOutput(map[string]interface{}{
"payload": "hello",
"type": "text",
"meta": `{"group_id":20002,"user_id":10001}`,
})
if err != nil {
t.Fatal(err)
}
if path != "/send_group_msg" {
t.Fatalf("path=%q, want /send_group_msg", path)
}
segments, ok := request["message"].([]interface{})
if !ok || len(segments) < 2 {
t.Fatalf("message is not a segment array: %#v", request["message"])
}
mention, _ := segments[0].(map[string]interface{})
data, _ := mention["data"].(map[string]interface{})
if mention["type"] != "at" || data["qq"] != "10001" {
t.Fatalf("leading mention=%#v", mention)
}
}
// 回归:循环保险曾按“总数”拦截,导致参数不同且必需的调用被误杀。
// 现在只拦参数完全相同的重复调用。
func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
// maxDuplicateSend 默认 1同一条消息重复才会被拦不同消息必须全部放行。
for i := 0; i < 5; i++ {
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": fmt.Sprintf("message-%d", i),
"type": "text",
"meta": `{"user_id":123}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("distinct message %d was blocked: %s", i, *ctx.Response)
}
}
}
func TestDistinctNecessaryToolCallsAreNotBlocked(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
// 旧实现 maxQQToolCalls=32 会在第 33 个不同参数的必需调用处误拦。
for i := 0; i < 50; i++ {
ctx := toolCallContext("cmd_run", map[string]interface{}{"command": fmt.Sprintf("cmd-%d", i)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("necessary tool call %d was blocked: %s", i, *ctx.Response)
}
}
}
func TestZeroLimitsMeanUnlimited(t *testing.T) {
p := newPermissionTestPlugin(t)
p.maxQQOutputCalls = 0
p.maxDuplicateSend = 0
p.maxQQToolCalls = 0
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
for i := 0; i < 30; i++ {
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": "same-content",
"type": "text",
"meta": `{"user_id":123}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("0 should mean unlimited, blocked at %d: %s", i, *ctx.Response)
}
}
}