From 7c01641de94c034af3816e0d90d30887dbbb41a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Sun, 8 Mar 2026 04:01:33 +0800 Subject: [PATCH] Add files via upload --- docs/robot.md | 44 +++- docs/robot_en.md | 42 ++- internal/handler/robot.go | 458 +++++++++++++++++++++++++++------ internal/handler/wecom_test.go | 44 ++++ 4 files changed, 499 insertions(+), 89 deletions(-) create mode 100644 internal/handler/wecom_test.go diff --git a/docs/robot.md b/docs/robot.md index eb4c814e..b518ddb3 100644 --- a/docs/robot.md +++ b/docs/robot.md @@ -2,7 +2,7 @@ [English](robot_en.md) -本文档说明如何通过**钉钉**、**飞书**与 CyberStrikeAI 对话(长连接模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。 +本文档说明如何通过**钉钉**、**飞书**与 **企业微信** 与 CyberStrikeAI 对话(长连接 / 回调模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。 --- @@ -19,12 +19,13 @@ --- -## 二、支持的平台(长连接) +## 二、支持的平台(长连接 / 回调) -| 平台 | 说明 | -|------|------| -| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 | -| 飞书 | 使用长连接,程序主动连接飞书接收消息 | +| 平台 | 说明 | +|----------|------| +| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 | +| 飞书 | 使用长连接,程序主动连接飞书接收消息 | +| 企业微信 | 使用 HTTP 回调接收消息,被动回包 + 主动调用企业微信发送消息 API | 下面第三节会按平台写清:在开放平台要做什么、要复制哪些字段、填到 CyberStrikeAI 的哪一栏。 @@ -101,6 +102,37 @@ --- +### 3.3 企业微信 (WeCom) + +> 企业微信目前采用「HTTP 回调 + 主动发送消息 API」的方式工作: +> - 用户发消息 → 企业微信以加密 XML **回调到你的服务器**(本程序的 `/api/robot/wecom`); +> - CyberStrikeAI 解密并调用 AI → 使用企业微信的 `message/send` 接口**主动发消息给用户**。 + +**配置概览:** + +- 在企业微信管理后台创建或选择一个**自建应用**。 +- 在该应用的「接收消息」处配置回调 URL、Token、EncodingAESKey。 +- 在 CyberStrikeAI 的 `config.yaml` 中填入: + - `robots.wecom.corp_id`:企业 ID(CorpID) + - `robots.wecom.agent_id`:应用的 AgentId + - `robots.wecom.token`:消息回调使用的 Token + - `robots.wecom.encoding_aes_key`:消息回调使用的 EncodingAESKey + - `robots.wecom.secret`:该应用的 Secret(用于调用企业微信主动发送消息接口) + +> **重要:IP 白名单(errcode 60020)** +> CyberStrikeAI 使用 `https://qyapi.weixin.qq.com/cgi-bin/message/send` 主动发送 AI 回复。 +> 若企业微信日志或本程序日志中出现 `errcode 60020 not allow to access from your ip`: +> +> - 说明你的服务器出口 IP **没有加入企业微信的 IP 白名单**; +> - 请在企业微信管理后台中找到该自建应用的**「安全设置 / IP 白名单」**(具体入口可能因版本略有不同),将运行 CyberStrikeAI 的服务器公网 IP(如 `110.xxx.xxx.xxx`)加入白名单; +> - 保存后等待生效,再次发送消息测试。 +> +> 如果 IP 未加入白名单,企业微信会拒绝主动发送消息,表现为: +> - 回调接口 `/api/robot/wecom` 能正常收到并处理消息; +> - 但手机端**始终收不到 AI 回复**,日志中有 `not allow to access from your ip` 提示。 + +--- + ## 四、机器人命令 在钉钉/飞书中向机器人发送以下**文本命令**(仅支持文本): diff --git a/docs/robot_en.md b/docs/robot_en.md index 5491529a..8f04c8fb 100644 --- a/docs/robot_en.md +++ b/docs/robot_en.md @@ -2,7 +2,7 @@ [中文](robot.md) -This document explains how to chat with CyberStrikeAI from **DingTalk** and **Lark (Feishu)** using long-lived connections—no need to open a browser on the server. Following the steps below helps avoid common mistakes. +This document explains how to chat with CyberStrikeAI from **DingTalk**, **Lark (Feishu)**, and **WeCom (Enterprise WeChat)** using long-lived connections or HTTP callbacks—no need to open a browser on the server. Following the steps below helps avoid common mistakes. --- @@ -19,12 +19,13 @@ Settings are written to the `robots` section of `config.yaml`; you can also edit --- -## 2. Supported platforms (long-lived connection) +## 2. Supported platforms (long-lived / callback) -| Platform | Description | -|----------|-------------| -| DingTalk | Stream long-lived connection; the app connects to DingTalk to receive messages | -| Lark (Feishu) | Long-lived connection; the app connects to Lark to receive messages | +| Platform | Description | +|----------------|-------------| +| DingTalk | Stream long-lived connection; the app connects to DingTalk to receive messages | +| Lark (Feishu) | Long-lived connection; the app connects to Lark to receive messages | +| WeCom (Qiye WX)| HTTP callback to receive messages; CyberStrikeAI replies via WeCom’s message sending API | Section 3 below describes, per platform, what to do in the developer console and which fields to copy into CyberStrikeAI. @@ -100,6 +101,35 @@ If you only have a **custom bot** Webhook URL (`oapi.dingtalk.com/robot/send?acc --- +### 3.3 WeCom (Enterprise WeChat) + +> WeCom uses a **“HTTP callback + active message send API”** model: +> - User sends a message → WeCom sends an **encrypted XML callback** to your server (CyberStrikeAI’s `/api/robot/wecom`). +> - CyberStrikeAI decrypts it, calls the AI, then uses WeCom’s `message/send` API to **actively push the reply** to the user. + +**Configuration overview:** + +- In the WeCom admin console, create or select a **custom app** (自建应用). +- In that app’s settings, configure the message **callback URL**, **Token**, and **EncodingAESKey**. +- In CyberStrikeAI’s `config.yaml`, fill in: + - `robots.wecom.corp_id`: your CorpID (企业 ID) + - `robots.wecom.agent_id`: the app’s AgentId + - `robots.wecom.token`: the Token used for message callbacks + - `robots.wecom.encoding_aes_key`: the EncodingAESKey used for callbacks + - `robots.wecom.secret`: the app’s Secret (used when calling WeCom APIs to send messages) + +> **Important: IP allowlist (errcode 60020)** +> CyberStrikeAI calls `https://qyapi.weixin.qq.com/cgi-bin/message/send` to actively send AI replies. +> If logs show `errcode 60020 not allow to access from your ip`: +> +> - Your server’s outbound IP is **not in WeCom’s IP allowlist**. +> - In the WeCom admin console, open the custom app’s **Security / IP allowlist** settings (name may vary slightly), and add the public IP of the machine running CyberStrikeAI (e.g. `110.xxx.xxx.xxx`). +> - Save and wait for it to take effect, then test again. +> +> If the IP is not whitelisted, WeCom will reject active message sending. You will see that `/api/robot/wecom` receives and processes callbacks, but users **never see AI replies**, and logs contain `not allow to access from your ip`. + +--- + ## 4. Bot commands Send these **text commands** to the bot in DingTalk or Lark (text only): diff --git a/internal/handler/robot.go b/internal/handler/robot.go index 965d14dc..78cd38a1 100644 --- a/internal/handler/robot.go +++ b/internal/handler/robot.go @@ -1,11 +1,15 @@ package handler import ( + "bytes" "context" "crypto/aes" "crypto/cipher" + "crypto/rand" + "crypto/sha1" "encoding/base64" "encoding/binary" + "encoding/json" "encoding/xml" "errors" "fmt" @@ -141,56 +145,9 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin return "请输入内容或发送「帮助」/ help 查看命令。" } - // 命令分发(支持中英文) - switch { - case text == robotCmdHelp || text == "help" || text == "?" || text == "?": - return h.cmdHelp() - case text == robotCmdList || text == robotCmdListAlt || text == "list": - return h.cmdList() - case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" ") || strings.HasPrefix(text, "switch ") || strings.HasPrefix(text, "continue "): - var id string - switch { - case strings.HasPrefix(text, robotCmdSwitch+" "): - id = strings.TrimSpace(text[len(robotCmdSwitch)+1:]) - case strings.HasPrefix(text, robotCmdContinue+" "): - 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) - case text == robotCmdNew || text == "new": - return h.cmdNew(platform, userID) - case text == robotCmdClear || text == "clear": - return h.cmdClear(platform, userID) - case text == robotCmdCurrent || text == "current": - 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() + // 先尝试作为命令处理(支持中英文) + if cmdReply, ok := h.handleRobotCommand(platform, userID, text); ok { + return cmdReply } // 普通消息:走 Agent @@ -404,6 +361,62 @@ func (h *RobotHandler) cmdVersion() string { return "CyberStrikeAI " + v } +// handleRobotCommand 处理机器人内置命令;若匹配到命令返回 (回复内容, true),否则返回 ("", false) +func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string, bool) { + switch { + case text == robotCmdHelp || text == "help" || text == "?" || text == "?": + return h.cmdHelp(), true + case text == robotCmdList || text == robotCmdListAlt || text == "list": + return h.cmdList(), true + case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" ") || strings.HasPrefix(text, "switch ") || strings.HasPrefix(text, "continue "): + var id string + switch { + case strings.HasPrefix(text, robotCmdSwitch+" "): + id = strings.TrimSpace(text[len(robotCmdSwitch)+1:]) + case strings.HasPrefix(text, robotCmdContinue+" "): + 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), true + case text == robotCmdNew || text == "new": + return h.cmdNew(platform, userID), true + case text == robotCmdClear || text == "clear": + return h.cmdClear(platform, userID), true + case text == robotCmdCurrent || text == "current": + return h.cmdCurrent(platform, userID), true + case text == robotCmdStop || text == "stop": + return h.cmdStop(platform, userID), true + case text == robotCmdRoles || text == robotCmdRolesList || text == "roles": + return h.cmdRoles(), true + 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), true + 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), true + case text == robotCmdVersion || text == "version": + return h.cmdVersion(), true + default: + return "", false + } +} + // —————— 企业微信 —————— // wecomXML 企业微信回调 XML(明文模式下的简化结构;加密模式需先解密再解析) @@ -418,14 +431,14 @@ type wecomXML struct { Encrypt string `xml:"Encrypt"` // 加密模式下消息在此 } -// wecomReplyXML 被动回复 XML +// wecomReplyXML 被动回复 XML(仅用于兼容,当前使用手动构造 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"` + FromUserName string `xml:"FromUserName"` + CreateTime int64 `xml:"CreateTime"` + MsgType string `xml:"MsgType"` + Content string `xml:"Content"` } // HandleWecomGET 企业微信 URL 校验(GET) @@ -434,15 +447,51 @@ func (h *RobotHandler) HandleWecomGET(c *gin.Context) { c.String(http.StatusNotFound, "") return } + // Gin 的 Query() 会自动 URL 解码,拿到的就是正确的 base64 字符串 echostr := c.Query("echostr") + msgSignature := c.Query("msg_signature") + timestamp := c.Query("timestamp") + nonce := c.Query("nonce") + + // 验证签名:将 token、timestamp、nonce、echostr 四个参数排序后拼接计算 SHA1 + signature := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, echostr) + if signature != msgSignature { + h.logger.Warn("企业微信 URL 验证签名失败", zap.String("expected", msgSignature), zap.String("got", signature)) + c.String(http.StatusBadRequest, "invalid signature") + return + } + if echostr == "" { c.String(http.StatusBadRequest, "missing echostr") return } - // 明文模式时企业微信可能直接传 echostr,先直接返回以通过校验 + + // 如果配置了 EncodingAESKey,说明是加密模式,需要解密 echostr + if h.config.Robots.Wecom.EncodingAESKey != "" { + decrypted, err := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, echostr) + if err != nil { + h.logger.Warn("企业微信 echostr 解密失败", zap.Error(err)) + c.String(http.StatusBadRequest, "decrypt failed") + return + } + c.String(http.StatusOK, string(decrypted)) + return + } + + // 明文模式直接返回 echostr c.String(http.StatusOK, echostr) } +// signWecomRequest 生成企业微信请求签名 +// 企业微信签名算法:将 token、timestamp、nonce、echostr 四个值排序后拼接成字符串,再计算 SHA1 +func (h *RobotHandler) signWecomRequest(token, timestamp, nonce, echostr string) string { + strs := []string{token, timestamp, nonce, echostr} + sort.Strings(strs) + s := strings.Join(strs, "") + hash := sha1.Sum([]byte(s)) + return fmt.Sprintf("%x", hash) +} + // wecomDecrypt 企业微信消息解密(AES-256-CBC,PKCS7,明文格式:16字节随机+4字节长度+消息+corpID) func wecomDecrypt(encodingAESKey, encryptedB64 string) ([]byte, error) { key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") @@ -484,54 +533,228 @@ func wecomDecrypt(encodingAESKey, encryptedB64 string) ([]byte, error) { return plain[20 : 20+msgLen], nil } +// wecomEncrypt 企业微信消息加密(AES-256-CBC,PKCS7,明文格式:16字节随机+4字节长度+消息+corpID) +func wecomEncrypt(encodingAESKey, message, corpID string) (string, error) { + key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") + if err != nil { + return "", err + } + if len(key) != 32 { + return "", fmt.Errorf("encoding_aes_key 解码后应为 32 字节") + } + // 构造明文:16 字节随机 + 4 字节长度 (大端) + 消息 + corpID + random := make([]byte, 16) + if _, err := rand.Read(random); err != nil { + // 降级方案:使用时间戳生成随机数 + for i := range random { + random[i] = byte(time.Now().UnixNano() % 256) + } + } + msgLen := len(message) + msgBytes := []byte(message) + corpBytes := []byte(corpID) + plain := make([]byte, 16+4+msgLen+len(corpBytes)) + copy(plain[:16], random) + binary.BigEndian.PutUint32(plain[16:20], uint32(msgLen)) + copy(plain[20:20+msgLen], msgBytes) + copy(plain[20+msgLen:], corpBytes) + // PKCS7 填充 + padding := aes.BlockSize - len(plain)%aes.BlockSize + pad := bytes.Repeat([]byte{byte(padding)}, padding) + plain = append(plain, pad...) + // AES-256-CBC 加密 + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + iv := key[:16] + ciphertext := make([]byte, len(plain)) + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(ciphertext, plain) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + // HandleWecomPOST 企业微信消息回调(POST),支持明文与加密模式 func (h *RobotHandler) HandleWecomPOST(c *gin.Context) { if !h.config.Robots.Wecom.Enabled { + h.logger.Debug("企业微信机器人未启用,跳过请求") c.String(http.StatusOK, "") return } - bodyRaw, _ := io.ReadAll(c.Request.Body) + // 从 URL 获取签名参数(加密模式回复时需要用到) + timestamp := c.Query("timestamp") + nonce := c.Query("nonce") + msgSignature := c.Query("msg_signature") + + // 先读取请求体,后续解析/签名验证都会用到 + bodyRaw, err := io.ReadAll(c.Request.Body) + if err != nil { + h.logger.Warn("企业微信 POST 读取请求体失败", zap.Error(err)) + c.String(http.StatusOK, "") + return + } + h.logger.Debug("企业微信 POST 收到请求", zap.String("body", string(bodyRaw))) + + // 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段 + if msgSignature != "" { + var tmp wecomXML + if err := xml.Unmarshal(bodyRaw, &tmp); err == nil { + expected := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, tmp.Encrypt) + if expected != msgSignature { + h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature)) + c.String(http.StatusOK, "") + return + } + } + } + var body wecomXML if err := xml.Unmarshal(bodyRaw, &body); err != nil { - h.logger.Debug("企业微信 POST 解析 XML 失败", zap.Error(err)) + h.logger.Warn("企业微信 POST 解析 XML 失败", zap.Error(err)) c.String(http.StatusOK, "") return } + h.logger.Debug("企业微信 XML 解析成功", zap.String("ToUserName", body.ToUserName), zap.String("FromUserName", body.FromUserName), zap.String("MsgType", body.MsgType), zap.String("Content", body.Content), zap.String("Encrypt", body.Encrypt)) + + // 保存企业 ID(用于明文模式回复) + enterpriseID := body.ToUserName + // 加密模式:先解密再解析内层 XML if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" { + h.logger.Debug("企业微信进入加密模式解密流程") decrypted, err := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, body.Encrypt) if err != nil { h.logger.Warn("企业微信消息解密失败", zap.Error(err)) c.String(http.StatusOK, "") return } + h.logger.Debug("企业微信解密成功", zap.String("decrypted", string(decrypted))) if err := xml.Unmarshal(decrypted, &body); err != nil { h.logger.Warn("企业微信解密后 XML 解析失败", zap.Error(err)) c.String(http.StatusOK, "") return } + h.logger.Debug("企业微信内层 XML 解析成功", zap.String("FromUserName", body.FromUserName), zap.String("Content", body.Content)) } - 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, - }) + + // 限制回复内容长度(企业微信限制 2048 字节) + maxReplyLen := 2000 + limitReply := func(s string) string { + if len(s) > maxReplyLen { + return s[:maxReplyLen] + "\n\n(内容过长,已截断)" + } + return s + } + + if body.MsgType != "text" { + h.logger.Debug("企业微信收到非文本消息", zap.String("MsgType", body.MsgType)) + h.sendWecomReply(c, userID, enterpriseID, limitReply("暂仅支持文本消息,请发送文字。"), timestamp, nonce) + return + } + + // 文本消息:先判断是否为内置命令(如 帮助/列表/新对话 等),这类命令处理很快,可以直接走被动回复,避免依赖主动发送 API。 + if cmdReply, ok := h.handleRobotCommand("wecom", userID, text); ok { + h.logger.Debug("企业微信收到命令消息,走被动回复", zap.String("userID", userID), zap.String("text", text)) + h.sendWecomReply(c, userID, enterpriseID, limitReply(cmdReply), timestamp, nonce) + return + } + + h.logger.Debug("企业微信开始处理消息(异步 AI)", zap.String("userID", userID), zap.String("text", text)) + + // 企业微信被动回复有 5 秒超时限制,而 AI 调用通常超过该时长。 + // 这里采用推荐做法:立即返回 success(或空串),然后通过主动发送接口推送完整回复。 + c.String(http.StatusOK, "success") + + // 异步处理消息并通过企业微信主动消息接口发送结果 + go func() { + reply := h.HandleMessage("wecom", userID, text) + reply = limitReply(reply) + h.logger.Debug("企业微信消息处理完成", zap.String("userID", userID), zap.String("reply", reply)) + // 调用企业微信 API 主动发送消息 + h.sendWecomMessageViaAPI(userID, enterpriseID, reply) + }() +} + +// sendWecomReply 发送企业微信回复(加密模式自动加密) +// 参数:toUser=用户 ID, fromUser=企业 ID(明文模式)/CorpID(加密模式), content=回复内容,timestamp/nonce=请求参数 +func (h *RobotHandler) sendWecomReply(c *gin.Context, toUser, fromUser, content, timestamp, nonce string) { + // 加密模式:判断 EncodingAESKey 是否配置 + if h.config.Robots.Wecom.EncodingAESKey != "" { + // 加密模式使用 CorpID 进行加密 + corpID := h.config.Robots.Wecom.CorpID + if corpID == "" { + h.logger.Warn("企业微信加密模式缺少 CorpID 配置") + c.String(http.StatusOK, "") + return + } + + // 构造完整的明文 XML 回复(格式严格按企业微信文档要求) + plainResp := fmt.Sprintf(` + + +%d + + +`, toUser, fromUser, time.Now().Unix(), content) + + encrypted, err := wecomEncrypt(h.config.Robots.Wecom.EncodingAESKey, plainResp, corpID) + if err != nil { + h.logger.Warn("企业微信回复加密失败", zap.Error(err)) + c.String(http.StatusOK, "") + return + } + // 使用请求中的 timestamp/nonce 生成签名(企业微信要求回复时使用与请求相同的 timestamp 和 nonce) + msgSignature := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, encrypted) + + h.logger.Debug("企业微信发送加密回复", + zap.String("Encrypt", encrypted[:50]+"..."), + zap.String("MsgSignature", msgSignature), + zap.String("TimeStamp", timestamp), + zap.String("Nonce", nonce)) + + // 加密模式仅返回 4 个核心字段(企业微信官方要求) + xmlResp := fmt.Sprintf(``, encrypted, msgSignature, timestamp, nonce) + // also log the final response body so we can cross-check with the + // network traffic or developer console + h.logger.Debug("企业微信加密回复包", zap.String("xml", xmlResp)) + // for additional confidence, decrypt the payload ourselves and log it + if dec, err2 := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, encrypted); err2 == nil { + h.logger.Debug("企业微信加密回复解密检查", zap.String("plain", string(dec))) + } else { + h.logger.Warn("企业微信加密回复解密检查失败", zap.Error(err2)) + } + + // 使用 c.Writer.Write 直接写入响应,避免 c.String 的转义问题 + c.Writer.WriteHeader(http.StatusOK) + // use text/xml as that's what WeCom examples show + c.Writer.Header().Set("Content-Type", "text/xml; charset=utf-8") + _, _ = c.Writer.Write([]byte(xmlResp)) + h.logger.Debug("企业微信加密回复已发送") + return + } + + // 明文模式 + h.logger.Debug("企业微信发送明文回复", zap.String("ToUserName", toUser), zap.String("FromUserName", fromUser), zap.String("Content", content[:50]+"...")) + + // 手动构造 XML 响应(使用 CDATA 包裹所有字段,并包含 AgentID) + xmlResp := fmt.Sprintf(` + + +%d + + +`, toUser, fromUser, time.Now().Unix(), content) + + // log the exact plaintext response for debugging + h.logger.Debug("企业微信明文回复包", zap.String("xml", xmlResp)) + + // use text/xml as recommended by WeCom docs + c.Header("Content-Type", "text/xml; charset=utf-8") + c.String(http.StatusOK, xmlResp) + h.logger.Debug("企业微信明文回复已发送") } // —————— 测试接口(需登录,用于验证机器人逻辑,无需钉钉/飞书客户端) —————— @@ -562,6 +785,87 @@ func (h *RobotHandler) HandleRobotTest(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"reply": reply}) } +// sendWecomMessageViaAPI 通过企业微信 API 主动发送消息(用于异步处理后的结果发送) +func (h *RobotHandler) sendWecomMessageViaAPI(toUser, toParty, content string) { + if !h.config.Robots.Wecom.Enabled { + return + } + + secret := h.config.Robots.Wecom.Secret + corpID := h.config.Robots.Wecom.CorpID + agentID := h.config.Robots.Wecom.AgentID + + if secret == "" || corpID == "" { + h.logger.Warn("企业微信主动 API 缺少 secret 或 corpID 配置") + return + } + + // 第 1 步:获取 access_token + tokenURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s", corpID, secret) + resp, err := http.Get(tokenURL) + if err != nil { + h.logger.Warn("企业微信获取 token 失败", zap.Error(err)) + return + } + defer resp.Body.Close() + + var tokenResp struct { + AccessToken string `json:"access_token"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + h.logger.Warn("企业微信 token 响应解析失败", zap.Error(err)) + return + } + if tokenResp.ErrCode != 0 { + h.logger.Warn("企业微信 token 获取错误", zap.String("errmsg", tokenResp.ErrMsg), zap.Int("errcode", tokenResp.ErrCode)) + return + } + + // 第 2 步:构造发送消息请求 + msgReq := map[string]interface{}{ + "touser": toUser, + "msgtype": "text", + "agentid": agentID, + "text": map[string]interface{}{ + "content": content, + }, + } + + msgBody, err := json.Marshal(msgReq) + if err != nil { + h.logger.Warn("企业微信消息序列化失败", zap.Error(err)) + return + } + + // 第 3 步:发送消息 + sendURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=%s", tokenResp.AccessToken) + msgResp, err := http.Post(sendURL, "application/json", bytes.NewReader(msgBody)) + if err != nil { + h.logger.Warn("企业微信主动发送消息失败", zap.Error(err)) + return + } + defer msgResp.Body.Close() + + var sendResp struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + InvalidUser string `json:"invaliduser"` + MsgID string `json:"msgid"` + } + if err := json.NewDecoder(msgResp.Body).Decode(&sendResp); err != nil { + h.logger.Warn("企业微信发送响应解析失败", zap.Error(err)) + return + } + + if sendResp.ErrCode == 0 { + h.logger.Debug("企业微信主动发送消息成功", zap.String("msgid", sendResp.MsgID)) + } else { + h.logger.Warn("企业微信主动发送消息失败", zap.String("errmsg", sendResp.ErrMsg), zap.Int("errcode", sendResp.ErrCode), zap.String("invaliduser", sendResp.InvalidUser)) + } +} + // —————— 钉钉 —————— // HandleDingtalkPOST 钉钉事件回调(流式接入等);当前为占位,返回 200 diff --git a/internal/handler/wecom_test.go b/internal/handler/wecom_test.go new file mode 100644 index 00000000..46f29b79 --- /dev/null +++ b/internal/handler/wecom_test.go @@ -0,0 +1,44 @@ +package handler + +import ( + "testing" +) + +// smoke test for the internal wecom encryption/decryption helpers. the +// functions are intentionally unexported because they're implementation +// detail for the robot handler, but we can still write a simple unit test +// in the same package to verify that round trips work with real values from +// our configuration. + +func TestWecomEncryptDecrypt(t *testing.T) { + // these values are pulled from the example config that is used in the + // workspace; the encode key must be the 43-character string (base64 + // encoded AES key) and the corpID should match the ToUserName that we + // receive in callbacks. + encodingKey := "TupAQh8HeOFYLrRhH8xc3wvJeds6nu1Xr0hn1Lfy1Gh" + corpID := "wwf37149f596dd5638" + + // build a sample XML payload exactly how sendWecomReply constructs it. + msg := `` + + `` + + `` + + `12345` + + `` + + `` + + `` + + `` + + enc, err := wecomEncrypt(encodingKey, msg, corpID) + if err != nil { + t.Fatalf("encrypt error: %v", err) + } + + dec, err := wecomDecrypt(encodingKey, enc) + if err != nil { + t.Fatalf("decrypt error: %v", err) + } + + if string(dec) != msg { + t.Fatalf("round trip mismatch; got %q", string(dec)) + } +}