mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-17 08:31:22 +02:00
Add files via upload
This commit is contained in:
+38
-6
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](robot_en.md)
|
[English](robot_en.md)
|
||||||
|
|
||||||
本文档说明如何通过**钉钉**、**飞书**与 CyberStrikeAI 对话(长连接模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
|
本文档说明如何通过**钉钉**、**飞书**与 **企业微信** 与 CyberStrikeAI 对话(长连接 / 回调模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,12 +19,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 二、支持的平台(长连接)
|
## 二、支持的平台(长连接 / 回调)
|
||||||
|
|
||||||
| 平台 | 说明 |
|
| 平台 | 说明 |
|
||||||
|------|------|
|
|----------|------|
|
||||||
| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 |
|
| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 |
|
||||||
| 飞书 | 使用长连接,程序主动连接飞书接收消息 |
|
| 飞书 | 使用长连接,程序主动连接飞书接收消息 |
|
||||||
|
| 企业微信 | 使用 HTTP 回调接收消息,被动回包 + 主动调用企业微信发送消息 API |
|
||||||
|
|
||||||
下面第三节会按平台写清:在开放平台要做什么、要复制哪些字段、填到 CyberStrikeAI 的哪一栏。
|
下面第三节会按平台写清:在开放平台要做什么、要复制哪些字段、填到 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` 提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 四、机器人命令
|
## 四、机器人命令
|
||||||
|
|
||||||
在钉钉/飞书中向机器人发送以下**文本命令**(仅支持文本):
|
在钉钉/飞书中向机器人发送以下**文本命令**(仅支持文本):
|
||||||
|
|||||||
+36
-6
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[中文](robot.md)
|
[中文](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 |
|
| Platform | Description |
|
||||||
|----------|-------------|
|
|----------------|-------------|
|
||||||
| DingTalk | Stream long-lived connection; the app connects to DingTalk to receive messages |
|
| 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 |
|
| 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.
|
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
|
## 4. Bot commands
|
||||||
|
|
||||||
Send these **text commands** to the bot in DingTalk or Lark (text only):
|
Send these **text commands** to the bot in DingTalk or Lark (text only):
|
||||||
|
|||||||
+381
-77
@@ -1,11 +1,15 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -141,56 +145,9 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
|
|||||||
return "请输入内容或发送「帮助」/ help 查看命令。"
|
return "请输入内容或发送「帮助」/ help 查看命令。"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 命令分发(支持中英文)
|
// 先尝试作为命令处理(支持中英文)
|
||||||
switch {
|
if cmdReply, ok := h.handleRobotCommand(platform, userID, text); ok {
|
||||||
case text == robotCmdHelp || text == "help" || text == "?" || text == "?":
|
return cmdReply
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 普通消息:走 Agent
|
// 普通消息:走 Agent
|
||||||
@@ -404,6 +361,62 @@ func (h *RobotHandler) cmdVersion() string {
|
|||||||
return "CyberStrikeAI " + v
|
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(明文模式下的简化结构;加密模式需先解密再解析)
|
// wecomXML 企业微信回调 XML(明文模式下的简化结构;加密模式需先解密再解析)
|
||||||
@@ -418,14 +431,14 @@ type wecomXML struct {
|
|||||||
Encrypt string `xml:"Encrypt"` // 加密模式下消息在此
|
Encrypt string `xml:"Encrypt"` // 加密模式下消息在此
|
||||||
}
|
}
|
||||||
|
|
||||||
// wecomReplyXML 被动回复 XML
|
// wecomReplyXML 被动回复 XML(仅用于兼容,当前使用手动构造 XML)
|
||||||
type wecomReplyXML struct {
|
type wecomReplyXML struct {
|
||||||
XMLName xml.Name `xml:"xml"`
|
XMLName xml.Name `xml:"xml"`
|
||||||
ToUserName string `xml:"ToUserName"`
|
ToUserName string `xml:"ToUserName"`
|
||||||
FromUserName string `xml:"FromUserName"`
|
FromUserName string `xml:"FromUserName"`
|
||||||
CreateTime int64 `xml:"CreateTime"`
|
CreateTime int64 `xml:"CreateTime"`
|
||||||
MsgType string `xml:"MsgType"`
|
MsgType string `xml:"MsgType"`
|
||||||
Content string `xml:"Content"`
|
Content string `xml:"Content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleWecomGET 企业微信 URL 校验(GET)
|
// HandleWecomGET 企业微信 URL 校验(GET)
|
||||||
@@ -434,15 +447,51 @@ func (h *RobotHandler) HandleWecomGET(c *gin.Context) {
|
|||||||
c.String(http.StatusNotFound, "")
|
c.String(http.StatusNotFound, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Gin 的 Query() 会自动 URL 解码,拿到的就是正确的 base64 字符串
|
||||||
echostr := c.Query("echostr")
|
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 == "" {
|
if echostr == "" {
|
||||||
c.String(http.StatusBadRequest, "missing echostr")
|
c.String(http.StatusBadRequest, "missing echostr")
|
||||||
return
|
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)
|
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)
|
// wecomDecrypt 企业微信消息解密(AES-256-CBC,PKCS7,明文格式:16字节随机+4字节长度+消息+corpID)
|
||||||
func wecomDecrypt(encodingAESKey, encryptedB64 string) ([]byte, error) {
|
func wecomDecrypt(encodingAESKey, encryptedB64 string) ([]byte, error) {
|
||||||
key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
||||||
@@ -484,54 +533,228 @@ func wecomDecrypt(encodingAESKey, encryptedB64 string) ([]byte, error) {
|
|||||||
return plain[20 : 20+msgLen], nil
|
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),支持明文与加密模式
|
// HandleWecomPOST 企业微信消息回调(POST),支持明文与加密模式
|
||||||
func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
||||||
if !h.config.Robots.Wecom.Enabled {
|
if !h.config.Robots.Wecom.Enabled {
|
||||||
|
h.logger.Debug("企业微信机器人未启用,跳过请求")
|
||||||
c.String(http.StatusOK, "")
|
c.String(http.StatusOK, "")
|
||||||
return
|
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
|
var body wecomXML
|
||||||
if err := xml.Unmarshal(bodyRaw, &body); err != nil {
|
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, "")
|
c.String(http.StatusOK, "")
|
||||||
return
|
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
|
// 加密模式:先解密再解析内层 XML
|
||||||
if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" {
|
if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" {
|
||||||
|
h.logger.Debug("企业微信进入加密模式解密流程")
|
||||||
decrypted, err := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, body.Encrypt)
|
decrypted, err := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, body.Encrypt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("企业微信消息解密失败", zap.Error(err))
|
h.logger.Warn("企业微信消息解密失败", zap.Error(err))
|
||||||
c.String(http.StatusOK, "")
|
c.String(http.StatusOK, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.logger.Debug("企业微信解密成功", zap.String("decrypted", string(decrypted)))
|
||||||
if err := xml.Unmarshal(decrypted, &body); err != nil {
|
if err := xml.Unmarshal(decrypted, &body); err != nil {
|
||||||
h.logger.Warn("企业微信解密后 XML 解析失败", zap.Error(err))
|
h.logger.Warn("企业微信解密后 XML 解析失败", zap.Error(err))
|
||||||
c.String(http.StatusOK, "")
|
c.String(http.StatusOK, "")
|
||||||
return
|
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
|
userID := body.FromUserName
|
||||||
text := strings.TrimSpace(body.Content)
|
text := strings.TrimSpace(body.Content)
|
||||||
reply := h.HandleMessage("wecom", userID, text)
|
|
||||||
// 加密模式需加密回复(此处简化为明文回复;若企业要求加密需再实现加密)
|
// 限制回复内容长度(企业微信限制 2048 字节)
|
||||||
c.XML(http.StatusOK, wecomReplyXML{
|
maxReplyLen := 2000
|
||||||
ToUserName: body.FromUserName,
|
limitReply := func(s string) string {
|
||||||
FromUserName: body.ToUserName,
|
if len(s) > maxReplyLen {
|
||||||
CreateTime: time.Now().Unix(),
|
return s[:maxReplyLen] + "\n\n(内容过长,已截断)"
|
||||||
MsgType: "text",
|
}
|
||||||
Content: reply,
|
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(`<xml>
|
||||||
|
<ToUserName><![CDATA[%s]]></ToUserName>
|
||||||
|
<FromUserName><![CDATA[%s]]></FromUserName>
|
||||||
|
<CreateTime>%d</CreateTime>
|
||||||
|
<MsgType><![CDATA[text]]></MsgType>
|
||||||
|
<Content><![CDATA[%s]]></Content>
|
||||||
|
</xml>`, 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(`<xml><Encrypt><![CDATA[%s]]></Encrypt><MsgSignature><![CDATA[%s]]></MsgSignature><TimeStamp><![CDATA[%s]]></TimeStamp><Nonce><![CDATA[%s]]></Nonce></xml>`, 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(`<xml>
|
||||||
|
<ToUserName><![CDATA[%s]]></ToUserName>
|
||||||
|
<FromUserName><![CDATA[%s]]></FromUserName>
|
||||||
|
<CreateTime>%d</CreateTime>
|
||||||
|
<MsgType><![CDATA[text]]></MsgType>
|
||||||
|
<Content><![CDATA[%s]]></Content>
|
||||||
|
</xml>`, 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})
|
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
|
// HandleDingtalkPOST 钉钉事件回调(流式接入等);当前为占位,返回 200
|
||||||
|
|||||||
@@ -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 := `<xml>` +
|
||||||
|
`<ToUserName><![CDATA[toUser]]></ToUserName>` +
|
||||||
|
`<FromUserName><![CDATA[fromUser]]></FromUserName>` +
|
||||||
|
`<CreateTime>12345</CreateTime>` +
|
||||||
|
`<MsgType><![CDATA[text]]></MsgType>` +
|
||||||
|
`<Content><![CDATA[test]]></Content>` +
|
||||||
|
`<AgentID><![CDATA[1000002]]></AgentID>` +
|
||||||
|
`</xml>`
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user