diff --git a/internal/handler/agent.go b/internal/handler/agent.go index d4afd4ed..f5ac3469 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -729,7 +729,7 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry( } // ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:Eino 单/多代理执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复 -func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform string, principal authctx.Principal, conversationID, message, role string) (response string, convID string, err error) { +func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform string, principal authctx.Principal, conversationID, message, role, agentMode string) (response string, convID string, err error) { ownerUserID := strings.TrimSpace(principal.UserID) if ownerUserID == "" { return "", "", fmt.Errorf("authenticated robot principal is required") @@ -814,18 +814,14 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform stri } progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil) - robotMode := "eino_single" - if h.config != nil { - robotMode = config.NormalizeRobotAgentMode(h.config.MultiAgent) - } + robotMode := config.NormalizeAgentMode(agentMode) switch robotMode { case "eino_single": return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus) case "deep", "plan_execute", "supervisor": if h.config == nil || !h.config.MultiAgent.Enabled { - h.logger.Warn("机器人配置为多代理模式但未启用 multi_agent,回退 Eino 单代理", - zap.String("robot_mode", robotMode)) - return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus) + taskStatus = "failed" + return "", conversationID, fmt.Errorf("机器人对话模式 %s 需要启用 Eino 多代理", robotMode) } return h.runRobotMultiAgentWithRetry(taskCtx, conversationID, finalMessage, robotMode, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus) } diff --git a/internal/handler/monitor.go b/internal/handler/monitor.go index fe5af636..ef0a5bc1 100644 --- a/internal/handler/monitor.go +++ b/internal/handler/monitor.go @@ -69,7 +69,7 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) { h.agentHandler = ah } -const monitorPageTopTools = 6 +const monitorPageTopTools = 3 // MonitorStatsSummary 工具调用汇总 type MonitorStatsSummary struct { diff --git a/internal/handler/robot.go b/internal/handler/robot.go index 557fd253..a4cc1fce 100644 --- a/internal/handler/robot.go +++ b/internal/handler/robot.go @@ -41,11 +41,14 @@ const ( robotCmdContinue = "继续" robotCmdNew = "新对话" robotCmdClear = "清空" - robotCmdCurrent = "当前" + robotCmdStatus = "状态" robotCmdStop = "停止" robotCmdRoles = "角色" robotCmdRolesList = "角色列表" robotCmdSwitchRole = "切换角色" + robotCmdModes = "模式" + robotCmdModesList = "模式列表" + robotCmdSwitchMode = "切换模式" robotCmdDelete = "删除" robotCmdVersion = "版本" robotCmdProjects = "项目" @@ -56,35 +59,51 @@ const ( robotCmdBindUser = "绑定" robotCmdUnbindUser = "解绑" robotCmdIdentity = "身份" + robotCmdTask = "任务" + robotCmdRename = "重命名" + robotCmdPermissions = "权限" + robotCmdDoctor = "诊断" + robotCmdConfirm = "确认" + robotCmdCancel = "取消" robotBindingCodeTTL = 5 * time.Minute ) +type robotPendingConfirmation struct { + Action string + Target string + ExpiresAt time.Time +} + // RobotHandler 企业微信/钉钉/飞书等机器人回调处理 type RobotHandler struct { - config *config.Config - db *database.DB - agentHandler *AgentHandler - logger *zap.Logger - mu sync.RWMutex - sessions map[string]string // key: "platform_userID", value: conversationID - sessionRoles map[string]string // key: "platform_userID", value: roleName(默认"默认") - cancelMu sync.Mutex // 保护 runningCancels - runningCancels map[string]context.CancelFunc // key: "platform_userID", 用于停止命令中断任务 - wecomReplay map[string]time.Time - audit *audit.Service + config *config.Config + db *database.DB + agentHandler *AgentHandler + logger *zap.Logger + mu sync.RWMutex + sessions map[string]string // key: "platform_userID", value: conversationID + sessionRoles map[string]string // key: "platform_userID", value: roleName(默认"默认") + sessionModes map[string]string // key: "platform_userID", value: agent mode + cancelMu sync.Mutex // 保护 runningCancels + runningCancels map[string]context.CancelFunc // key: "platform_userID", 用于停止命令中断任务 + wecomReplay map[string]time.Time + pendingConfirmations map[string]robotPendingConfirmation + audit *audit.Service } // NewRobotHandler 创建机器人处理器 func NewRobotHandler(cfg *config.Config, db *database.DB, agentHandler *AgentHandler, logger *zap.Logger) *RobotHandler { return &RobotHandler{ - config: cfg, - db: db, - agentHandler: agentHandler, - logger: logger, - sessions: make(map[string]string), - sessionRoles: make(map[string]string), - runningCancels: make(map[string]context.CancelFunc), - wecomReplay: make(map[string]time.Time), + config: cfg, + db: db, + agentHandler: agentHandler, + logger: logger, + sessions: make(map[string]string), + sessionRoles: make(map[string]string), + sessionModes: make(map[string]string), + runningCancels: make(map[string]context.CancelFunc), + wecomReplay: make(map[string]time.Time), + pendingConfirmations: make(map[string]robotPendingConfirmation), } } @@ -175,26 +194,26 @@ func (h *RobotHandler) robotAccessDeniedMessage(platform string) string { return "当前平台账号尚未绑定 CyberStrikeAI 用户。请先在网页端生成绑定码,然后发送:绑定 XXXX-XXXX" } -func (h *RobotHandler) loadSessionBinding(sk string) (convID, role string) { +func (h *RobotHandler) loadSessionBinding(sk string) (convID, role, agentMode string) { if h.db == nil || strings.TrimSpace(sk) == "" { - return "", "" + return "", "", "" } binding, err := h.db.GetRobotSessionBinding(sk) if err != nil { h.logger.Warn("读取机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err)) - return "", "" + return "", "", "" } if binding == nil { - return "", "" + return "", "", "" } - return binding.ConversationID, binding.RoleName + return binding.ConversationID, binding.RoleName, binding.AgentMode } -func (h *RobotHandler) persistSessionBinding(sk, convID, role string) { +func (h *RobotHandler) persistSessionBinding(sk, convID, role, agentMode string) { if h.db == nil || strings.TrimSpace(sk) == "" || strings.TrimSpace(convID) == "" { return } - if err := h.db.UpsertRobotSessionBinding(sk, convID, role); err != nil { + if err := h.db.UpsertRobotSessionBinding(sk, convID, role, agentMode); err != nil { h.logger.Warn("写入机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err)) } } @@ -219,7 +238,7 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string, a if convID != "" && access.Permissions["chat:read"] && h.db.UserCanAccessResource(ownerID, readScope, "conversation", convID) { return convID, false } - if persistedConvID, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedConvID) != "" { + if persistedConvID, persistedRole, persistedMode := h.loadSessionBinding(sk); strings.TrimSpace(persistedConvID) != "" { if !access.Permissions["chat:read"] || !h.db.UserCanAccessResource(ownerID, readScope, "conversation", persistedConvID) { h.deleteSessionBinding(sk) } else { @@ -229,6 +248,9 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string, a if strings.TrimSpace(persistedRole) != "" { h.sessionRoles[sk] = persistedRole } + if strings.TrimSpace(persistedMode) != "" { + h.sessionModes[sk] = config.NormalizeAgentMode(persistedMode) + } h.mu.Unlock() return persistedConvID, false } @@ -256,9 +278,13 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string, a _ = h.db.SetResourceOwner("conversation", convID, ownerID) h.mu.Lock() role := h.sessionRoles[sk] + agentMode := h.sessionModes[sk] h.sessions[sk] = convID h.mu.Unlock() - h.persistSessionBinding(sk, convID, role) + if agentMode == "" { + agentMode = config.NormalizeRobotAgentMode(h.config.MultiAgent) + } + h.persistSessionBinding(sk, convID, role, agentMode) return convID, true } @@ -267,9 +293,10 @@ func (h *RobotHandler) setConversation(platform, userID, convID string) { sk := h.sessionKey(platform, userID) h.mu.Lock() role := h.sessionRoles[sk] + agentMode := h.sessionModes[sk] h.sessions[sk] = convID h.mu.Unlock() - h.persistSessionBinding(sk, convID, role) + h.persistSessionBinding(sk, convID, role, agentMode) } // getRole 获取当前用户使用的角色,未设置时返回"默认" @@ -281,7 +308,7 @@ func (h *RobotHandler) getRole(platform, userID string) string { if strings.TrimSpace(role) != "" { return role } - if _, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedRole) != "" { + if _, persistedRole, _ := h.loadSessionBinding(sk); strings.TrimSpace(persistedRole) != "" { h.mu.Lock() h.sessionRoles[sk] = persistedRole h.mu.Unlock() @@ -296,8 +323,38 @@ func (h *RobotHandler) setRole(platform, userID, roleName string) { h.mu.Lock() h.sessionRoles[sk] = roleName convID := h.sessions[sk] + agentMode := h.sessionModes[sk] h.mu.Unlock() - h.persistSessionBinding(sk, convID, roleName) + h.persistSessionBinding(sk, convID, roleName, agentMode) +} + +func (h *RobotHandler) getAgentMode(platform, userID string) string { + sk := h.sessionKey(platform, userID) + h.mu.RLock() + mode := h.sessionModes[sk] + h.mu.RUnlock() + if mode != "" { + return config.NormalizeAgentMode(mode) + } + if _, _, persistedMode := h.loadSessionBinding(sk); persistedMode != "" { + mode = config.NormalizeAgentMode(persistedMode) + h.mu.Lock() + h.sessionModes[sk] = mode + h.mu.Unlock() + return mode + } + return config.NormalizeRobotAgentMode(h.config.MultiAgent) +} + +func (h *RobotHandler) setAgentMode(platform, userID, mode string) { + sk := h.sessionKey(platform, userID) + mode = config.NormalizeAgentMode(mode) + h.mu.Lock() + h.sessionModes[sk] = mode + convID := h.sessions[sk] + role := h.sessionRoles[sk] + h.mu.Unlock() + h.persistSessionBinding(sk, convID, role, mode) } // clearConversation 清空当前会话(切换到新对话) @@ -379,7 +436,8 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin h.cancelMu.Unlock() }() role := h.getRole(platform, userID) - resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, platform, robotPrincipal(access), convID, text, role) + agentMode := h.getAgentMode(platform, userID) + resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, platform, robotPrincipal(access), convID, text, role, agentMode) if err != nil { h.logger.Warn("机器人 Agent 执行失败", zap.String("platform", platform), zap.String("userID", userID), zap.Error(err)) if errors.Is(err, context.Canceled) { @@ -401,32 +459,51 @@ func (h *RobotHandler) robotMessageTimeout() time.Duration { return 10 * time.Hour } -func (h *RobotHandler) cmdHelp() string { +func (h *RobotHandler) cmdHelp(platform, userID string) string { + access, _ := h.resolveRobotAccess(platform, userID) + can := func(permission string) bool { + return access != nil && access.Permissions[permission] + } var b strings.Builder b.WriteString("【CyberStrikeAI 机器人命令】\n\n") b.WriteString("【通用 General】\n") b.WriteString("· 帮助 / help — 显示本帮助\n") b.WriteString("· 版本 / version — 显示当前版本号\n") b.WriteString("· 绑定 <绑定码> / bind — 绑定网页端 RBAC 用户\n") - b.WriteString("· 解绑 / unbind — 解除当前平台账号绑定\n") + b.WriteString("· 解绑 / unbind — 请求解除账号绑定(需确认)\n") b.WriteString("· 身份 / whoami — 显示平台发送者、鉴权模式及当前实际 RBAC 身份\n") - b.WriteString("\n【对话 Conversation】\n") - b.WriteString("· 列表 / list — 列出所有对话标题与 ID\n") - b.WriteString("· 切换 / switch — 指定对话继续\n") - b.WriteString("· 新对话 / new — 开启新对话\n") - b.WriteString("· 清空 / clear — 清空当前上下文\n") - b.WriteString("· 当前 / current — 显示当前对话、角色与项目\n") - b.WriteString("· 停止 / stop — 中断当前任务\n") - b.WriteString("· 删除 / delete — 删除指定对话\n") - b.WriteString("\n【角色 Role】\n") - b.WriteString("· 角色 / roles — 列出所有可用角色\n") - b.WriteString("· 角色 <名> / role — 切换当前角色\n") - if h.projectsEnabled() { + if can("chat:read") || can("chat:write") || can("chat:delete") { + b.WriteString("\n【对话 Conversation】\n") + if can("chat:read") { + b.WriteString("· 列表 / list — 列出所有对话标题与 ID\n· 切换 / switch — 指定对话继续\n· 状态 / status — 汇总当前选择\n· 任务 / task — 查看当前任务状态\n") + } + if can("chat:write") { + b.WriteString("· 新对话 / new;清空 / clear — 开启新对话\n· 重命名 <名称> / rename — 修改当前对话标题\n") + } + if can("chat:delete") { + b.WriteString("· 删除 / delete — 删除指定对话(需确认)\n") + } + } + if can("roles:read") { + b.WriteString("\n【角色 Role】\n· 角色 / roles — 列出所有可用角色\n· 角色 <名> / role — 切换当前角色\n") + } + if can("agent:execute") { + b.WriteString("\n【模式 Mode】\n· 模式 / modes — 列出对话模式与当前选择\n· 模式 <名称> / mode — 切换对话模式\n· 停止 / stop — 中断当前任务\n") + } + b.WriteString("\n【诊断 Diagnostics】\n") + b.WriteString("· 权限 / permissions — 查看当前业务权限\n") + if can("config:read") { + b.WriteString("· 诊断 / doctor — 检查机器人关键配置状态\n") + } + b.WriteString("· 确认 / confirm;取消 / cancel — 处理高风险操作确认\n") + if h.projectsEnabled() && (can("project:read") || can("project:write")) { b.WriteString("\n【项目 Project】\n") - b.WriteString("· 项目 / projects — 列出所有项目\n") - b.WriteString("· 新建项目 <名称> / new project — 创建并绑定当前对话\n") - b.WriteString("· 绑定项目 / bind project — 绑定到已有项目\n") - b.WriteString("· 解除项目 / unbind project — 解除项目绑定\n") + if can("project:read") { + b.WriteString("· 项目 / projects — 列出所有项目\n") + } + if can("project:write") { + b.WriteString("· 新建项目 <名称> / new project — 创建并绑定当前对话\n· 绑定项目 / bind project — 绑定已有项目\n· 解除项目 / unbind project — 解除项目绑定\n") + } } b.WriteString("\n──────────────\n") b.WriteString("除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。") @@ -575,7 +652,7 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string { convID := h.sessions[sk] h.mu.RUnlock() if convID == "" { - if persistedConvID, _ := h.loadSessionBinding(sk); persistedConvID != "" { + if persistedConvID, _, _ := h.loadSessionBinding(sk); persistedConvID != "" { convID = persistedConvID } } @@ -673,12 +750,10 @@ func (h *RobotHandler) cmdStop(platform, userID string) string { return "已停止当前任务。" } -func (h *RobotHandler) cmdCurrent(platform, userID string) string { - h.mu.RLock() - convID := h.sessions[h.sessionKey(platform, userID)] - h.mu.RUnlock() +func (h *RobotHandler) cmdStatus(platform, userID string) string { + convID := h.currentConversationID(platform, userID) if convID == "" { - return "当前没有进行中的对话。发送任意内容将创建新对话。" + return fmt.Sprintf("【当前状态】\n当前对话: 无\n当前角色: %s\n当前模式: %s\n当前项目: 无\n\n发送任意内容将创建新对话。", h.getRole(platform, userID), robotAgentModeLabel(h.getAgentMode(platform, userID))) } access, err := h.resolveRobotAccess(platform, userID) if err != nil { @@ -692,14 +767,122 @@ func (h *RobotHandler) cmdCurrent(platform, userID string) string { return "当前对话 ID: " + convID + "(获取标题失败)" } role := h.getRole(platform, userID) - reply := fmt.Sprintf("当前对话:「%s」\nID: %s\n当前角色: %s", conv.Title, conv.ID, role) + reply := fmt.Sprintf("【当前状态】\n当前对话: %s\n对话 ID: %s\n当前模式: %s\n当前角色: %s", conv.Title, conv.ID, robotAgentModeLabel(h.getAgentMode(platform, userID)), role) if h.projectsEnabled() { projectID, _ := h.db.GetConversationProjectID(conv.ID) reply += "\n当前项目: " + h.formatProjectLabel(projectID) + } else { + reply += "\n当前项目: 未启用" } return reply } +func (h *RobotHandler) currentConversationID(platform, userID string) string { + sk := h.sessionKey(platform, userID) + h.mu.RLock() + convID := h.sessions[sk] + h.mu.RUnlock() + if convID != "" { + return convID + } + persistedConvID, persistedRole, persistedMode := h.loadSessionBinding(sk) + if persistedConvID == "" { + return "" + } + h.mu.Lock() + h.sessions[sk] = persistedConvID + h.sessionRoles[sk] = persistedRole + h.sessionModes[sk] = config.NormalizeAgentMode(persistedMode) + h.mu.Unlock() + return persistedConvID +} + +func (h *RobotHandler) cmdTask(platform, userID string) string { + convID := h.currentConversationID(platform, userID) + if convID == "" { + return "【任务状态】\n当前没有对话,也没有正在执行的任务。" + } + if h.agentHandler == nil || h.agentHandler.tasks == nil { + return "任务状态服务不可用。" + } + task := h.agentHandler.tasks.GetTaskSnapshot(convID) + if task == nil { + return "【任务状态】\n状态: 空闲\n当前没有正在执行的任务。" + } + elapsed := time.Since(task.StartedAt).Round(time.Second) + return fmt.Sprintf("【任务状态】\n状态: %s\n已运行: %s\n对话 ID: %s\n模式: %s\n可用操作: 停止 / stop", task.Status, elapsed, convID, robotAgentModeLabel(h.getAgentMode(platform, userID))) +} + +func (h *RobotHandler) cmdRename(platform, userID, title string) string { + title = strings.TrimSpace(title) + if title == "" { + return "请指定新标题,例如:重命名 外网资产排查" + } + title = safeTruncateString(title, 100) + convID := h.currentConversationID(platform, userID) + if convID == "" { + return "当前没有对话,无法重命名。" + } + access, err := h.resolveRobotAccess(platform, userID) + if err != nil || !h.db.UserCanAccessResource(access.User.ID, robotPrincipal(access).ScopeFor("chat:write"), "conversation", convID) { + return "当前对话不存在或无权修改。" + } + if err := h.db.UpdateConversationTitle(convID, title); err != nil { + return "重命名失败: " + err.Error() + } + h.recordRobotCommandAudit(access, platform, "conversation_rename", "conversation", convID, "机器人重命名当前对话") + return fmt.Sprintf("已将当前对话重命名为:「%s」", title) +} + +func (h *RobotHandler) cmdPermissions(platform, userID string) string { + access, err := h.resolveRobotAccess(platform, userID) + if err != nil { + return h.robotAccessDeniedMessage(platform) + } + allowed := func(permission string) string { + if access.Permissions[permission] { + return "允许" + } + return "不允许" + } + return fmt.Sprintf("【当前权限】\n执行 Agent: %s\n读取对话: %s\n编辑对话: %s\n删除对话: %s\n读取角色: %s\n读取项目: %s\n编辑项目: %s\n资源范围: %s", allowed("agent:execute"), allowed("chat:read"), allowed("chat:write"), allowed("chat:delete"), allowed("roles:read"), allowed("project:read"), allowed("project:write"), access.Scope) +} + +func (h *RobotHandler) cmdDoctor() string { + configured := func(ok bool) string { + if ok { + return "正常" + } + return "未配置" + } + enabled := func(ok bool) string { + if ok { + return "已启用" + } + return "已关闭" + } + enabledInternalTools := 0 + for _, tool := range h.config.Security.Tools { + if tool.Enabled { + enabledInternalTools++ + } + } + enabledExternal := 0 + for _, server := range h.config.ExternalMCP.Servers { + if server.ExternalMCPEnable && !server.Disabled { + enabledExternal++ + } + } + return fmt.Sprintf("【配置诊断】\n主模型: %s\nEino 多代理: %s\n内置 MCP 工具: %d/%d 个已启用\nHTTP MCP 服务: %s\n外部 MCP: %d 个已启用\n知识库: %s\n项目功能: %s\n说明: 内置工具不依赖 HTTP MCP 服务;此命令只检查配置,不主动探测外部服务。", configured(strings.TrimSpace(h.config.OpenAI.Model) != "" && strings.TrimSpace(h.config.OpenAI.BaseURL) != ""), enabled(h.config.MultiAgent.Enabled), enabledInternalTools, len(h.config.Security.Tools), enabled(h.config.MCP.Enabled), enabledExternal, enabled(h.config.Knowledge.Enabled), enabled(h.config.Project.Enabled)) +} + +func (h *RobotHandler) recordRobotCommandAudit(access *database.RBACAccess, platform, action, resourceType, resourceID, message string) { + if h.audit == nil || access == nil { + return + } + h.audit.RecordSystem(audit.Entry{Category: "robot", Action: action, Result: "success", Actor: access.User.Username, ResourceType: resourceType, ResourceID: resourceID, Message: message + "(" + platform + ")"}) +} + func (h *RobotHandler) cmdRoles() string { if h.config.Roles == nil || len(h.config.Roles) == 0 { return "暂无可用角色。" @@ -753,6 +936,55 @@ func (h *RobotHandler) cmdSwitchRole(platform, userID, roleName string) string { return fmt.Sprintf("已切换到角色:「%s」\n%s", roleName, role.Description) } +func robotAgentModeLabel(mode string) string { + switch config.NormalizeAgentMode(mode) { + case "deep": + return "Deep" + case "plan_execute": + return "Plan-Execute" + case "supervisor": + return "Supervisor" + default: + return "Eino 单代理" + } +} + +func parseRobotAgentMode(input string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "eino_single", "eino-single", "single", "单代理", "eino单代理", "eino 单代理": + return "eino_single", true + case "deep": + return "deep", true + case "plan_execute", "plan-execute", "planexecute", "pe": + return "plan_execute", true + case "supervisor", "super", "sv": + return "supervisor", true + default: + return "", false + } +} + +func (h *RobotHandler) cmdModes(platform, userID string) string { + current := h.getAgentMode(platform, userID) + multiStatus := "可用" + if h.config == nil || !h.config.MultiAgent.Enabled { + multiStatus = "不可用(需在系统设置中启用 Eino 多代理)" + } + return fmt.Sprintf("【对话模式】\n· Eino 单代理 — 可用\n· Deep — %s\n· Plan-Execute — %s\n· Supervisor — %s\n\n当前模式: %s\n切换示例:模式 deep", multiStatus, multiStatus, multiStatus, robotAgentModeLabel(current)) +} + +func (h *RobotHandler) cmdSwitchMode(platform, userID, input string) string { + mode, ok := parseRobotAgentMode(input) + if !ok { + return fmt.Sprintf("不支持的对话模式「%s」。发送「模式」查看可用模式。", strings.TrimSpace(input)) + } + if mode != "eino_single" && (h.config == nil || !h.config.MultiAgent.Enabled) { + return fmt.Sprintf("无法切换到 %s:请先在系统设置中启用 Eino 多代理。", robotAgentModeLabel(mode)) + } + h.setAgentMode(platform, userID, mode) + return fmt.Sprintf("已切换对话模式:%s\n后续消息和新对话将使用该模式。", robotAgentModeLabel(mode)) +} + func (h *RobotHandler) cmdDelete(platform, userID, convID string) string { if convID == "" { return "请指定对话 ID,例如:删除 xxx-xxx-xxx" @@ -764,6 +996,15 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string { if !h.db.UserCanAccessResource(access.User.ID, robotPrincipal(access).ScopeFor("chat:delete"), "conversation", convID) { return "对话不存在或无权访问。" } + h.setPendingConfirmation(platform, userID, "delete_conversation", convID) + return fmt.Sprintf("⚠️ 即将删除对话 ID: %s\n此操作不可撤销。请在 2 分钟内发送「确认」继续,或发送「取消」。", convID) +} + +func (h *RobotHandler) executeDelete(platform, userID, convID string) string { + access, err := h.resolveRobotAccess(platform, userID) + if err != nil || !h.db.UserCanAccessResource(access.User.ID, robotPrincipal(access).ScopeFor("chat:delete"), "conversation", convID) { + return "对话不存在或无权删除。" + } sk := h.sessionKey(platform, userID) h.mu.RLock() currentConvID := h.sessions[sk] @@ -773,6 +1014,7 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string { h.mu.Lock() delete(h.sessions, sk) delete(h.sessionRoles, sk) + delete(h.sessionModes, sk) h.mu.Unlock() h.deleteSessionBinding(sk) } @@ -782,6 +1024,7 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string { if err := h.db.DeleteConversation(convID); err != nil { return "删除失败: " + err.Error() } + h.recordRobotCommandAudit(access, platform, "conversation_delete", "conversation", convID, "机器人删除对话") return fmt.Sprintf("已删除对话 ID: %s", convID) } @@ -844,9 +1087,10 @@ func robotCommandPermission(text string) (string, bool) { case text == robotCmdList || text == robotCmdListAlt || text == "list", strings.HasPrefix(text, robotCmdSwitch+" "), strings.HasPrefix(text, robotCmdContinue+" "), strings.HasPrefix(text, "switch "), strings.HasPrefix(text, "continue "), - text == robotCmdCurrent || text == "current": + text == robotCmdStatus || text == "status", text == robotCmdTask || text == "task": return "chat:read", true - case text == robotCmdNew || text == "new", text == robotCmdClear || text == "clear": + case text == robotCmdNew || text == "new", text == robotCmdClear || text == "clear", + strings.HasPrefix(text, robotCmdRename+" "), strings.HasPrefix(text, "rename "): return "chat:write", true case strings.HasPrefix(text, robotCmdDelete+" "), strings.HasPrefix(text, "delete "): return "chat:delete", true @@ -855,6 +1099,15 @@ func robotCommandPermission(text string) (string, bool) { case text == robotCmdRoles || text == robotCmdRolesList || text == "roles", strings.HasPrefix(text, robotCmdRoles+" "), strings.HasPrefix(text, robotCmdSwitchRole+" "), strings.HasPrefix(text, "role "): return "roles:read", true + case text == robotCmdModes || text == robotCmdModesList || text == "modes", + strings.HasPrefix(text, robotCmdModes+" "), strings.HasPrefix(text, robotCmdSwitchMode+" "), strings.HasPrefix(text, "mode "): + return "agent:execute", true + case text == robotCmdPermissions || text == "permissions": + return "", true + case text == robotCmdConfirm || text == "confirm", text == robotCmdCancel || text == "cancel": + return "", true + case text == robotCmdDoctor || text == "doctor": + return "config:read", true case text == robotCmdProjects || text == robotCmdProjectsList || text == "projects": return "project:read", true case text == robotCmdUnbindProject || text == "unbind project", @@ -883,6 +1136,7 @@ func (h *RobotHandler) cmdBindUser(platform, userID, code string) string { h.mu.Lock() delete(h.sessions, sk) delete(h.sessionRoles, sk) + delete(h.sessionModes, sk) h.mu.Unlock() h.deleteSessionBinding(sk) name := strings.TrimSpace(user.DisplayName) @@ -903,6 +1157,15 @@ func (h *RobotHandler) cmdUnbindUser(platform, userID string) string { if h.config.Robots.AuthorizationFor(platform).EffectiveMode() != config.RobotAuthModeUserBinding { return "该机器人使用受控服务账号模式,无需用户解绑。" } + _, accessErr := h.resolveRobotAccess(platform, userID) + if accessErr != nil { + return "当前平台账号尚未绑定。" + } + h.setPendingConfirmation(platform, userID, "unbind_user", "") + return "⚠️ 即将解除当前平台账号绑定。请在 2 分钟内发送「确认」继续,或发送「取消」。" +} + +func (h *RobotHandler) executeUnbindUser(platform, userID string) string { access, accessErr := h.resolveRobotAccess(platform, userID) if accessErr != nil { return "当前平台账号尚未绑定。" @@ -914,6 +1177,7 @@ func (h *RobotHandler) cmdUnbindUser(platform, userID string) string { h.mu.Lock() delete(h.sessions, sk) delete(h.sessionRoles, sk) + delete(h.sessionModes, sk) h.mu.Unlock() h.deleteSessionBinding(sk) if h.audit != nil { @@ -926,6 +1190,50 @@ func (h *RobotHandler) cmdUnbindUser(platform, userID string) string { return "已解除当前平台账号与 CyberStrikeAI 用户的绑定。" } +func (h *RobotHandler) setPendingConfirmation(platform, userID, action, target string) { + sk := h.sessionKey(platform, userID) + now := time.Now() + h.mu.Lock() + for key, pending := range h.pendingConfirmations { + if now.After(pending.ExpiresAt) { + delete(h.pendingConfirmations, key) + } + } + h.pendingConfirmations[sk] = robotPendingConfirmation{Action: action, Target: target, ExpiresAt: now.Add(2 * time.Minute)} + h.mu.Unlock() +} + +func (h *RobotHandler) cmdConfirm(platform, userID string) string { + sk := h.sessionKey(platform, userID) + h.mu.Lock() + pending, ok := h.pendingConfirmations[sk] + delete(h.pendingConfirmations, sk) + h.mu.Unlock() + if !ok || time.Now().After(pending.ExpiresAt) { + return "当前没有待确认操作,或确认已超时。" + } + switch pending.Action { + case "delete_conversation": + return h.executeDelete(platform, userID, pending.Target) + case "unbind_user": + return h.executeUnbindUser(platform, userID) + default: + return "待确认操作无效,已取消。" + } +} + +func (h *RobotHandler) cmdCancelConfirmation(platform, userID string) string { + sk := h.sessionKey(platform, userID) + h.mu.Lock() + _, ok := h.pendingConfirmations[sk] + delete(h.pendingConfirmations, sk) + h.mu.Unlock() + if !ok { + return "当前没有待确认操作。" + } + return "已取消待确认操作。" +} + // handleRobotCommand 处理机器人内置命令;若匹配到命令返回 (回复内容, true),否则返回 ("", false) func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string, bool) { if (strings.HasPrefix(text, robotCmdBindUser+" ") || strings.HasPrefix(text, "bind ")) && !strings.HasPrefix(text, "bind project ") { @@ -946,9 +1254,13 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string } switch { case text == robotCmdHelp || text == "help" || text == "?" || text == "?": - return h.cmdHelp(), true + return h.cmdHelp(platform, userID), true case text == robotCmdIdentity || text == "whoami": return h.cmdIdentity(platform, userID), true + case text == robotCmdConfirm || text == "confirm": + return h.cmdConfirm(platform, userID), true + case text == robotCmdCancel || text == "cancel": + return h.cmdCancelConfirmation(platform, userID), true case text == robotCmdList || text == robotCmdListAlt || text == "list": return h.cmdList(platform, userID), true case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" ") || strings.HasPrefix(text, "switch ") || strings.HasPrefix(text, "continue "): @@ -968,8 +1280,18 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string return h.cmdNew(platform, userID), true case text == robotCmdClear || text == "clear": return h.cmdClear(platform, userID), true - case text == robotCmdCurrent || text == "current": - return h.cmdCurrent(platform, userID), true + case text == robotCmdStatus || text == "status": + return h.cmdStatus(platform, userID), true + case text == robotCmdTask || text == "task": + return h.cmdTask(platform, userID), true + case strings.HasPrefix(text, robotCmdRename+" ") || strings.HasPrefix(text, "rename "): + var title string + if strings.HasPrefix(text, robotCmdRename+" ") { + title = strings.TrimSpace(text[len(robotCmdRename)+1:]) + } else { + title = strings.TrimSpace(text[len("rename "):]) + } + return h.cmdRename(platform, userID, title), true case text == robotCmdStop || text == "stop": return h.cmdStop(platform, userID), true case text == robotCmdRoles || text == robotCmdRolesList || text == "roles": @@ -985,6 +1307,23 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string roleName = strings.TrimSpace(text[5:]) } return h.cmdSwitchRole(platform, userID, roleName), true + case text == robotCmdModes || text == robotCmdModesList || text == "modes": + return h.cmdModes(platform, userID), true + case strings.HasPrefix(text, robotCmdModes+" ") || strings.HasPrefix(text, robotCmdSwitchMode+" ") || strings.HasPrefix(text, "mode "): + var mode string + switch { + case strings.HasPrefix(text, robotCmdModes+" "): + mode = strings.TrimSpace(text[len(robotCmdModes)+1:]) + case strings.HasPrefix(text, robotCmdSwitchMode+" "): + mode = strings.TrimSpace(text[len(robotCmdSwitchMode)+1:]) + default: + mode = strings.TrimSpace(text[5:]) + } + return h.cmdSwitchMode(platform, userID, mode), true + case text == robotCmdPermissions || text == "permissions": + return h.cmdPermissions(platform, userID), true + case text == robotCmdDoctor || text == "doctor": + return h.cmdDoctor(), true case strings.HasPrefix(text, robotCmdDelete+" ") || strings.HasPrefix(text, "delete "): var convID string if strings.HasPrefix(text, robotCmdDelete+" ") { diff --git a/internal/handler/robot_mode_test.go b/internal/handler/robot_mode_test.go new file mode 100644 index 00000000..ee9ab631 --- /dev/null +++ b/internal/handler/robot_mode_test.go @@ -0,0 +1,101 @@ +package handler + +import ( + "strings" + "testing" + + "cyberstrike-ai/internal/config" + + "go.uber.org/zap" +) + +func TestRobotModeSwitch(t *testing.T) { + h := NewRobotHandler(&config.Config{MultiAgent: config.MultiAgentConfig{Enabled: true}}, nil, nil, zap.NewNop()) + + if got := h.cmdSwitchMode("lark", "user-1", "plan-execute"); !strings.Contains(got, "Plan-Execute") { + t.Fatalf("unexpected switch response: %s", got) + } + if got := h.getAgentMode("lark", "user-1"); got != "plan_execute" { + t.Fatalf("mode = %q, want plan_execute", got) + } + if got := h.cmdModes("lark", "user-1"); !strings.Contains(got, "当前模式: Plan-Execute") { + t.Fatalf("unexpected modes response: %s", got) + } +} + +func TestRobotModeRejectsUnavailableMultiAgent(t *testing.T) { + h := NewRobotHandler(&config.Config{}, nil, nil, zap.NewNop()) + + if got := h.cmdSwitchMode("lark", "user-1", "deep"); !strings.Contains(got, "启用 Eino 多代理") { + t.Fatalf("unexpected rejection: %s", got) + } + if got := h.getAgentMode("lark", "user-1"); got != "eino_single" { + t.Fatalf("mode changed after rejection: %q", got) + } +} + +func TestParseRobotAgentModeRejectsUnknownMode(t *testing.T) { + if mode, ok := parseRobotAgentMode("unknown"); ok || mode != "" { + t.Fatalf("parseRobotAgentMode returned (%q, %v), want empty,false", mode, ok) + } +} + +func TestRobotStatusCommandPermission(t *testing.T) { + for _, command := range []string{"状态", "status"} { + permission, recognized := robotCommandPermission(command) + if !recognized || permission != "chat:read" { + t.Fatalf("command %q returned permission=%q recognized=%v", command, permission, recognized) + } + } + for _, removed := range []string{"当前", "current"} { + if _, recognized := robotCommandPermission(removed); recognized { + t.Fatalf("removed command %q is still recognized", removed) + } + } +} + +func TestRobotBestPracticeCommandPermissions(t *testing.T) { + cases := map[string]string{ + "任务": "chat:read", + "task": "chat:read", + "重命名 新标题": "chat:write", + "rename x": "chat:write", + "诊断": "config:read", + "doctor": "config:read", + } + for command, want := range cases { + permission, recognized := robotCommandPermission(command) + if !recognized || permission != want { + t.Fatalf("command %q returned permission=%q recognized=%v, want %q,true", command, permission, recognized, want) + } + } +} + +func TestRobotConfirmationCanBeCancelled(t *testing.T) { + h := NewRobotHandler(&config.Config{}, nil, nil, zap.NewNop()) + h.setPendingConfirmation("lark", "user-1", "delete_conversation", "conv-1") + if got := h.cmdCancelConfirmation("lark", "user-1"); got != "已取消待确认操作。" { + t.Fatalf("unexpected cancel response: %s", got) + } + if got := h.cmdConfirm("lark", "user-1"); !strings.Contains(got, "没有待确认操作") { + t.Fatalf("confirmation survived cancellation: %s", got) + } +} + +func TestRobotDoctorSeparatesInternalToolsFromHTTPMCP(t *testing.T) { + h := NewRobotHandler(&config.Config{ + Security: config.SecurityConfig{Tools: []config.ToolConfig{ + {Name: "enabled-tool", Enabled: true}, + {Name: "disabled-tool", Enabled: false}, + }}, + MCP: config.MCPConfig{Enabled: false}, + }, nil, nil, zap.NewNop()) + + got := h.cmdDoctor() + if !strings.Contains(got, "内置 MCP 工具: 1/2 个已启用") { + t.Fatalf("internal tool status missing: %s", got) + } + if !strings.Contains(got, "HTTP MCP 服务: 已关闭") { + t.Fatalf("HTTP MCP status missing: %s", got) + } +} diff --git a/internal/handler/task_manager.go b/internal/handler/task_manager.go index 5869007a..51a4be51 100644 --- a/internal/handler/task_manager.go +++ b/internal/handler/task_manager.go @@ -298,6 +298,18 @@ func (m *AgentTaskManager) GetTask(conversationID string) *AgentTask { return m.tasks[conversationID] } +// GetTaskSnapshot 返回运行任务的只读副本,供状态展示使用,避免锁外读取可变任务字段。 +func (m *AgentTaskManager) GetTaskSnapshot(conversationID string) *AgentTask { + m.mu.RLock() + defer m.mu.RUnlock() + task := m.tasks[conversationID] + if task == nil { + return nil + } + snapshot := *task + return &snapshot +} + // runStuckCancellingCleanup 定期将长时间处于「取消中」的任务强制结束,避免卡住无法发新消息 func (m *AgentTaskManager) runStuckCancellingCleanup() { ticker := time.NewTicker(cleanupInterval)