Compare commits

...

13 Commits

Author SHA1 Message Date
公明 c265e66afb Update config.yaml 2026-03-02 20:41:41 +08:00
公明 647bb4b5e4 Add files via upload 2026-03-02 20:37:27 +08:00
公明 dd311f7a3b Add files via upload 2026-03-02 20:13:16 +08:00
公明 2e482a3baf Update version number to v1.3.13 2026-03-02 01:11:08 +08:00
公明 67d5e7f11e Add files via upload 2026-03-02 01:10:44 +08:00
公明 7e0198a64c Add files via upload 2026-03-02 00:58:40 +08:00
公明 1e50272229 Update version number to v1.3.12 2026-03-02 00:52:38 +08:00
公明 39b47a86fb Add files via upload 2026-03-02 00:49:21 +08:00
公明 74738ee555 Add files via upload 2026-03-01 13:35:11 +08:00
公明 90bc3f4b61 Update config.yaml 2026-02-28 23:34:07 +08:00
公明 ad96be3c64 Add files via upload 2026-02-28 23:31:17 +08:00
公明 8866ff4cdd Add files via upload 2026-02-28 23:09:48 +08:00
公明 3534a956b2 Add files via upload 2026-02-28 23:03:09 +08:00
13 changed files with 841 additions and 118 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.3.10" version: "v1.3.14"
# 服务器配置 # 服务器配置
server: server:
+8
View File
@@ -113,6 +113,11 @@
| **新对话** | 开启一个新对话,后续消息在新对话中 | | **新对话** | 开启一个新对话,后续消息在新对话中 |
| **清空** | 清空当前对话上下文(效果等同「新对话」) | | **清空** | 清空当前对话上下文(效果等同「新对话」) |
| **当前** | 显示当前对话 ID 与标题 | | **当前** | 显示当前对话 ID 与标题 |
| **停止** | 中断当前正在执行的任务 |
| **角色****角色列表** | 列出所有可用角色(渗透测试、CTF、Web 应用扫描等) |
| **角色 \<角色名\>****切换角色 \<角色名\>** | 切换当前使用的角色 |
| **删除 \<对话ID\>** | 删除指定对话 |
| **版本** | 显示当前 CyberStrikeAI 版本号 |
除以上命令外,**直接输入任意文字**会作为用户消息发给 AI,与 Web 端对话逻辑一致(渗透测试/安全分析等)。 除以上命令外,**直接输入任意文字**会作为用户消息发给 AI,与 Web 端对话逻辑一致(渗透测试/安全分析等)。
@@ -184,6 +189,9 @@ curl -X POST "http://localhost:8080/api/robot/test" \
按顺序检查: 按顺序检查:
0. **笔记本合盖睡眠 / 断网后**
钉钉、飞书均使用长连接收消息,睡眠或断网后连接会断开。程序会**自动重连**(约 5 秒~60 秒内重试)。唤醒或恢复网络后稍等一会儿再发消息;若仍无反应,可重启 CyberStrikeAI 进程。
1. **Client ID / Client Secret 是否与开放平台完全一致** 1. **Client ID / Client Secret 是否与开放平台完全一致**
从「凭证与基础信息」里**复制粘贴**,不要手打。注意数字 **0** 与字母 **o**、数字 **1** 与字母 **l**(例如 `ding9gf9tiozuc504aer` 中间是 **504** 不是 5o4)。 从「凭证与基础信息」里**复制粘贴**,不要手打。注意数字 **0** 与字母 **o**、数字 **1** 与字母 **l**(例如 `ding9gf9tiozuc504aer` 中间是 **504** 不是 5o4)。
+8
View File
@@ -112,6 +112,11 @@ Send these **text commands** to the bot in DingTalk or Lark (text only):
| **新对话** (new) | Start a new conversation | | **新对话** (new) | Start a new conversation |
| **清空** (clear) | Clear current context (same effect as new conversation) | | **清空** (clear) | Clear current context (same effect as new conversation) |
| **当前** (current) | Show current conversation ID and title | | **当前** (current) | Show current conversation ID and title |
| **停止** (stop) | Abort the currently running task |
| **角色** or **角色列表** (roles) | List all available roles (penetration testing, CTF, Web scan, etc.) |
| **角色 \<roleName\>** or **切换角色 \<roleName\>** | Switch to the specified role |
| **删除 \<conversationID\>** | Delete the specified conversation |
| **版本** (version) | Show current CyberStrikeAI version |
Any other text is sent to the AI as a user message, same as in the web UI (e.g. penetration testing, security analysis). Any other text is sent to the AI as a user message, same as in the web UI (e.g. penetration testing, security analysis).
@@ -183,6 +188,9 @@ API: `POST /api/robot/test` (requires login). Body: `{"platform":"optional","use
Check in this order: Check in this order:
0. **After laptop sleep or network drop**
DingTalk and Lark both use long-lived connections; they break when the machine sleeps or the network drops. The app **auto-reconnects** (retries within about 560 seconds). After wake or network recovery, wait a moment before sending; if there is still no response, restart the CyberStrikeAI process.
1. **Client ID / Client Secret match the open platform exactly** 1. **Client ID / Client Secret match the open platform exactly**
Copy from “Credentials and basic info”; avoid typing. Watch **0** vs **o** and **1** vs **l** (e.g. `ding9gf9tiozuc504aer` has **504**, not 5o4). Copy from “Credentials and basic info”; avoid typing. Watch **0** vs **o** and **1** vs **l** (e.g. `ding9gf9tiozuc504aer` has **504**, not 5o4).
+4
View File
@@ -48,3 +48,7 @@ require (
golang.org/x/text v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect google.golang.org/protobuf v1.30.0 // indirect
) )
// 修复钉钉 Stream SDK 在长连接断开(熄屏/网络中断)后 "panic: send on closed channel" 问题
// 详见: https://github.com/open-dingtalk/dingtalk-stream-sdk-go/issues/28
replace github.com/open-dingtalk/dingtalk-stream-sdk-go => github.com/uouuou/dingtalk-stream-sdk-go v0.0.0-20250626025113-079132acc406
+2 -2
View File
@@ -62,8 +62,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo= github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo=
@@ -85,6 +83,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/uouuou/dingtalk-stream-sdk-go v0.0.0-20250626025113-079132acc406 h1:b72HNsEnmTRn7vhWGOfbWHAkA5RbRCk0Pbc56V2WAuY=
github.com/uouuou/dingtalk-stream-sdk-go v0.0.0-20250626025113-079132acc406/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+194 -7
View File
@@ -2,10 +2,14 @@ package handler
import ( import (
"context" "context"
"crypto/rand"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -108,11 +112,159 @@ func (h *AgentHandler) SetSkillsManager(manager *skills.Manager) {
h.skillsManager = manager h.skillsManager = manager
} }
// ChatAttachment 聊天附件(用户上传的文件)
type ChatAttachment struct {
FileName string `json:"fileName"` // 文件名
Content string `json:"content"` // 文本内容或 base64(由 MimeType 决定是否解码)
MimeType string `json:"mimeType,omitempty"`
}
// ChatRequest 聊天请求 // ChatRequest 聊天请求
type ChatRequest struct { type ChatRequest struct {
Message string `json:"message" binding:"required"` Message string `json:"message" binding:"required"`
ConversationID string `json:"conversationId,omitempty"` ConversationID string `json:"conversationId,omitempty"`
Role string `json:"role,omitempty"` // 角色名称 Role string `json:"role,omitempty"` // 角色名称
Attachments []ChatAttachment `json:"attachments,omitempty"`
}
const (
maxAttachments = 10
maxAttachmentBytes = 2 * 1024 * 1024 // 单文件约 2MB(仅用于是否内联展示内容,不限制上传)
chatUploadsDirName = "chat_uploads" // 对话附件保存的根目录(相对当前工作目录)
)
// saveAttachmentsToDateAndConversationDir 将附件保存到 chat_uploads/YYYY-MM-DD/{conversationID}/,返回每个文件的保存路径(与 attachments 顺序一致)
// conversationID 为空时使用 "_new" 作为目录名(新对话尚未有 ID)
func saveAttachmentsToDateAndConversationDir(attachments []ChatAttachment, conversationID string, logger *zap.Logger) (savedPaths []string, err error) {
if len(attachments) == 0 {
return nil, nil
}
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("获取当前工作目录失败: %w", err)
}
dateDir := filepath.Join(cwd, chatUploadsDirName, time.Now().Format("2006-01-02"))
convDirName := strings.TrimSpace(conversationID)
if convDirName == "" {
convDirName = "_new"
} else {
convDirName = strings.ReplaceAll(convDirName, string(filepath.Separator), "_")
}
targetDir := filepath.Join(dateDir, convDirName)
if err = os.MkdirAll(targetDir, 0755); err != nil {
return nil, fmt.Errorf("创建上传目录失败: %w", err)
}
savedPaths = make([]string, 0, len(attachments))
for i, a := range attachments {
raw, decErr := attachmentContentToBytes(a)
if decErr != nil {
return nil, fmt.Errorf("附件 %s 解码失败: %w", a.FileName, decErr)
}
baseName := filepath.Base(a.FileName)
if baseName == "" || baseName == "." {
baseName = "file"
}
baseName = strings.ReplaceAll(baseName, string(filepath.Separator), "_")
ext := filepath.Ext(baseName)
nameNoExt := strings.TrimSuffix(baseName, ext)
suffix := fmt.Sprintf("_%s_%s", time.Now().Format("150405"), shortRand(6))
var unique string
if ext != "" {
unique = nameNoExt + suffix + ext
} else {
unique = baseName + suffix
}
fullPath := filepath.Join(targetDir, unique)
if err = os.WriteFile(fullPath, raw, 0644); err != nil {
return nil, fmt.Errorf("写入文件 %s 失败: %w", a.FileName, err)
}
absPath, _ := filepath.Abs(fullPath)
savedPaths = append(savedPaths, absPath)
if logger != nil {
logger.Debug("对话附件已保存", zap.Int("index", i+1), zap.String("fileName", a.FileName), zap.String("path", absPath))
}
}
return savedPaths, nil
}
func shortRand(n int) string {
const letters = "0123456789abcdef"
b := make([]byte, n)
_, _ = rand.Read(b)
for i := range b {
b[i] = letters[int(b[i])%len(letters)]
}
return string(b)
}
func attachmentContentToBytes(a ChatAttachment) ([]byte, error) {
content := a.Content
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil && len(decoded) > 0 {
return decoded, nil
}
return []byte(content), nil
}
// userMessageContentForStorage 返回要存入数据库的用户消息内容:有附件时在正文后追加附件名(及路径),刷新后仍能显示,继续对话时大模型也能从历史中拿到路径
func userMessageContentForStorage(message string, attachments []ChatAttachment, savedPaths []string) string {
if len(attachments) == 0 {
return message
}
var b strings.Builder
b.WriteString(message)
for i, a := range attachments {
b.WriteString("\n📎 ")
b.WriteString(a.FileName)
if i < len(savedPaths) && savedPaths[i] != "" {
b.WriteString(": ")
b.WriteString(savedPaths[i])
}
}
return b.String()
}
// appendAttachmentsToMessage 将附件内容拼接到用户消息末尾;若 savedPaths 与 attachments 一一对应,会先写入“已保存到”路径供大模型按路径读取
func appendAttachmentsToMessage(msg string, attachments []ChatAttachment, savedPaths []string, logger *zap.Logger) string {
if len(attachments) == 0 {
return msg
}
var b strings.Builder
b.WriteString(msg)
if len(savedPaths) == len(attachments) {
b.WriteString("\n\n[用户上传的文件已保存到以下路径(可使用 cat/exec 等工具按路径读取)]\n")
for i, a := range attachments {
b.WriteString(fmt.Sprintf("- %s: %s\n", a.FileName, savedPaths[i]))
}
b.WriteString("\n[以下为附件内容(便于直接参考)]\n")
}
for i, a := range attachments {
b.WriteString(fmt.Sprintf("\n--- 附件 %d: %s ---\n", i+1, a.FileName))
content := a.Content
mime := strings.ToLower(strings.TrimSpace(a.MimeType))
isText := strings.HasPrefix(mime, "text/") || mime == "" ||
strings.Contains(mime, "json") || strings.Contains(mime, "xml") ||
strings.Contains(mime, "javascript") || strings.Contains(mime, "shell")
if isText && len(content) > 0 {
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil && len(decoded) > 0 {
content = string(decoded)
}
b.WriteString("```\n")
b.WriteString(content)
b.WriteString("\n```\n")
} else {
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil {
content = string(decoded)
}
if utf8.ValidString(content) && len(content) < maxAttachmentBytes {
b.WriteString("```\n")
b.WriteString(content)
b.WriteString("\n```\n")
} else {
b.WriteString(fmt.Sprintf("(二进制文件,约 %d 字节,已保存到上述路径,可按路径读取)\n", len(content)))
}
}
}
return b.String()
} }
// ChatResponse 聊天响应 // ChatResponse 聊天响应
@@ -181,6 +333,12 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages))) h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
} }
// 校验附件数量(非流式)
if len(req.Attachments) > maxAttachments {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("附件最多 %d 个", maxAttachments)})
return
}
// 应用角色用户提示词和工具配置 // 应用角色用户提示词和工具配置
finalMessage := req.Message finalMessage := req.Message
var roleTools []string // 角色配置的工具列表 var roleTools []string // 角色配置的工具列表
@@ -206,9 +364,20 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
} }
} }
} }
var savedPaths []string
if len(req.Attachments) > 0 {
savedPaths, err = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
if err != nil {
h.logger.Error("保存对话附件失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存上传文件失败: " + err.Error()})
return
}
}
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths, h.logger)
// 保存用户消息(保存原始消息,不包含角色提示词) // 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil) userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
_, err = h.db.AddMessage(conversationID, "user", userContent, nil)
if err != nil { if err != nil {
h.logger.Error("保存用户消息失败", zap.Error(err)) h.logger.Error("保存用户消息失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存用户消息失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存用户消息失败: " + err.Error()})
@@ -618,6 +787,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages))) h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
} }
// 校验附件数量
if len(req.Attachments) > maxAttachments {
sendEvent("error", fmt.Sprintf("附件最多 %d 个", maxAttachments), nil)
return
}
// 应用角色用户提示词和工具配置 // 应用角色用户提示词和工具配置
finalMessage := req.Message finalMessage := req.Message
var roleTools []string // 角色配置的工具列表 var roleTools []string // 角色配置的工具列表
@@ -645,10 +820,22 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
} }
} }
} }
var savedPaths []string
if len(req.Attachments) > 0 {
savedPaths, err = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
if err != nil {
h.logger.Error("保存对话附件失败", zap.Error(err))
sendEvent("error", "保存上传文件失败: "+err.Error(), nil)
return
}
}
// 将附件内容拼接到 finalMessage,便于大模型识别上传了哪些文件及内容
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths, h.logger)
// 如果roleTools为空,表示使用所有工具(默认角色或未配置工具的角色) // 如果roleTools为空,表示使用所有工具(默认角色或未配置工具的角色)
// 保存用户消息(保存原始消息,不包含角色提示词) // 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil) userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
_, err = h.db.AddMessage(conversationID, "user", userContent, nil)
if err != nil { if err != nil {
h.logger.Error("保存用户消息失败", zap.Error(err)) h.logger.Error("保存用户消息失败", zap.Error(err))
} }
+238 -46
View File
@@ -7,9 +7,11 @@ import (
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"encoding/xml" "encoding/xml"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -22,34 +24,45 @@ import (
) )
const ( const (
robotCmdHelp = "帮助" robotCmdHelp = "帮助"
robotCmdList = "列表" robotCmdList = "列表"
robotCmdListAlt = "对话列表" robotCmdListAlt = "对话列表"
robotCmdSwitch = "切换" robotCmdSwitch = "切换"
robotCmdContinue = "继续" robotCmdContinue = "继续"
robotCmdNew = "新对话" robotCmdNew = "新对话"
robotCmdClear = "清空" robotCmdClear = "清空"
robotCmdCurrent = "当前" robotCmdCurrent = "当前"
robotCmdStop = "停止"
robotCmdRoles = "角色"
robotCmdRolesList = "角色列表"
robotCmdSwitchRole = "切换角色"
robotCmdDelete = "删除"
robotCmdVersion = "版本"
) )
// RobotHandler 企业微信/钉钉/飞书等机器人回调处理 // RobotHandler 企业微信/钉钉/飞书等机器人回调处理
type RobotHandler struct { type RobotHandler struct {
config *config.Config config *config.Config
db *database.DB db *database.DB
agentHandler *AgentHandler agentHandler *AgentHandler
logger *zap.Logger logger *zap.Logger
mu sync.RWMutex mu sync.RWMutex
sessions map[string]string // key: "platform_userID", value: conversationID 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", 用于停止命令中断任务
} }
// NewRobotHandler 创建机器人处理器 // NewRobotHandler 创建机器人处理器
func NewRobotHandler(cfg *config.Config, db *database.DB, agentHandler *AgentHandler, logger *zap.Logger) *RobotHandler { func NewRobotHandler(cfg *config.Config, db *database.DB, agentHandler *AgentHandler, logger *zap.Logger) *RobotHandler {
return &RobotHandler{ return &RobotHandler{
config: cfg, config: cfg,
db: db, db: db,
agentHandler: agentHandler, agentHandler: agentHandler,
logger: logger, logger: logger,
sessions: make(map[string]string), sessions: make(map[string]string),
sessionRoles: make(map[string]string),
runningCancels: make(map[string]context.CancelFunc),
} }
} }
@@ -58,15 +71,21 @@ func (h *RobotHandler) sessionKey(platform, userID string) string {
return platform + "_" + userID return platform + "_" + userID
} }
// getOrCreateConversation 获取或创建当前会话 // getOrCreateConversation 获取或创建当前会话,title 用于新对话的标题(取用户首条消息前50字)
func (h *RobotHandler) getOrCreateConversation(platform, userID string) (convID string, isNew bool) { func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (convID string, isNew bool) {
h.mu.RLock() h.mu.RLock()
convID = h.sessions[h.sessionKey(platform, userID)] convID = h.sessions[h.sessionKey(platform, userID)]
h.mu.RUnlock() h.mu.RUnlock()
if convID != "" { if convID != "" {
return convID, false return convID, false
} }
conv, err := h.db.CreateConversation("机器人对话") t := strings.TrimSpace(title)
if t == "" {
t = "新对话 " + time.Now().Format("01-02 15:04")
} else {
t = safeTruncateString(t, 50)
}
conv, err := h.db.CreateConversation(t)
if err != nil { if err != nil {
h.logger.Warn("创建机器人会话失败", zap.Error(err)) h.logger.Warn("创建机器人会话失败", zap.Error(err))
return "", false return "", false
@@ -85,9 +104,28 @@ func (h *RobotHandler) setConversation(platform, userID, convID string) {
h.mu.Unlock() h.mu.Unlock()
} }
// getRole 获取当前用户使用的角色,未设置时返回"默认"
func (h *RobotHandler) getRole(platform, userID string) string {
h.mu.RLock()
role := h.sessionRoles[h.sessionKey(platform, userID)]
h.mu.RUnlock()
if role == "" {
return "默认"
}
return role
}
// setRole 设置当前用户使用的角色
func (h *RobotHandler) setRole(platform, userID, roleName string) {
h.mu.Lock()
h.sessionRoles[h.sessionKey(platform, userID)] = roleName
h.mu.Unlock()
}
// clearConversation 清空当前会话(切换到新对话) // clearConversation 清空当前会话(切换到新对话)
func (h *RobotHandler) clearConversation(platform, userID string) (newConvID string) { func (h *RobotHandler) clearConversation(platform, userID string) (newConvID string) {
conv, err := h.db.CreateConversation("新对话") title := "新对话 " + time.Now().Format("01-02 15:04")
conv, err := h.db.CreateConversation(title)
if err != nil { if err != nil {
h.logger.Warn("创建新对话失败", zap.Error(err)) h.logger.Warn("创建新对话失败", zap.Error(err))
return "" return ""
@@ -100,41 +138,91 @@ func (h *RobotHandler) clearConversation(platform, userID string) (newConvID str
func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply string) { func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply string) {
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
if text == "" { if text == "" {
return "请输入内容或发送「帮助」查看命令。" return "请输入内容或发送「帮助」/ help 查看命令。"
} }
// 命令分发 // 命令分发(支持中英文)
switch { switch {
case text == robotCmdHelp || text == "help" || text == "" || text == "?": case text == robotCmdHelp || text == "help" || text == "" || text == "?":
return h.cmdHelp() return h.cmdHelp()
case text == robotCmdList || text == robotCmdListAlt: case text == robotCmdList || text == robotCmdListAlt || text == "list":
return h.cmdList(userID) return h.cmdList()
case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" "): case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" ") || strings.HasPrefix(text, "switch ") || strings.HasPrefix(text, "continue "):
var id string var id string
if strings.HasPrefix(text, robotCmdSwitch+" ") { switch {
case strings.HasPrefix(text, robotCmdSwitch+" "):
id = strings.TrimSpace(text[len(robotCmdSwitch)+1:]) id = strings.TrimSpace(text[len(robotCmdSwitch)+1:])
} else { case strings.HasPrefix(text, robotCmdContinue+" "):
id = strings.TrimSpace(text[len(robotCmdContinue)+1:]) id = strings.TrimSpace(text[len(robotCmdContinue)+1:])
case strings.HasPrefix(text, "switch "):
id = strings.TrimSpace(text[7:])
default:
id = strings.TrimSpace(text[9:])
} }
return h.cmdSwitch(platform, userID, id) return h.cmdSwitch(platform, userID, id)
case text == robotCmdNew: case text == robotCmdNew || text == "new":
return h.cmdNew(platform, userID) return h.cmdNew(platform, userID)
case text == robotCmdClear: case text == robotCmdClear || text == "clear":
return h.cmdClear(platform, userID) return h.cmdClear(platform, userID)
case text == robotCmdCurrent: case text == robotCmdCurrent || text == "current":
return h.cmdCurrent(platform, userID) return h.cmdCurrent(platform, userID)
case text == robotCmdStop || text == "stop":
return h.cmdStop(platform, userID)
case text == robotCmdRoles || text == robotCmdRolesList || text == "roles":
return h.cmdRoles()
case strings.HasPrefix(text, robotCmdRoles+" ") || strings.HasPrefix(text, robotCmdSwitchRole+" ") || strings.HasPrefix(text, "role "):
var roleName string
switch {
case strings.HasPrefix(text, robotCmdRoles+" "):
roleName = strings.TrimSpace(text[len(robotCmdRoles)+1:])
case strings.HasPrefix(text, robotCmdSwitchRole+" "):
roleName = strings.TrimSpace(text[len(robotCmdSwitchRole)+1:])
default:
roleName = strings.TrimSpace(text[5:])
}
return h.cmdSwitchRole(platform, userID, roleName)
case strings.HasPrefix(text, robotCmdDelete+" ") || strings.HasPrefix(text, "delete "):
var convID string
if strings.HasPrefix(text, robotCmdDelete+" ") {
convID = strings.TrimSpace(text[len(robotCmdDelete)+1:])
} else {
convID = strings.TrimSpace(text[7:])
}
return h.cmdDelete(platform, userID, convID)
case text == robotCmdVersion || text == "version":
return h.cmdVersion()
} }
// 普通消息:走 Agent // 普通消息:走 Agent
convID, _ := h.getOrCreateConversation(platform, userID) convID, _ := h.getOrCreateConversation(platform, userID, text)
if convID == "" { if convID == "" {
return "无法创建或获取对话,请稍后再试。" return "无法创建或获取对话,请稍后再试。"
} }
// 若对话标题为「新对话 xx:xx」格式(由「新对话」命令创建),将标题更新为首条消息内容,与 Web 端体验一致
if conv, err := h.db.GetConversation(convID); err == nil && strings.HasPrefix(conv.Title, "新对话 ") {
newTitle := safeTruncateString(text, 50)
if newTitle != "" {
_ = h.db.UpdateConversationTitle(convID, newTitle)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() sk := h.sessionKey(platform, userID)
resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, convID, text, "默认") h.cancelMu.Lock()
h.runningCancels[sk] = cancel
h.cancelMu.Unlock()
defer func() {
cancel()
h.cancelMu.Lock()
delete(h.runningCancels, sk)
h.cancelMu.Unlock()
}()
role := h.getRole(platform, userID)
resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, convID, text, role)
if err != nil { if err != nil {
h.logger.Warn("机器人 Agent 执行失败", zap.String("platform", platform), zap.String("userID", userID), zap.Error(err)) h.logger.Warn("机器人 Agent 执行失败", zap.String("platform", platform), zap.String("userID", userID), zap.Error(err))
if errors.Is(err, context.Canceled) {
return "任务已取消。"
}
return "处理失败: " + err.Error() return "处理失败: " + err.Error()
} }
if newConvID != convID { if newConvID != convID {
@@ -144,17 +232,24 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
} }
func (h *RobotHandler) cmdHelp() string { func (h *RobotHandler) cmdHelp() string {
return `【CyberStrikeAI 机器人命令】 return "**【CyberStrikeAI 机器人命令】**\n\n" +
· 帮助 — 显示本帮助 "- `帮助` `help` — 显示本帮助 | Show this help\n" +
· 列表 / 对话列表 — 列出所有对话标题与 ID "- `列表` `list` — 列出所有对话标题与 ID | List conversations\n" +
· 切换 <对话ID> / 继续 <对话ID> — 指定对话继续 "- `切换 <ID>` `switch <ID>` — 指定对话继续 | Switch to conversation\n" +
· 新对话 — 开启新对话 "- `新对话` `new` — 开启新对话 | Start new conversation\n" +
· 清空 — 清空当前上下文(等同于新对话) "- `清空` `clear` — 清空当前上下文 | Clear context\n" +
· 当前 — 显示当前对话 ID 与标题 "- `当前` `current` — 显示当前对话 ID 与标题 | Show current conversation\n" +
除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。` "- `停止` `stop` — 中断当前任务 | Stop running task\n" +
"- `角色` `roles` — 列出所有可用角色 | List roles\n" +
"- `角色 <名>` `role <name>` — 切换当前角色 | Switch role\n" +
"- `删除 <ID>` `delete <ID>` — 删除指定对话 | Delete conversation\n" +
"- `版本` `version` — 显示当前版本号 | Show version\n\n" +
"---\n" +
"除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。\n" +
"Otherwise, send any text for AI penetration testing / security analysis."
} }
func (h *RobotHandler) cmdList(userID string) string { func (h *RobotHandler) cmdList() string {
convs, err := h.db.ListConversations(50, 0, "") convs, err := h.db.ListConversations(50, 0, "")
if err != nil { if err != nil {
return "获取对话列表失败: " + err.Error() return "获取对话列表失败: " + err.Error()
@@ -198,6 +293,21 @@ func (h *RobotHandler) cmdClear(platform, userID string) string {
return h.cmdNew(platform, userID) return h.cmdNew(platform, userID)
} }
func (h *RobotHandler) cmdStop(platform, userID string) string {
sk := h.sessionKey(platform, userID)
h.cancelMu.Lock()
cancel, ok := h.runningCancels[sk]
if ok {
delete(h.runningCancels, sk)
cancel()
}
h.cancelMu.Unlock()
if !ok {
return "当前没有正在执行的任务。"
}
return "已停止当前任务。"
}
func (h *RobotHandler) cmdCurrent(platform, userID string) string { func (h *RobotHandler) cmdCurrent(platform, userID string) string {
h.mu.RLock() h.mu.RLock()
convID := h.sessions[h.sessionKey(platform, userID)] convID := h.sessions[h.sessionKey(platform, userID)]
@@ -209,7 +319,89 @@ func (h *RobotHandler) cmdCurrent(platform, userID string) string {
if err != nil { if err != nil {
return "当前对话 ID: " + convID + "(获取标题失败)" return "当前对话 ID: " + convID + "(获取标题失败)"
} }
return fmt.Sprintf("当前对话:「%s」\nID: %s", conv.Title, conv.ID) role := h.getRole(platform, userID)
return fmt.Sprintf("当前对话:「%s」\nID: %s\n当前角色: %s", conv.Title, conv.ID, role)
}
func (h *RobotHandler) cmdRoles() string {
if h.config.Roles == nil || len(h.config.Roles) == 0 {
return "暂无可用角色。"
}
names := make([]string, 0, len(h.config.Roles))
for name, role := range h.config.Roles {
if role.Enabled {
names = append(names, name)
}
}
if len(names) == 0 {
return "暂无可用角色。"
}
sort.Slice(names, func(i, j int) bool {
if names[i] == "默认" {
return true
}
if names[j] == "默认" {
return false
}
return names[i] < names[j]
})
var b strings.Builder
b.WriteString("【角色列表】\n")
for _, name := range names {
role := h.config.Roles[name]
desc := role.Description
if desc == "" {
desc = "无描述"
}
b.WriteString(fmt.Sprintf("· %s — %s\n", name, desc))
}
return strings.TrimSuffix(b.String(), "\n")
}
func (h *RobotHandler) cmdSwitchRole(platform, userID, roleName string) string {
if roleName == "" {
return "请指定角色名称,例如:角色 渗透测试"
}
if h.config.Roles == nil {
return "暂无可用角色。"
}
role, exists := h.config.Roles[roleName]
if !exists {
return fmt.Sprintf("角色「%s」不存在。发送「角色」查看可用角色。", roleName)
}
if !role.Enabled {
return fmt.Sprintf("角色「%s」已禁用。", roleName)
}
h.setRole(platform, userID, roleName)
return fmt.Sprintf("已切换到角色:「%s」\n%s", roleName, role.Description)
}
func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
if convID == "" {
return "请指定对话 ID,例如:删除 xxx-xxx-xxx"
}
sk := h.sessionKey(platform, userID)
h.mu.RLock()
currentConvID := h.sessions[sk]
h.mu.RUnlock()
if convID == currentConvID {
// 删除当前对话时,先清空会话绑定
h.mu.Lock()
delete(h.sessions, sk)
h.mu.Unlock()
}
if err := h.db.DeleteConversation(convID); err != nil {
return "删除失败: " + err.Error()
}
return fmt.Sprintf("已删除对话 ID: %s", convID)
}
func (h *RobotHandler) cmdVersion() string {
v := h.config.Version
if v == "" {
v = "未知"
}
return "CyberStrikeAI " + v
} }
// —————— 企业微信 —————— // —————— 企业微信 ——————
+57 -18
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"time"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
@@ -15,30 +16,54 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
const (
dingReconnectInitial = 5 * time.Second // 首次重连间隔
dingReconnectMax = 60 * time.Second // 最大重连间隔
)
// StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复。 // StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复。
// ctx 被取消时长连接会退出,便于配置变更时重启。 // 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
func StartDing(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) { func StartDing(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) {
if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" { if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" {
return return
} }
streamClient := client.NewStreamClient( go runDingLoop(ctx, cfg, h, logger)
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) { // runDingLoop 循环维持钉钉长连接:断开且 ctx 未取消时按退避间隔重连。
go handleDingMessage(ctx, msg, h, logger) func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) {
return nil, nil backoff := dingReconnectInitial
}).OnEventReceived), for {
) streamClient := client.NewStreamClient(
logger.Info("钉钉 Stream 正在连接…", zap.String("client_id", cfg.ClientID)) client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)),
go func() { 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))
err := streamClient.Start(ctx) err := streamClient.Start(ctx)
if err != nil && ctx.Err() == nil { if ctx.Err() != nil {
logger.Error("钉钉 Stream 长连接退出", zap.Error(err))
} else if ctx.Err() != nil {
logger.Info("钉钉 Stream 已按配置重启关闭") logger.Info("钉钉 Stream 已按配置重启关闭")
return
} }
}() if err != nil {
logger.Info("钉钉 Stream 已启动(无需公网),等待收消息", zap.String("client_id", cfg.ClientID)) logger.Warn("钉钉 Stream 长连接断开(如睡眠/断网),将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
// 下次重连间隔递增,上限 60 秒,避免频繁重试
if backoff < dingReconnectMax {
backoff *= 2
if backoff > dingReconnectMax {
backoff = dingReconnectMax
}
}
}
}
} }
func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h MessageHandler, logger *zap.Logger) { func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h MessageHandler, logger *zap.Logger) {
@@ -73,9 +98,23 @@ func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h
userID = msg.ConversationId userID = msg.ConversationId
} }
reply := h.HandleMessage("dingtalk", userID, content) reply := h.HandleMessage("dingtalk", userID, content)
// 使用 markdown 类型以便正确展示标题、列表、代码块等格式
title := reply
if idx := strings.IndexAny(reply, "\n"); idx > 0 {
title = strings.TrimSpace(reply[:idx])
}
if len(title) > 50 {
title = title[:50] + "…"
}
if title == "" {
title = "回复"
}
body := map[string]interface{}{ body := map[string]interface{}{
"msgtype": "text", "msgtype": "markdown",
"text": map[string]string{"content": reply}, "markdown": map[string]string{
"title": title,
"text": reply,
},
} }
bodyBytes, _ := json.Marshal(body) bodyBytes, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, msg.SessionWebhook, bytes.NewReader(bodyBytes)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, msg.SessionWebhook, bytes.NewReader(bodyBytes))
+42 -17
View File
@@ -4,45 +4,70 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"strings" "strings"
"time"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" 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" larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
"go.uber.org/zap" "go.uber.org/zap"
) )
const (
larkReconnectInitial = 5 * time.Second // 首次重连间隔
larkReconnectMax = 60 * time.Second // 最大重连间隔
)
type larkTextContent struct { type larkTextContent struct {
Text string `json:"text"` Text string `json:"text"`
} }
// StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。 // StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。
// ctx 被取消时长连接会退出,便于配置变更时重启。 // 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
func StartLark(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) { func StartLark(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) {
if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" { if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" {
return return
} }
larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret) go runLarkLoop(ctx, cfg, h, logger)
eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error { }
go handleLarkMessage(ctx, event, h, larkClient, logger)
return nil // runLarkLoop 循环维持飞书长连接:断开且 ctx 未取消时按退避间隔重连。
}) func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) {
wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret, backoff := larkReconnectInitial
larkws.WithEventHandler(eventHandler), for {
larkws.WithLogLevel(larkcore.LogLevelInfo), larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret)
) eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
go func() { go handleLarkMessage(ctx, event, h, larkClient, logger)
return nil
})
wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret,
larkws.WithEventHandler(eventHandler),
larkws.WithLogLevel(larkcore.LogLevelInfo),
)
logger.Info("飞书长连接正在连接…", zap.String("app_id", cfg.AppID))
err := wsClient.Start(ctx) err := wsClient.Start(ctx)
if err != nil && ctx.Err() == nil { if ctx.Err() != nil {
logger.Error("飞书长连接退出", zap.Error(err))
} else if ctx.Err() != nil {
logger.Info("飞书长连接已按配置重启关闭") logger.Info("飞书长连接已按配置重启关闭")
return
} }
}() if err != nil {
logger.Info("飞书长连接已启动(无需公网)", zap.String("app_id", cfg.AppID)) logger.Warn("飞书长连接断开(如睡眠/断网),将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
if backoff < larkReconnectMax {
backoff *= 2
if backoff > larkReconnectMax {
backoff = larkReconnectMax
}
}
}
}
} }
func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h MessageHandler, client *lark.Client, logger *zap.Logger) { func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h MessageHandler, client *lark.Client, logger *zap.Logger) {
+2 -1
View File
@@ -46,8 +46,9 @@ parameters:
**注意事项:** **注意事项:**
- 必需参数,不能为空 - 必需参数,不能为空
- 如果指定进程ID,需要配合 -d 参数使用 - 如果指定进程ID,需要配合 -d 参数使用
- 注意:radare2 要求文件路径必须是最后一个参数,因此 target 使用 position 1
required: true required: true
position: 0 position: 1
format: "positional" format: "positional"
- name: "commands" - name: "commands"
type: "string" type: "string"
+94
View File
@@ -1582,12 +1582,106 @@ header {
box-sizing: border-box; box-sizing: border-box;
} }
.chat-input-container > .chat-input-with-files {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
gap: 6px;
}
.chat-input-container > .chat-input-field { .chat-input-container > .chat-input-field {
flex: 1; flex: 1;
display: flex; display: flex;
min-width: 0; min-width: 0;
} }
.chat-file-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-height: 0;
}
.chat-file-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(0, 102, 255, 0.08);
border: 1px solid rgba(0, 102, 255, 0.2);
border-radius: 8px;
font-size: 12px;
color: var(--text-primary);
max-width: 200px;
}
.chat-file-chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-file-chip-remove {
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: none;
background: rgba(0, 0, 0, 0.08);
border-radius: 50%;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, color 0.2s;
}
.chat-file-chip-remove:hover {
background: rgba(0, 0, 0, 0.12);
color: var(--text-primary);
}
.chat-file-input-hidden {
position: absolute;
width: 0;
height: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
}
.chat-upload-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
background: #ffffff;
color: var(--text-muted);
cursor: pointer;
transition: border-color 0.2s, color 0.2s, background 0.2s;
flex-shrink: 0;
}
.chat-upload-btn:hover {
border-color: var(--accent-color);
color: var(--accent-color);
background: rgba(0, 102, 255, 0.04);
}
.chat-input-container.drag-over {
background: rgba(0, 102, 255, 0.06);
border-radius: 12px;
outline: 2px dashed rgba(0, 102, 255, 0.35);
outline-offset: -2px;
}
.chat-input-field { .chat-input-field {
flex: 1; flex: 1;
position: relative; position: relative;
+162 -12
View File
@@ -22,6 +22,12 @@ const DRAFT_STORAGE_KEY = 'cyberstrike-chat-draft';
let draftSaveTimer = null; let draftSaveTimer = null;
const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟 const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟
// 对话文件上传相关(后端会拼接路径与内容发给大模型,前端不再重复发文件列表)
const MAX_CHAT_FILES = 10;
const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。';
/** @type {{ fileName: string, content: string, mimeType: string }[]} */
let chatAttachments = [];
// 保存输入框草稿到localStorage(防抖版本) // 保存输入框草稿到localStorage(防抖版本)
function saveChatDraftDebounced(content) { function saveChatDraftDebounced(content) {
// 清除之前的定时器 // 清除之前的定时器
@@ -107,14 +113,22 @@ function adjustTextareaHeight(textarea) {
// 发送消息 // 发送消息
async function sendMessage() { async function sendMessage() {
const input = document.getElementById('chat-input'); const input = document.getElementById('chat-input');
const message = input.value.trim(); let message = input.value.trim();
const hasAttachments = chatAttachments && chatAttachments.length > 0;
if (!message) { if (!message && !hasAttachments) {
return; return;
} }
// 有附件且用户未输入时,发一句简短默认提示即可(后端会拼接路径和文件内容给大模型)
if (hasAttachments && !message) {
message = CHAT_FILE_DEFAULT_PROMPT;
}
// 显示用户消息 // 显示用户消息(含附件名,便于用户确认)
addMessage('user', message); const displayMessage = hasAttachments
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
: message;
addMessage('user', displayMessage);
// 清除防抖定时器,防止在清空输入框后重新保存草稿 // 清除防抖定时器,防止在清空输入框后重新保存草稿
if (draftSaveTimer) { if (draftSaveTimer) {
@@ -136,6 +150,23 @@ async function sendMessage() {
// 强制重置输入框高度为初始高度(40px) // 强制重置输入框高度为初始高度(40px)
input.style.height = '40px'; input.style.height = '40px';
// 构建请求体(含附件)
const body = {
message: message,
conversationId: currentConversationId,
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
};
if (hasAttachments) {
body.attachments = chatAttachments.map(a => ({
fileName: a.fileName,
content: a.content,
mimeType: a.mimeType || ''
}));
}
// 发送后清空附件列表
chatAttachments = [];
renderChatFileChips();
// 创建进度消息容器(使用详细的进度展示) // 创建进度消息容器(使用详细的进度展示)
const progressId = addProgressMessage(); const progressId = addProgressMessage();
const progressElement = document.getElementById(progressId); const progressElement = document.getElementById(progressId);
@@ -145,19 +176,12 @@ async function sendMessage() {
let mcpExecutionIds = []; let mcpExecutionIds = [];
try { try {
// 获取当前选中的角色(从 roles.js 的函数获取)
const roleName = typeof getCurrentRole === 'function' ? getCurrentRole() : '';
const response = await apiFetch('/api/agent-loop/stream', { const response = await apiFetch('/api/agent-loop/stream', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify(body),
message: message,
conversationId: currentConversationId,
role: roleName || undefined
}),
}); });
if (!response.ok) { if (!response.ok) {
@@ -222,6 +246,130 @@ async function sendMessage() {
} }
} }
// ---------- 对话文件上传 ----------
function renderChatFileChips() {
const list = document.getElementById('chat-file-list');
if (!list) return;
list.innerHTML = '';
if (!chatAttachments.length) return;
chatAttachments.forEach((a, i) => {
const chip = document.createElement('div');
chip.className = 'chat-file-chip';
chip.setAttribute('role', 'listitem');
const name = document.createElement('span');
name.className = 'chat-file-chip-name';
name.title = a.fileName;
name.textContent = a.fileName;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'chat-file-chip-remove';
remove.title = '移除';
remove.innerHTML = '×';
remove.setAttribute('aria-label', '移除 ' + a.fileName);
remove.addEventListener('click', () => removeChatAttachment(i));
chip.appendChild(name);
chip.appendChild(remove);
list.appendChild(chip);
});
}
function removeChatAttachment(index) {
chatAttachments.splice(index, 1);
renderChatFileChips();
}
// 有附件且输入框为空时,填入一句默认提示(可编辑);后端会单独拼接路径与内容给大模型
function appendChatFilePrompt() {
const input = document.getElementById('chat-input');
if (!input || !chatAttachments.length) return;
if (!input.value.trim()) {
input.value = CHAT_FILE_DEFAULT_PROMPT;
adjustTextareaHeight(input);
}
}
function readFileAsAttachment(file) {
return new Promise((resolve, reject) => {
const mimeType = file.type || '';
const isTextLike = /^text\//i.test(mimeType) || /^(application\/(json|xml|javascript)|image\/svg\+xml)/i.test(mimeType);
const reader = new FileReader();
reader.onload = () => {
let content = reader.result;
if (typeof content === 'string' && content.startsWith('data:')) {
content = content.replace(/^data:[^;]+;base64,/, '');
}
resolve({ fileName: file.name, content: content, mimeType: mimeType });
};
reader.onerror = () => reject(reader.error);
if (isTextLike) {
reader.readAsText(file, 'UTF-8');
} else {
reader.readAsDataURL(file);
}
});
}
function addFilesToChat(files) {
if (!files || !files.length) return;
const next = Array.from(files);
if (chatAttachments.length + next.length > MAX_CHAT_FILES) {
alert('最多同时上传 ' + MAX_CHAT_FILES + ' 个文件,当前已选 ' + chatAttachments.length + ' 个。');
return;
}
const addOne = (file) => {
return readFileAsAttachment(file).then((a) => {
chatAttachments.push(a);
renderChatFileChips();
appendChatFilePrompt();
}).catch(() => {
alert('读取文件失败:' + file.name);
});
};
let p = Promise.resolve();
next.forEach((file) => { p = p.then(() => addOne(file)); });
p.then(() => {});
}
function setupChatFileUpload() {
const inputEl = document.getElementById('chat-file-input');
const container = document.getElementById('chat-input-container') || document.querySelector('.chat-input-container');
if (!inputEl || !container) return;
inputEl.addEventListener('change', function () {
const files = this.files;
if (files && files.length) {
addFilesToChat(files);
}
this.value = '';
});
container.addEventListener('dragover', function (e) {
e.preventDefault();
e.stopPropagation();
this.classList.add('drag-over');
});
container.addEventListener('dragleave', function (e) {
e.preventDefault();
e.stopPropagation();
if (!this.contains(e.relatedTarget)) {
this.classList.remove('drag-over');
}
});
container.addEventListener('drop', function (e) {
e.preventDefault();
e.stopPropagation();
this.classList.remove('drag-over');
const files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length) addFilesToChat(files);
});
}
// 确保 chat-input-container 有 id(若模板未写)
function ensureChatInputContainerId() {
const c = document.querySelector('.chat-input-container');
if (c && !c.id) c.id = 'chat-input-container';
}
function setupMentionSupport() { function setupMentionSupport() {
mentionSuggestionsEl = document.getElementById('mention-suggestions'); mentionSuggestionsEl = document.getElementById('mention-suggestions');
if (mentionSuggestionsEl) { if (mentionSuggestionsEl) {
@@ -799,6 +947,8 @@ function initializeChatUI() {
} }
activeTaskInterval = setInterval(() => loadActiveTasks(), ACTIVE_TASK_REFRESH_INTERVAL); activeTaskInterval = setInterval(() => loadActiveTasks(), ACTIVE_TASK_REFRESH_INTERVAL);
setupMentionSupport(); setupMentionSupport();
ensureChatInputContainerId();
setupChatFileUpload();
} }
// 消息计数器,确保ID唯一 // 消息计数器,确保ID唯一
+26 -11
View File
@@ -499,7 +499,7 @@
</div> </div>
<div id="active-tasks-bar" class="active-tasks-bar"></div> <div id="active-tasks-bar" class="active-tasks-bar"></div>
<div id="chat-messages" class="chat-messages"></div> <div id="chat-messages" class="chat-messages"></div>
<div class="chat-input-container"> <div id="chat-input-container" class="chat-input-container">
<div class="role-selector-wrapper"> <div class="role-selector-wrapper">
<button id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" title="选择角色"> <button id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" title="选择角色">
<span id="role-selector-icon" class="role-selector-icon">🔵</span> <span id="role-selector-icon" class="role-selector-icon">🔵</span>
@@ -521,10 +521,19 @@
<div id="role-selection-list" class="role-selection-list-main"></div> <div id="role-selection-list" class="role-selection-list-main"></div>
</div> </div>
</div> </div>
<div class="chat-input-field"> <div class="chat-input-with-files">
<textarea id="chat-input" placeholder="输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)" rows="1"></textarea> <div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div>
<div id="mention-suggestions" class="mention-suggestions" role="listbox" aria-label="工具提及候选"></div> <div class="chat-input-field">
<textarea id="chat-input" placeholder="输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)" rows="1"></textarea>
<div id="mention-suggestions" class="mention-suggestions" role="listbox" aria-label="工具提及候选"></div>
</div>
</div> </div>
<input type="file" id="chat-file-input" class="chat-file-input-hidden" multiple accept="*" title="选择文件">
<button type="button" class="chat-upload-btn" onclick="document.getElementById('chat-file-input').click()" title="上传文件(可多选或拖拽到此处)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="send-btn" onclick="sendMessage()"> <button class="send-btn" onclick="sendMessage()">
<span>发送</span> <span>发送</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1289,15 +1298,21 @@
<div class="settings-subsection"> <div class="settings-subsection">
<h4>机器人命令说明</h4> <h4>机器人命令说明</h4>
<p class="settings-description">在对话中可发送以下命令:</p> <p class="settings-description">在对话中可发送以下命令(支持中英文)</p>
<ul style="color: var(--text-muted); font-size: 13px; line-height: 1.8; margin: 8px 0 0 16px;"> <ul style="color: var(--text-muted); font-size: 13px; line-height: 1.8; margin: 8px 0 0 16px;">
<li><strong>帮助</strong> — 显示命令帮助</li> <li><code>帮助</code> <code>help</code> — 显示本帮助 | Show this help</li>
<li><strong>列表</strong> <strong>对话列表</strong> — 列出所有对话标题与 ID</li> <li><code>列表</code> <code>list</code> — 列出所有对话标题与 ID | List conversations</li>
<li><strong>切换 &lt;对话ID&gt;</strong> <strong>继续 &lt;对话ID&gt;</strong> — 指定对话 ID 继续对话</li> <li><code>切换 &lt;ID&gt;</code> <code>switch &lt;ID&gt;</code> — 指定对话继续 | Switch to conversation</li>
<li><strong>新对话</strong> — 开启新对话</li> <li><code>新对话</code> <code>new</code> — 开启新对话 | Start new conversation</li>
<li><strong>清空</strong> — 清空当前对话上下文(不删除历史)</li> <li><code>清空</code> <code>clear</code> — 清空当前上下文 | Clear context</li>
<li><strong>当前</strong> — 显示当前对话 ID 与标题</li> <li><code>当前</code> <code>current</code> — 显示当前对话 ID 与标题 | Show current conversation</li>
<li><code>停止</code> <code>stop</code> — 中断当前任务 | Stop running task</li>
<li><code>角色</code> <code>roles</code> — 列出所有可用角色 | List roles</li>
<li><code>角色 &lt;&gt;</code> <code>role &lt;name&gt;</code> — 切换当前角色 | Switch role</li>
<li><code>删除 &lt;ID&gt;</code> <code>delete &lt;ID&gt;</code> — 删除指定对话 | Delete conversation</li>
<li><code>版本</code> <code>version</code> — 显示当前版本号 | Show version</li>
</ul> </ul>
<p class="settings-description" style="margin-top: 8px;">除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis.</p>
</div> </div>
<div class="settings-actions"> <div class="settings-actions">