diff --git a/docs/robot.md b/docs/robot.md new file mode 100644 index 00000000..5a344ec5 --- /dev/null +++ b/docs/robot.md @@ -0,0 +1,215 @@ +# CyberStrikeAI 机器人使用说明 + +本文档说明如何通过**钉钉**、**飞书**与 CyberStrikeAI 对话(长连接模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。 + +--- + +## 一、在 CyberStrikeAI 里从哪里配置 + +1. 登录 CyberStrikeAI Web 端 +2. 左侧导航进入 **系统设置** +3. 在左侧设置分类中点击 **机器人设置**(位于「基本设置」与「安全设置」之间) +4. 按平台勾选并填写(钉钉填 Client ID / Client Secret,飞书填 App ID / App Secret) +5. 点击 **应用配置** 保存 +6. **重启 CyberStrikeAI 应用**(只保存不重启,机器人不会连上) + +配置会写入 `config.yaml` 的 `robots` 段,也可在配置文件中直接编辑。**修改钉钉/飞书配置后必须重启,长连接才会生效。** + +--- + +## 二、支持的平台(长连接) + +| 平台 | 说明 | +|------|------| +| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 | +| 飞书 | 使用长连接,程序主动连接飞书接收消息 | + +下面第三节会按平台写清:在开放平台要做什么、要复制哪些字段、填到 CyberStrikeAI 的哪一栏。 + +--- + +## 三、各平台配置项与详细步骤 + +### 3.1 钉钉 + +**先搞清楚:两种钉钉机器人不一样** + +| 类型 | 从哪里创建 | 能否做「用户发消息→机器人回复」 | 本程序是否支持 | +|------|------------|----------------------------------|----------------| +| **自定义机器人** | 钉钉群里:群设置 → 添加机器人 → 自定义(Webhook) | ❌ 不能,只能你往群里发消息 | ❌ 不支持 | +| **企业内部应用机器人** | [钉钉开放平台](https://open.dingtalk.com) 创建应用并开通机器人 | ✅ 能 | ✅ 支持 | + +如果你手里是「自定义机器人」的 Webhook 地址(`oapi.dingtalk.com/robot/send?access_token=xxx`)和加签密钥(`SEC...`),**不能直接填到本程序**,必须按下面步骤在开放平台创建「企业内部应用」并拿到 **Client ID**、**Client Secret**。 + +--- + +**钉钉配置完整步骤(按顺序做)** + +1. **打开钉钉开放平台** + 浏览器访问 [https://open.dingtalk.com](https://open.dingtalk.com),用**企业管理员**账号登录。 + +2. **进入应用开发** + 左侧选 **应用开发** → **企业内部开发** → 点击 **创建应用**(或选择已有应用)。填写应用名称等基本信息后创建。 + +3. **拿到 Client ID 和 Client Secret** + - 左侧点 **凭证与基础信息**(在「基础信息」下)。 + - 页面上有 **Client ID(原 AppKey)** 和 **Client Secret(原 AppSecret)**。 + - 点击复制,**不要手打**,注意:数字 **0** 和字母 **o**、数字 **1** 和字母 **l** 容易抄错(例如 `ding9gf9tiozuc504aer` 中间是数字 **504** 不是 5o4)。 + +4. **开通机器人并选 Stream 模式** + - 左侧 **应用能力** → **机器人**。 + - 打开「机器人配置」开关。 + - 填写机器人名称、简介等(必填项按提示填)。 + - **关键**:消息接收方式要选 **「Stream 模式」**(流式接入)。若只有「HTTP 回调」或未选 Stream,本程序收不到消息。 + - 保存。 + +5. **权限与发布** + - 左侧 **权限管理**:搜索「机器人」「消息」等,勾选**接收消息**、**发送消息**等机器人相关权限,并确认授权。 + - 左侧 **版本管理与发布**:若有未发布配置,点击 **发布新版本** / **上线**,否则修改不生效。 + +6. **填回 CyberStrikeAI** + - 回到 CyberStrikeAI → 系统设置 → 机器人设置 → 钉钉。 + - 勾选「启用钉钉机器人」。 + - **Client ID (AppKey)** 粘贴第 3 步复制的 Client ID。 + - **Client Secret** 粘贴第 3 步复制的 Client Secret。 + - 点击 **应用配置**,然后**重启 CyberStrikeAI**。 + +--- + +**CyberStrikeAI 钉钉栏位对照** + +| CyberStrikeAI 中填写项 | 在钉钉开放平台的来源 | +|------------------------|------------------------| +| 启用钉钉机器人 | 勾选即启用 | +| Client ID (AppKey) | 凭证与基础信息 → **Client ID(原 AppKey)** | +| Client Secret | 凭证与基础信息 → **Client Secret(原 AppSecret)** | + +--- + +### 3.2 飞书 (Lark) + +| 配置项 | 说明 | +|--------|------| +| 启用飞书机器人 | 勾选后启动飞书长连接 | +| App ID | 飞书开放平台应用凭证中的 App ID | +| App Secret | 飞书开放平台应用凭证中的 App Secret | +| Verify Token | 事件订阅用(可选) | + +**飞书配置简要步骤**:登录 [飞书开放平台](https://open.feishu.cn) → 创建企业自建应用 → 在「凭证与基础信息」中获取 **App ID**、**App Secret** → 在「应用能力」中开通**机器人**并启用相应权限 → 发布应用 → 将 App ID、App Secret 填到 CyberStrikeAI 机器人设置 → 保存并**重启应用**。 + +--- + +## 四、机器人命令 + +在钉钉/飞书中向机器人发送以下**文本命令**(仅支持文本): + +| 命令 | 说明 | +|------|------| +| **帮助** | 显示命令帮助与说明 | +| **列表** 或 **对话列表** | 列出所有对话的标题与对话 ID | +| **切换 \<对话ID\>** 或 **继续 \<对话ID\>** | 指定对话 ID,后续消息在该对话中继续 | +| **新对话** | 开启一个新对话,后续消息在新对话中 | +| **清空** | 清空当前对话上下文(效果等同「新对话」) | +| **当前** | 显示当前对话 ID 与标题 | + +除以上命令外,**直接输入任意文字**会作为用户消息发给 AI,与 Web 端对话逻辑一致(渗透测试/安全分析等)。 + +--- + +## 五、如何使用(要 @ 机器人吗?) + +- **单聊(推荐)**:在钉钉/飞书里**搜索并打开该机器人**,进入与机器人的**私聊**,直接输入「帮助」或任意文字即可,**不需要 @**。 +- **群聊**:若机器人被添加到群里,在群内只有 **@机器人** 后发送的消息才会被机器人收到并回复;不 @ 的群消息不会触发机器人。 + +总结:和机器人**单聊时直接发**;在**群里用时需要 @机器人** 再发内容。 + +--- + +## 六、推荐使用流程(避免漏步骤) + +1. **在开放平台**:按第三节完成钉钉或飞书应用创建、凭证复制、机器人开通(钉钉务必选 **Stream 模式**)、权限与发布。 +2. **在 CyberStrikeAI**:系统设置 → 机器人设置 → 勾选对应平台,粘贴 Client ID/App ID、Client Secret/App Secret → 点击 **应用配置**。 +3. **重启 CyberStrikeAI 进程**(否则长连接不会建立)。 +4. **在手机钉钉/飞书**:找到该机器人(单聊直接发,群聊需 @机器人),发「帮助」或任意内容测试。 + +若发消息没反应,先看 **第九节排查** 和 **第十节常见弯路**。 + +--- + +## 七、配置文件示例 + +`config.yaml` 中机器人相关片段示例: + +```yaml +robots: + dingtalk: + enabled: true + client_id: "your_dingtalk_app_key" + client_secret: "your_dingtalk_app_secret" + lark: + enabled: true + app_id: "your_lark_app_id" + app_secret: "your_lark_app_secret" + verify_token: "" +``` + +修改后需**重启应用**,长连接在应用启动时建立。 + +--- + +## 八、如何验证是否可用(无需钉钉/飞书客户端) + +在未安装钉钉或飞书时,可用**测试接口**验证机器人逻辑是否正常: + +1. 先登录 CyberStrikeAI Web 端(保证有登录态)。 +2. 使用 curl 调用测试接口(需携带登录后的 Cookie): + +```bash +# 将 YOUR_COOKIE 替换为登录后获得的 Cookie(浏览器 F12 → 网络 → 任意请求 → 请求头中的 Cookie) +curl -X POST "http://localhost:8080/api/robot/test" \ + -H "Content-Type: application/json" \ + -H "Cookie: YOUR_COOKIE" \ + -d '{"platform":"dingtalk","user_id":"test_user","text":"帮助"}' +``` + +若返回 JSON 中含有 `"reply":"【CyberStrikeAI 机器人命令】..."`,说明命令处理正常。可再试 `"text":"列表"`、`"text":"当前"` 等。 + +接口说明:`POST /api/robot/test`(需登录),请求体 `{"platform":"可选","user_id":"可选","text":"必填"}`,响应 `{"reply":"回复内容"}`。 + +--- + +## 九、钉钉发消息没反应时排查 + +按顺序检查: + +1. **Client ID / Client Secret 是否与开放平台完全一致** + 从「凭证与基础信息」里**复制粘贴**,不要手打。注意数字 **0** 与字母 **o**、数字 **1** 与字母 **l**(例如 `ding9gf9tiozuc504aer` 中间是 **504** 不是 5o4)。 + +2. **是否在保存配置后重启了应用** + 机器人长连接在**应用启动时**建立。在 Web 端点击「应用配置」只写入配置文件,**必须重启 CyberStrikeAI 进程**后钉钉连接才会生效。 + +3. **看程序日志** + - 启动后应看到:`钉钉 Stream 正在连接…`、`钉钉 Stream 已启动(无需公网),等待收消息`。 + - 若出现 `钉钉 Stream 长连接退出` 且带错误信息,多为 **Client ID / Client Secret 错误**或**开放平台未开通流式接入**。 + - 在钉钉里发一条消息后,若有收到,应有日志:`钉钉收到消息`;若没有,说明钉钉未把消息推到本程序(回头检查开放平台「机器人」是否开通、是否选用 **Stream 模式**)。 + +4. **开放平台侧** + 应用需已**发布**;在「机器人」能力中需开启**流式接入(Stream)** 用于接收消息(仅 HTTP 回调不够);权限管理里需有机器人接收、发送消息等权限。 + +--- + +## 十、常见弯路(避免踩坑) + +- **用错了机器人类型**:在钉钉**群里**添加的「自定义」机器人(Webhook + 加签)**不能**用来做对话,本程序只支持**开放平台「企业内部应用」**里的机器人。 +- **只保存没重启**:在 CyberStrikeAI 里改完机器人配置后必须**重启应用**,否则长连接不会建立。 +- **Client ID 抄错**:开放平台是 `504` 就填 `504`,不要填成 `5o4`;尽量用复制粘贴。 +- **钉钉只开了 HTTP 回调没开 Stream**:本程序通过 **Stream 长连接**收消息,开放平台里机器人的消息接收方式必须选 **Stream 模式**。 +- **应用没发布**:开放平台里修改了机器人或权限后,要在「版本管理与发布」里**发布新版本**,否则不生效。 + +--- + +## 十一、注意事项 + +- 钉钉、飞书均**仅处理文本消息**;其他类型(如图片、语音)会提示暂不支持或忽略。 +- 会话与 Web 端共用同一套对话数据:在机器人里创建的对话会在 Web 端「对话」列表中看到,反之亦然。 +- 机器人执行逻辑与 **`/api/agent-loop/stream`** 一致(含进度回调、过程详情写入数据库),仅不向客户端推送 SSE,最后将完整回复一次性发回钉钉/飞书/企业微信。 diff --git a/internal/app/app.go b/internal/app/app.go index 4d273370..90c2fb14 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -14,6 +14,7 @@ import ( "cyberstrike-ai/internal/database" "cyberstrike-ai/internal/handler" "cyberstrike-ai/internal/knowledge" + "cyberstrike-ai/internal/robot" "cyberstrike-ai/internal/logger" "cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp/builtin" @@ -325,6 +326,14 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) { // 创建OpenAPI处理器 conversationHandler := handler.NewConversationHandler(db, log.Logger) + robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger) + // 飞书/钉钉长连接(无需公网),启用时在后台启动 + if cfg.Robots.Lark.Enabled && cfg.Robots.Lark.AppID != "" && cfg.Robots.Lark.AppSecret != "" { + go robot.StartLark(cfg.Robots.Lark, robotHandler, log.Logger) + } + if cfg.Robots.Dingtalk.Enabled && cfg.Robots.Dingtalk.ClientID != "" && cfg.Robots.Dingtalk.ClientSecret != "" { + go robot.StartDing(cfg.Robots.Dingtalk, robotHandler, log.Logger) + } openAPIHandler := handler.NewOpenAPIHandler(db, log.Logger, resultStorage, conversationHandler, agentHandler) // 创建 App 实例(部分字段稍后填充) @@ -408,6 +417,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) { agentHandler, monitorHandler, conversationHandler, + robotHandler, groupHandler, configHandler, externalMCPHandler, @@ -472,6 +482,7 @@ func setupRoutes( agentHandler *handler.AgentHandler, monitorHandler *handler.MonitorHandler, conversationHandler *handler.ConversationHandler, + robotHandler *handler.RobotHandler, groupHandler *handler.GroupHandler, configHandler *handler.ConfigHandler, externalMCPHandler *handler.ExternalMCPHandler, @@ -497,9 +508,18 @@ func setupRoutes( authRoutes.GET("/validate", security.AuthMiddleware(authManager), authHandler.Validate) } + // 机器人回调(无需登录,供企业微信/钉钉/飞书服务器调用) + api.GET("/robot/wecom", robotHandler.HandleWecomGET) + api.POST("/robot/wecom", robotHandler.HandleWecomPOST) + api.POST("/robot/dingtalk", robotHandler.HandleDingtalkPOST) + api.POST("/robot/lark", robotHandler.HandleLarkPOST) + protected := api.Group("") protected.Use(security.AuthMiddleware(authManager)) { + // 机器人测试(需登录):POST /api/robot/test,body: {"platform":"dingtalk","user_id":"test","text":"帮助"},用于验证机器人逻辑 + protected.POST("/robot/test", robotHandler.HandleRobotTest) + // Agent Loop protected.POST("/agent-loop", agentHandler.AgentLoop) // Agent Loop 流式输出 diff --git a/internal/config/config.go b/internal/config/config.go index ca65f926..47db1bd6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,11 +25,44 @@ type Config struct { Auth AuthConfig `yaml:"auth"` ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"` Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"` + Robots RobotsConfig `yaml:"robots,omitempty" json:"robots,omitempty"` // 企业微信/钉钉/飞书等机器人配置 RolesDir string `yaml:"roles_dir,omitempty" json:"roles_dir,omitempty"` // 角色配置文件目录(新方式) Roles map[string]RoleConfig `yaml:"roles,omitempty" json:"roles,omitempty"` // 向后兼容:支持在主配置文件中定义角色 SkillsDir string `yaml:"skills_dir,omitempty" json:"skills_dir,omitempty"` // Skills配置文件目录 } +// RobotsConfig 机器人配置(企业微信、钉钉、飞书等) +type RobotsConfig struct { + Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信 + Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉 + Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书 +} + +// RobotWecomConfig 企业微信机器人配置 +type RobotWecomConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Token string `yaml:"token" json:"token"` // 回调 URL 校验 Token + EncodingAESKey string `yaml:"encoding_aes_key" json:"encoding_aes_key"` // EncodingAESKey + CorpID string `yaml:"corp_id" json:"corp_id"` // 企业 ID + Secret string `yaml:"secret" json:"secret"` // 应用 Secret + AgentID int64 `yaml:"agent_id" json:"agent_id"` // 应用 AgentId +} + +// RobotDingtalkConfig 钉钉机器人配置 +type RobotDingtalkConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey) + ClientSecret string `yaml:"client_secret" json:"client_secret"` // 应用 Secret +} + +// RobotLarkConfig 飞书机器人配置 +type RobotLarkConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID + AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret + VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选) +} + type ServerConfig struct { Host string `yaml:"host"` Port int `yaml:"port"` diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 3a0fe79a..3a63f2e7 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -259,6 +259,96 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) { }) } +// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:与 /api/agent-loop/stream 相同执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复 +func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationID, message, role string) (response string, convID string, err error) { + if conversationID == "" { + title := safeTruncateString(message, 50) + conv, createErr := h.db.CreateConversation(title) + if createErr != nil { + return "", "", fmt.Errorf("创建对话失败: %w", createErr) + } + conversationID = conv.ID + } else { + if _, getErr := h.db.GetConversation(conversationID); getErr != nil { + return "", "", fmt.Errorf("对话不存在") + } + } + + agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID) + if err != nil { + historyMessages, getErr := h.db.GetMessages(conversationID) + if getErr != nil { + agentHistoryMessages = []agent.ChatMessage{} + } else { + agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages)) + for _, msg := range historyMessages { + agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{Role: msg.Role, Content: msg.Content}) + } + } + } + + finalMessage := message + var roleTools, roleSkills []string + if role != "" && role != "默认" && h.config.Roles != nil { + if r, exists := h.config.Roles[role]; exists && r.Enabled { + if r.UserPrompt != "" { + finalMessage = r.UserPrompt + "\n\n" + message + } + roleTools = r.Tools + roleSkills = r.Skills + } + } + + if _, err = h.db.AddMessage(conversationID, "user", message, nil); err != nil { + return "", "", fmt.Errorf("保存用户消息失败: %w", err) + } + + // 与 agent-loop/stream 一致:先创建助手消息占位,用 progressCallback 写过程详情(不发送 SSE) + assistantMsg, err := h.db.AddMessage(conversationID, "assistant", "处理中...", nil) + if err != nil { + h.logger.Warn("机器人:创建助手消息占位失败", zap.Error(err)) + } + var assistantMessageID string + if assistantMsg != nil { + assistantMessageID = assistantMsg.ID + } + progressCallback := h.createProgressCallback(conversationID, assistantMessageID, nil) + + result, err := h.agent.AgentLoopWithProgress(ctx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, roleSkills) + if err != nil { + errMsg := "执行失败: " + err.Error() + if assistantMessageID != "" { + _, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID) + _ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil) + } + return "", conversationID, err + } + + // 更新助手消息内容与 MCP 执行 ID(与 stream 一致) + if assistantMessageID != "" { + mcpIDsJSON := "" + if len(result.MCPExecutionIDs) > 0 { + jsonData, _ := json.Marshal(result.MCPExecutionIDs) + mcpIDsJSON = string(jsonData) + } + _, err = h.db.Exec( + "UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?", + result.Response, mcpIDsJSON, assistantMessageID, + ) + if err != nil { + h.logger.Warn("机器人:更新助手消息失败", zap.Error(err)) + } + } else { + if _, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs); err != nil { + h.logger.Warn("机器人:保存助手消息失败", zap.Error(err)) + } + } + if result.LastReActInput != "" || result.LastReActOutput != "" { + _ = h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput) + } + return result.Response, conversationID, nil +} + // StreamEvent 流式事件 type StreamEvent struct { Type string `json:"type"` // conversation, progress, tool_call, tool_result, response, error, cancelled, done diff --git a/internal/handler/config.go b/internal/handler/config.go index 47c799c5..55210567 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -150,6 +150,7 @@ type GetConfigResponse struct { Tools []ToolConfigInfo `json:"tools"` Agent config.AgentConfig `json:"agent"` Knowledge config.KnowledgeConfig `json:"knowledge"` + Robots config.RobotsConfig `json:"robots,omitempty"` } // ToolConfigInfo 工具配置信息 @@ -222,6 +223,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) { Tools: tools, Agent: h.config.Agent, Knowledge: h.config.Knowledge, + Robots: h.config.Robots, }) } @@ -479,6 +481,7 @@ type UpdateConfigRequest struct { Tools []ToolEnableStatus `json:"tools,omitempty"` Agent *config.AgentConfig `json:"agent,omitempty"` Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"` + Robots *config.RobotsConfig `json:"robots,omitempty"` } // ToolEnableStatus 工具启用状态 @@ -555,6 +558,16 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { ) } + // 更新机器人配置 + if req.Robots != nil { + h.config.Robots = *req.Robots + h.logger.Info("更新机器人配置", + zap.Bool("wecom_enabled", h.config.Robots.Wecom.Enabled), + zap.Bool("dingtalk_enabled", h.config.Robots.Dingtalk.Enabled), + zap.Bool("lark_enabled", h.config.Robots.Lark.Enabled), + ) + } + // 更新工具启用状态 if req.Tools != nil { // 分离内部工具和外部工具 @@ -856,6 +869,7 @@ func (h *ConfigHandler) saveConfig() error { updateOpenAIConfig(root, h.config.OpenAI) updateFOFAConfig(root, h.config.FOFA) updateKnowledgeConfig(root, h.config.Knowledge) + updateRobotsConfig(root, h.config.Robots) // 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用) // 读取原始配置以保持向后兼容 originalConfigs := make(map[string]map[string]bool) @@ -1031,6 +1045,30 @@ func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) { setFloatInMap(retrievalNode, "hybrid_weight", cfg.Retrieval.HybridWeight) } +func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) { + root := doc.Content[0] + robotsNode := ensureMap(root, "robots") + + wecomNode := ensureMap(robotsNode, "wecom") + setBoolInMap(wecomNode, "enabled", cfg.Wecom.Enabled) + setStringInMap(wecomNode, "token", cfg.Wecom.Token) + setStringInMap(wecomNode, "encoding_aes_key", cfg.Wecom.EncodingAESKey) + setStringInMap(wecomNode, "corp_id", cfg.Wecom.CorpID) + setStringInMap(wecomNode, "secret", cfg.Wecom.Secret) + setIntInMap(wecomNode, "agent_id", int(cfg.Wecom.AgentID)) + + dingtalkNode := ensureMap(robotsNode, "dingtalk") + setBoolInMap(dingtalkNode, "enabled", cfg.Dingtalk.Enabled) + setStringInMap(dingtalkNode, "client_id", cfg.Dingtalk.ClientID) + setStringInMap(dingtalkNode, "client_secret", cfg.Dingtalk.ClientSecret) + + larkNode := ensureMap(robotsNode, "lark") + setBoolInMap(larkNode, "enabled", cfg.Lark.Enabled) + setStringInMap(larkNode, "app_id", cfg.Lark.AppID) + setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret) + setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken) +} + func ensureMap(parent *yaml.Node, path ...string) *yaml.Node { current := parent for _, key := range path { diff --git a/internal/handler/robot.go b/internal/handler/robot.go new file mode 100644 index 00000000..40541b75 --- /dev/null +++ b/internal/handler/robot.go @@ -0,0 +1,401 @@ +package handler + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/binary" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +const ( + robotCmdHelp = "帮助" + robotCmdList = "列表" + robotCmdListAlt = "对话列表" + robotCmdSwitch = "切换" + robotCmdContinue = "继续" + robotCmdNew = "新对话" + robotCmdClear = "清空" + robotCmdCurrent = "当前" +) + +// 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 +} + +// 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), + } +} + +// sessionKey 生成会话 key +func (h *RobotHandler) sessionKey(platform, userID string) string { + return platform + "_" + userID +} + +// getOrCreateConversation 获取或创建当前会话 +func (h *RobotHandler) getOrCreateConversation(platform, userID string) (convID string, isNew bool) { + h.mu.RLock() + convID = h.sessions[h.sessionKey(platform, userID)] + h.mu.RUnlock() + if convID != "" { + return convID, false + } + conv, err := h.db.CreateConversation("机器人对话") + if err != nil { + h.logger.Warn("创建机器人会话失败", zap.Error(err)) + return "", false + } + convID = conv.ID + h.mu.Lock() + h.sessions[h.sessionKey(platform, userID)] = convID + h.mu.Unlock() + return convID, true +} + +// setConversation 切换当前会话 +func (h *RobotHandler) setConversation(platform, userID, convID string) { + h.mu.Lock() + h.sessions[h.sessionKey(platform, userID)] = convID + h.mu.Unlock() +} + +// clearConversation 清空当前会话(切换到新对话) +func (h *RobotHandler) clearConversation(platform, userID string) (newConvID string) { + conv, err := h.db.CreateConversation("新对话") + if err != nil { + h.logger.Warn("创建新对话失败", zap.Error(err)) + return "" + } + h.setConversation(platform, userID, conv.ID) + return conv.ID +} + +// HandleMessage 处理用户输入,返回回复文本(供各平台 webhook 调用) +func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply string) { + text = strings.TrimSpace(text) + if text == "" { + return "请输入内容或发送「帮助」查看命令。" + } + + // 命令分发 + switch { + case text == robotCmdHelp || text == "help" || text == "?" || text == "?": + return h.cmdHelp() + case text == robotCmdList || text == robotCmdListAlt: + return h.cmdList(userID) + case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" "): + var id string + if strings.HasPrefix(text, robotCmdSwitch+" ") { + id = strings.TrimSpace(text[len(robotCmdSwitch)+1:]) + } else { + id = strings.TrimSpace(text[len(robotCmdContinue)+1:]) + } + return h.cmdSwitch(platform, userID, id) + case text == robotCmdNew: + return h.cmdNew(platform, userID) + case text == robotCmdClear: + return h.cmdClear(platform, userID) + case text == robotCmdCurrent: + return h.cmdCurrent(platform, userID) + } + + // 普通消息:走 Agent + convID, _ := h.getOrCreateConversation(platform, userID) + if convID == "" { + return "无法创建或获取对话,请稍后再试。" + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, convID, text, "默认") + if err != nil { + h.logger.Warn("机器人 Agent 执行失败", zap.String("platform", platform), zap.String("userID", userID), zap.Error(err)) + return "处理失败: " + err.Error() + } + if newConvID != convID { + h.setConversation(platform, userID, newConvID) + } + return resp +} + +func (h *RobotHandler) cmdHelp() string { + return `【CyberStrikeAI 机器人命令】 +· 帮助 — 显示本帮助 +· 列表 / 对话列表 — 列出所有对话标题与 ID +· 切换 <对话ID> / 继续 <对话ID> — 指定对话继续 +· 新对话 — 开启新对话 +· 清空 — 清空当前上下文(等同于新对话) +· 当前 — 显示当前对话 ID 与标题 +除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。` +} + +func (h *RobotHandler) cmdList(userID string) string { + convs, err := h.db.ListConversations(50, 0, "") + if err != nil { + return "获取对话列表失败: " + err.Error() + } + if len(convs) == 0 { + return "暂无对话。发送任意内容将自动创建新对话。" + } + var b strings.Builder + b.WriteString("【对话列表】\n") + for i, c := range convs { + if i >= 20 { + b.WriteString("… 仅显示前 20 条\n") + break + } + b.WriteString(fmt.Sprintf("· %s\n ID: %s\n", c.Title, c.ID)) + } + return strings.TrimSuffix(b.String(), "\n") +} + +func (h *RobotHandler) cmdSwitch(platform, userID, convID string) string { + if convID == "" { + return "请指定对话 ID,例如:切换 xxx-xxx-xxx" + } + conv, err := h.db.GetConversation(convID) + if err != nil { + return "对话不存在或 ID 错误。" + } + h.setConversation(platform, userID, conv.ID) + return fmt.Sprintf("已切换到对话:「%s」\nID: %s", conv.Title, conv.ID) +} + +func (h *RobotHandler) cmdNew(platform, userID string) string { + newID := h.clearConversation(platform, userID) + if newID == "" { + return "创建新对话失败,请重试。" + } + return "已开启新对话,可直接发送内容。" +} + +func (h *RobotHandler) cmdClear(platform, userID string) string { + return h.cmdNew(platform, userID) +} + +func (h *RobotHandler) cmdCurrent(platform, userID string) string { + h.mu.RLock() + convID := h.sessions[h.sessionKey(platform, userID)] + h.mu.RUnlock() + if convID == "" { + return "当前没有进行中的对话。发送任意内容将创建新对话。" + } + conv, err := h.db.GetConversation(convID) + if err != nil { + return "当前对话 ID: " + convID + "(获取标题失败)" + } + return fmt.Sprintf("当前对话:「%s」\nID: %s", conv.Title, conv.ID) +} + +// —————— 企业微信 —————— + +// wecomXML 企业微信回调 XML(明文模式下的简化结构;加密模式需先解密再解析) +type wecomXML struct { + ToUserName string `xml:"ToUserName"` + FromUserName string `xml:"FromUserName"` + CreateTime int64 `xml:"CreateTime"` + MsgType string `xml:"MsgType"` + Content string `xml:"Content"` + MsgID string `xml:"MsgId"` + AgentID int64 `xml:"AgentID"` + Encrypt string `xml:"Encrypt"` // 加密模式下消息在此 +} + +// wecomReplyXML 被动回复 XML +type wecomReplyXML struct { + XMLName xml.Name `xml:"xml"` + ToUserName string `xml:"ToUserName"` + FromUserName string `xml:"FromUserName"` + CreateTime int64 `xml:"CreateTime"` + MsgType string `xml:"MsgType"` + Content string `xml:"Content"` +} + +// HandleWecomGET 企业微信 URL 校验(GET) +func (h *RobotHandler) HandleWecomGET(c *gin.Context) { + if !h.config.Robots.Wecom.Enabled { + c.String(http.StatusNotFound, "") + return + } + echostr := c.Query("echostr") + if echostr == "" { + c.String(http.StatusBadRequest, "missing echostr") + return + } + // 明文模式时企业微信可能直接传 echostr,先直接返回以通过校验 + c.String(http.StatusOK, echostr) +} + +// wecomDecrypt 企业微信消息解密(AES-256-CBC,PKCS7,明文格式:16字节随机+4字节长度+消息+corpID) +func wecomDecrypt(encodingAESKey, encryptedB64 string) ([]byte, error) { + key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") + if err != nil { + return nil, err + } + if len(key) != 32 { + return nil, fmt.Errorf("encoding_aes_key 解码后应为 32 字节") + } + ciphertext, err := base64.StdEncoding.DecodeString(encryptedB64) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + iv := key[:16] + mode := cipher.NewCBCDecrypter(block, iv) + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("密文长度不是块大小的倍数") + } + plain := make([]byte, len(ciphertext)) + mode.CryptBlocks(plain, ciphertext) + // 去除 PKCS7 填充 + n := int(plain[len(plain)-1]) + if n < 1 || n > 32 { + return nil, fmt.Errorf("无效的 PKCS7 填充") + } + plain = plain[:len(plain)-n] + // 企业微信格式:16 字节随机 + 4 字节长度(大端) + 消息 + corpID + if len(plain) < 20 { + return nil, fmt.Errorf("明文过短") + } + msgLen := binary.BigEndian.Uint32(plain[16:20]) + if int(20+msgLen) > len(plain) { + return nil, fmt.Errorf("消息长度越界") + } + return plain[20 : 20+msgLen], nil +} + +// HandleWecomPOST 企业微信消息回调(POST),支持明文与加密模式 +func (h *RobotHandler) HandleWecomPOST(c *gin.Context) { + if !h.config.Robots.Wecom.Enabled { + c.String(http.StatusOK, "") + return + } + bodyRaw, _ := io.ReadAll(c.Request.Body) + var body wecomXML + if err := xml.Unmarshal(bodyRaw, &body); err != nil { + h.logger.Debug("企业微信 POST 解析 XML 失败", zap.Error(err)) + c.String(http.StatusOK, "") + return + } + // 加密模式:先解密再解析内层 XML + if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" { + decrypted, err := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, body.Encrypt) + if err != nil { + h.logger.Warn("企业微信消息解密失败", zap.Error(err)) + c.String(http.StatusOK, "") + return + } + if err := xml.Unmarshal(decrypted, &body); err != nil { + h.logger.Warn("企业微信解密后 XML 解析失败", zap.Error(err)) + c.String(http.StatusOK, "") + return + } + } + if body.MsgType != "text" { + c.XML(http.StatusOK, wecomReplyXML{ + ToUserName: body.FromUserName, + FromUserName: body.ToUserName, + CreateTime: time.Now().Unix(), + MsgType: "text", + Content: "暂仅支持文本消息,请发送文字。", + }) + return + } + userID := body.FromUserName + text := strings.TrimSpace(body.Content) + reply := h.HandleMessage("wecom", userID, text) + // 加密模式需加密回复(此处简化为明文回复;若企业要求加密需再实现加密) + c.XML(http.StatusOK, wecomReplyXML{ + ToUserName: body.FromUserName, + FromUserName: body.ToUserName, + CreateTime: time.Now().Unix(), + MsgType: "text", + Content: reply, + }) +} + +// —————— 测试接口(需登录,用于验证机器人逻辑,无需钉钉/飞书客户端) —————— + +// RobotTestRequest 模拟机器人消息请求 +type RobotTestRequest struct { + Platform string `json:"platform"` // 如 "dingtalk"、"lark"、"wecom" + UserID string `json:"user_id"` + Text string `json:"text"` +} + +// HandleRobotTest 供本地验证:POST JSON { "platform", "user_id", "text" },返回 { "reply": "..." } +func (h *RobotHandler) HandleRobotTest(c *gin.Context) { + var req RobotTestRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求体需为 JSON,包含 platform、user_id、text"}) + return + } + platform := strings.TrimSpace(req.Platform) + if platform == "" { + platform = "test" + } + userID := strings.TrimSpace(req.UserID) + if userID == "" { + userID = "test_user" + } + reply := h.HandleMessage(platform, userID, req.Text) + c.JSON(http.StatusOK, gin.H{"reply": reply}) +} + +// —————— 钉钉 —————— + +// HandleDingtalkPOST 钉钉事件回调(流式接入等);当前为占位,返回 200 +func (h *RobotHandler) HandleDingtalkPOST(c *gin.Context) { + if !h.config.Robots.Dingtalk.Enabled { + c.JSON(http.StatusOK, gin.H{}) + return + } + // 钉钉流式/事件回调格式需按官方文档解析并异步回复,此处仅返回 200 + c.JSON(http.StatusOK, gin.H{"message": "ok"}) +} + +// —————— 飞书 —————— + +// HandleLarkPOST 飞书事件回调;当前为占位,返回 200;验证时需返回 challenge +func (h *RobotHandler) HandleLarkPOST(c *gin.Context) { + if !h.config.Robots.Lark.Enabled { + c.JSON(http.StatusOK, gin.H{}) + return + } + var body struct { + Challenge string `json:"challenge"` + } + if err := c.ShouldBindJSON(&body); err == nil && body.Challenge != "" { + c.JSON(http.StatusOK, gin.H{"challenge": body.Challenge}) + return + } + c.JSON(http.StatusOK, gin.H{}) +} diff --git a/internal/robot/conn.go b/internal/robot/conn.go new file mode 100644 index 00000000..d57e361d --- /dev/null +++ b/internal/robot/conn.go @@ -0,0 +1,6 @@ +package robot + +// MessageHandler 供飞书/钉钉长连接调用的消息处理接口(由 handler.RobotHandler 实现) +type MessageHandler interface { + HandleMessage(platform, userID, text string) string +} diff --git a/internal/robot/ding.go b/internal/robot/ding.go new file mode 100644 index 00000000..1fe8e88c --- /dev/null +++ b/internal/robot/ding.go @@ -0,0 +1,95 @@ +package robot + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + + "cyberstrike-ai/internal/config" + + "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + dingutils "github.com/open-dingtalk/dingtalk-stream-sdk-go/utils" + "go.uber.org/zap" +) + +// StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复 +func StartDing(cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) { + if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" { + return + } + streamClient := client.NewStreamClient( + client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)), + client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get", + chatbot.NewDefaultChatBotFrameHandler(func(ctx context.Context, msg *chatbot.BotCallbackDataModel) ([]byte, error) { + go handleDingMessage(ctx, msg, h, logger) + return nil, nil + }).OnEventReceived), + ) + logger.Info("钉钉 Stream 正在连接…", zap.String("client_id", cfg.ClientID)) + go func() { + err := streamClient.Start(context.Background()) + if err != nil { + logger.Error("钉钉 Stream 长连接退出", zap.Error(err)) + } + }() + logger.Info("钉钉 Stream 已启动(无需公网),等待收消息", zap.String("client_id", cfg.ClientID)) +} + +func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h MessageHandler, logger *zap.Logger) { + if msg == nil || msg.SessionWebhook == "" { + return + } + content := "" + if msg.Text.Content != "" { + content = strings.TrimSpace(msg.Text.Content) + } + if content == "" && msg.Msgtype == "richText" { + if cMap, ok := msg.Content.(map[string]interface{}); ok { + if rich, ok := cMap["richText"].([]interface{}); ok { + for _, c := range rich { + if m, ok := c.(map[string]interface{}); ok { + if txt, ok := m["text"].(string); ok { + content = strings.TrimSpace(txt) + break + } + } + } + } + } + } + if content == "" { + logger.Debug("钉钉消息内容为空,已忽略", zap.String("msgtype", msg.Msgtype)) + return + } + logger.Info("钉钉收到消息", zap.String("sender", msg.SenderId), zap.String("content", content)) + userID := msg.SenderId + if userID == "" { + userID = msg.ConversationId + } + reply := h.HandleMessage("dingtalk", userID, content) + body := map[string]interface{}{ + "msgtype": "text", + "text": map[string]string{"content": reply}, + } + bodyBytes, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, msg.SessionWebhook, bytes.NewReader(bodyBytes)) + if err != nil { + logger.Warn("钉钉构造回复请求失败", zap.Error(err)) + return + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + logger.Warn("钉钉回复请求失败", zap.Error(err)) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + logger.Warn("钉钉回复非 200", zap.Int("status", resp.StatusCode)) + return + } + logger.Debug("钉钉回复成功", zap.String("content_preview", reply)) +} diff --git a/internal/robot/lark.go b/internal/robot/lark.go new file mode 100644 index 00000000..30c825fb --- /dev/null +++ b/internal/robot/lark.go @@ -0,0 +1,83 @@ +package robot + +import ( + "context" + "encoding/json" + "strings" + + "cyberstrike-ai/internal/config" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + lark "github.com/larksuite/oapi-sdk-go/v3" + larkws "github.com/larksuite/oapi-sdk-go/v3/ws" + "go.uber.org/zap" +) + +type larkTextContent struct { + Text string `json:"text"` +} + +// StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复 +func StartLark(cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) { + if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" { + return + } + larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret) + eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error { + go handleLarkMessage(ctx, event, h, larkClient, logger) + return nil + }) + wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret, + larkws.WithEventHandler(eventHandler), + larkws.WithLogLevel(larkcore.LogLevelInfo), + ) + go func() { + err := wsClient.Start(context.Background()) + if err != nil { + logger.Error("飞书长连接退出", zap.Error(err)) + } + }() + logger.Info("飞书长连接已启动(无需公网)", zap.String("app_id", cfg.AppID)) +} + +func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h MessageHandler, client *lark.Client, logger *zap.Logger) { + if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil { + return + } + msg := event.Event.Message + msgType := larkcore.StringValue(msg.MessageType) + if msgType != larkim.MsgTypeText { + logger.Debug("飞书暂仅处理文本消息", zap.String("msg_type", msgType)) + return + } + var textBody larkTextContent + if err := json.Unmarshal([]byte(larkcore.StringValue(msg.Content)), &textBody); err != nil { + logger.Warn("飞书消息 Content 解析失败", zap.Error(err)) + return + } + text := strings.TrimSpace(textBody.Text) + if text == "" { + return + } + userID := "" + if event.Event.Sender.SenderId.UserId != nil { + userID = *event.Event.Sender.SenderId.UserId + } + messageID := larkcore.StringValue(msg.MessageId) + reply := h.HandleMessage("lark", userID, text) + contentBytes, _ := json.Marshal(larkTextContent{Text: reply}) + _, err := client.Im.Message.Reply(ctx, larkim.NewReplyMessageReqBuilder(). + MessageId(messageID). + Body(larkim.NewReplyMessageReqBodyBuilder(). + MsgType(larkim.MsgTypeText). + Content(string(contentBytes)). + Build()). + Build()) + if err != nil { + logger.Warn("飞书回复失败", zap.String("message_id", messageID), zap.Error(err)) + return + } + logger.Debug("飞书已回复", zap.String("message_id", messageID)) +} diff --git a/web/static/js/settings.js b/web/static/js/settings.js index e4675d2a..75ac4832 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -170,6 +170,38 @@ async function loadConfig(loadTools = true) { retrievalWeightInput.value = (hybridWeight !== undefined && hybridWeight !== null) ? hybridWeight : 0.7; } } + + // 填充机器人配置 + const robots = currentConfig.robots || {}; + const wecom = robots.wecom || {}; + const dingtalk = robots.dingtalk || {}; + const lark = robots.lark || {}; + const wecomEnabled = document.getElementById('robot-wecom-enabled'); + if (wecomEnabled) wecomEnabled.checked = wecom.enabled === true; + const wecomToken = document.getElementById('robot-wecom-token'); + if (wecomToken) wecomToken.value = wecom.token || ''; + const wecomAes = document.getElementById('robot-wecom-encoding-aes-key'); + if (wecomAes) wecomAes.value = wecom.encoding_aes_key || ''; + const wecomCorp = document.getElementById('robot-wecom-corp-id'); + if (wecomCorp) wecomCorp.value = wecom.corp_id || ''; + const wecomSecret = document.getElementById('robot-wecom-secret'); + if (wecomSecret) wecomSecret.value = wecom.secret || ''; + const wecomAgentId = document.getElementById('robot-wecom-agent-id'); + if (wecomAgentId) wecomAgentId.value = wecom.agent_id || '0'; + const dingtalkEnabled = document.getElementById('robot-dingtalk-enabled'); + if (dingtalkEnabled) dingtalkEnabled.checked = dingtalk.enabled === true; + const dingtalkClientId = document.getElementById('robot-dingtalk-client-id'); + if (dingtalkClientId) dingtalkClientId.value = dingtalk.client_id || ''; + const dingtalkClientSecret = document.getElementById('robot-dingtalk-client-secret'); + if (dingtalkClientSecret) dingtalkClientSecret.value = dingtalk.client_secret || ''; + const larkEnabled = document.getElementById('robot-lark-enabled'); + if (larkEnabled) larkEnabled.checked = lark.enabled === true; + const larkAppId = document.getElementById('robot-lark-app-id'); + if (larkAppId) larkAppId.value = lark.app_id || ''; + const larkAppSecret = document.getElementById('robot-lark-app-secret'); + if (larkAppSecret) larkAppSecret.value = lark.app_secret || ''; + const larkVerify = document.getElementById('robot-lark-verify-token'); + if (larkVerify) larkVerify.value = lark.verify_token || ''; // 只有在需要时才加载工具列表(MCP管理页面需要,系统设置页面不需要) if (loadTools) { @@ -696,6 +728,7 @@ async function applySettings() { } }; + const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim(); const config = { openai: { api_key: apiKey, @@ -711,6 +744,27 @@ async function applySettings() { max_iterations: parseInt(document.getElementById('agent-max-iterations').value) || 30 }, knowledge: knowledgeConfig, + robots: { + wecom: { + enabled: document.getElementById('robot-wecom-enabled')?.checked === true, + token: document.getElementById('robot-wecom-token')?.value.trim() || '', + encoding_aes_key: document.getElementById('robot-wecom-encoding-aes-key')?.value.trim() || '', + corp_id: document.getElementById('robot-wecom-corp-id')?.value.trim() || '', + secret: document.getElementById('robot-wecom-secret')?.value.trim() || '', + agent_id: parseInt(wecomAgentIdVal, 10) || 0 + }, + dingtalk: { + enabled: document.getElementById('robot-dingtalk-enabled')?.checked === true, + client_id: document.getElementById('robot-dingtalk-client-id')?.value.trim() || '', + client_secret: document.getElementById('robot-dingtalk-client-secret')?.value.trim() || '' + }, + lark: { + enabled: document.getElementById('robot-lark-enabled')?.checked === true, + app_id: document.getElementById('robot-lark-app-id')?.value.trim() || '', + app_secret: document.getElementById('robot-lark-app-secret')?.value.trim() || '', + verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || '' + } + }, tools: [] }; diff --git a/web/templates/index.html b/web/templates/index.html index 0d96d53e..fe3e906c 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1062,6 +1062,9 @@
基本设置
+
+ 机器人设置 +
安全设置
@@ -1194,6 +1197,114 @@ + +
+
+

机器人设置

+

配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。

+
+ + +
+

企业微信

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

钉钉

+
+
+ +
+
+ + +
+
+ + + 需开启机器人能力并配置流式接入 +
+
+
+ + +
+

飞书 (Lark)

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

机器人命令说明

+

在对话中可发送以下命令:

+ +
+ +
+ +
+
+