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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
机器人命令说明
+
在对话中可发送以下命令:
+
+ - 帮助 — 显示命令帮助
+ - 列表 或 对话列表 — 列出所有对话标题与 ID
+ - 切换 <对话ID> 或 继续 <对话ID> — 指定对话 ID 继续对话
+ - 新对话 — 开启新对话
+ - 清空 — 清空当前对话上下文(不删除历史)
+ - 当前 — 显示当前对话 ID 与标题
+
+
+
+
+
+
+
+