让你的企业微信员工号秒变 AI 智能客服,支持私聊和群聊自动回复。
联系微信:llike620
一、项目背景
企业微信已成为国内企业内外沟通的核心工具。但员工号需要人工回复客户消息,效率低、响应慢。有没有办法让一个普普通通的企业微信员工号,自动拥有 AI 大脑,7×24 小时智能回复客户?
答案是:企业微信 Hook 注入协议 + 扣子(Coze)知识库机器人。
本项目完整实现了这一方案——使用 Go 语言构建了一套轻量级的 SCRM 服务,通过 Hook 协议与企业微信客户端通信,接入扣子知识库 API,实现私聊&群聊全自动回复。
二、整体架构

核心链路:
- 客户在企业微信中给员工号发消息
- 注入版客户端拦截消息,通过 Hook 协议回调到我们的 Go 服务
- Go 服务提取消息内容,调用扣子知识库 API
- 扣子返回 AI 生成的回复,Go 服务再通过 Hook 协议驱动客户端发送回复
- 一次完整的 AI 自动对话就这样完成了
三、Hook 协议封装
3.1 协议简述
企业微信注入版 Hook 在本地暴露了一个 HTTP 服务(默认为 http://127.0.0.1:8061),通过不同的命令码(type)来操作企业微信客户端:
| 命令码 | 路径 | 功能 |
|---|---|---|
| 11029 | /cmd/11029 | 发送普通消息 |
| 11035 | /cmd/11035 | 获取账号信息 |
| 11069 | /cmd/11069 | 发送群@消息 |
| … | … | 登录、扫码、获取联系人等 |
3.2 Go 客户端封装
我们将 Hook 协议封装为一个 Go 客户端库 lib/wxworkhook/client.go,核心设计如下:
单例客户端:
var (
instance *Client
once sync.Once
)
// Client 企业微信Hook协议客户端
type Client struct {
ClientId string // client_id
Profile Profile // 当前账号信息(调用 GetProfile 后填充)
profileFet bool // 是否已拉取过个人信息
}
// GetClient 获取单例客户端
func GetClient(clientId string) *Client {
once.Do(func() {
instance = &Client{
ClientId: clientId,
}
})
return instance
}
发送普通消息(私聊用):
// SendMsg 发送消息(通过 /cmd/11029)
// conversationID: 会话ID,单聊填用户ID,群聊填群ID
// content: 消息文本内容
func (c *Client) SendTxtMsg(conversationID, content string) (*CmdResponse, error) {
req := SendMsgReq{
Type: 11029,
ClientId: c.ClientId,
Data: SendMsgData{
ConversationID: conversationID,
Content: content,
},
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %v", err)
}
url := DefaultBaseURL + "/cmd/11029"
log.Printf("[企微Hook发送] 请求: %s, 参数: %s\n", url, string(body))
resp, err := http.Post(url, "application/json", bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
var result CmdResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
respBody, _ := json.Marshal(result)
log.Printf("[企微Hook发送] 响应内容: %s\n", string(respBody))
if result.ErrCode != 0 {
return &result, fmt.Errorf("发送消息失败: %s", result.ErrMsg)
}
log.Printf("[企微Hook发送] 消息发送成功 -> conversation_id=%s\n", conversationID)
return &result, nil
}
发送群@消息(群聊用):
// SendAtMsg 发送群@消息(通过 /cmd/11069)
// conversationID: 会话ID(群聊填群ID)
// content: 消息文本内容
// atList: @的用户ID列表
func (c *Client) SendAtMsg(conversationID, content string, atList []string) (*CmdResponse, error) {
req := SendAtMsgReq{
Type: 11069,
ClientId: c.ClientId,
Data: SendAtMsgData{
ConversationID: conversationID,
Content: content,
AtList: atList,
},
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %v", err)
}
url := DefaultBaseURL + "/cmd/11069"
log.Printf("[企微Hook群@] 请求: %s, 参数: %s\n", url, string(body))
resp, err := http.Post(url, "application/json", bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
var result CmdResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
respBody, _ := json.Marshal(result)
log.Printf("[企微Hook群@] 响应内容: %s\n", string(respBody))
if result.ErrCode != 0 {
return &result, fmt.Errorf("发送群@消息失败: %s", result.ErrMsg)
}
log.Printf("[企微Hook群@] 消息发送成功 -> conversation_id=%s, at_list=%v\n", conversationID, atList)
return &result, nil
}
3.3 关键细节:私聊 vs 群聊识别
企业微信 Hook 回调中的 conversation_id 字段有明确前缀:
S:开头 → 私聊(Single chat)R:开头 → 群聊(Room chat)
注意:这并非官方 API 规范,而是注入版 Hook 的约定。不同 Hook 版本可能不同,需根据实际情况调整。
四、扣子 Coze API 接入
4.1 为什么选扣子?
- 知识库原生支持:上传文档即可构建领域知识库,无需训练模型
- 会话记忆:每个用户维护独立会话,上下文连续
- 简单 API:创建会话 → 发送消息 → 轮询结果 → 获取回复,四步搞定
- 免费额度:个人开发者白嫖友好
4.2 Go 封装实现

关键代码——轮询等待完成后获取 AI 回复:
func (c *Coze) waitForCompletion(chatID, conversationID string) error {
url := "https://api.coze.cn/v3/chat/retrieve"
maxRetries := 30
interval := 1 * time.Second
for i := 0; i < maxRetries; i++ {
queryURL := fmt.Sprintf("%s?chat_id=%s&conversation_id=%s", url, chatID, conversationID)
req, err := http.NewRequest("GET", queryURL, nil)
if err != nil {
return fmt.Errorf("create request failed: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.API_KEY)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response failed: %w", err)
}
var response ChatRetrieveResponse
if err := json.Unmarshal(body, &response); err != nil {
return fmt.Errorf("unmarshal response failed: %w", err)
}
if response.Code != 0 {
return fmt.Errorf("API error: %s", response.Msg)
}
if response.Data.Status == "completed" {
return nil
}
time.Sleep(interval)
}
return fmt.Errorf("wait for completion timeout")
}
func (c *Coze) getFinalResponse(chatID, conversationID string) (string, error) {
url := "https://api.coze.cn/v3/chat/message/list"
queryURL := fmt.Sprintf("%s?chat_id=%s&conversation_id=%s", url, chatID, conversationID)
req, err := http.NewRequest("GET", queryURL, nil)
if err != nil {
return "", fmt.Errorf("create request failed: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.API_KEY)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response failed: %w", err)
}
var response MessageListResponse
if err := json.Unmarshal(body, &response); err != nil {
return "", fmt.Errorf("unmarshal response failed: %w", err)
}
if response.Code != 0 {
return "", fmt.Errorf("API error: %s", response.Msg)
}
var assistantReplies []Message
for _, msg := range response.Data {
if msg.Role == "assistant" && msg.Type == "answer" {
assistantReplies = append(assistantReplies, msg)
}
}
if len(assistantReplies) > 0 {
return assistantReplies[len(assistantReplies)-1].Content, nil
}
return "", nil
}
4.3 会话缓存
每个用户的会话 ID 使用 sync.RWMutex 保护的内存 map 缓存,避免重复创建会话:
type Coze struct {
BOT_ID string
API_KEY string
conversation map[string]string
mu sync.RWMutex
}
这意味着同一位客户的所有对话共享同一个 Coze 会话上下文,AI 能记住之前的对话内容。
五、核心:回调处理与自动回复
这是整个系统的大脑——routers/wxwork_hook.go 中的 PostWxWorkHookCallback:
// PostWxWorkHookCallback 企业微信Hook回调(通用回调入口)
func PostWxWorkHookCallback(c *gin.Context) {
rawData, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("[企微Hook回调] 读取body失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"code": -1, "msg": "读取失败"})
return
}
log.Printf("[企微Hook回调] Body: %s\n", string(rawData))
payloadType := gjson.GetBytes(rawData, "payload.type").Int()
// 普通消息:提取字段存入 LiveComment 表
clientId := gjson.GetBytes(rawData, "client_id").String()
content := gjson.GetBytes(rawData, "payload.data.content").String()
sender := gjson.GetBytes(rawData, "payload.data.sender").String()
senderName := gjson.GetBytes(rawData, "payload.data.sender_name").String()
conversationID := gjson.GetBytes(rawData, "payload.data.conversation_id").String()
// type 11035 = 账号信息回调
if payloadType == 11035 {
var p wxworkhook.Profile
if err := json.Unmarshal([]byte(gjson.GetBytes(rawData, "payload.data").Raw), &p); err != nil {
log.Printf("[企微Hook回调] 解析账号信息失败: %v\n", err)
} else {
client := wxworkhook.GetClient(clientId)
client.SetProfile(p)
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "ok"})
return
}
comment := &models.LiveComment{
Platform: sender,
Nickname: senderName,
Content: content,
To: conversationID,
}
if err := models.AddOrUpdateLiveComment(comment); err != nil {
log.Printf("[企微Hook回调] 存表失败: %v\n", err)
} else {
log.Printf("[企微Hook回调] 消息入库 id=%d sender_name=%s content=%s\n", comment.ID, senderName, content)
}
// 自动回复(跳过自己的消息,防止死循环)
myUserId := models.FindSettingDefault("wxworkUserId", "")
if conversationID != "" && content != "" && myUserId != "" && sender != myUserId {
botID := models.FindSettingDefault("cozeBotId", "")
apiKey := models.FindSettingDefault("cozeApiKey", "")
if botID != "" && apiKey != "" {
reply, err := coze.ChatCozeAPI(botID, apiKey, sender, content)
if err != nil {
log.Printf("[企微Hook回调] Coze调用失败: %v\n", err)
} else if reply != "" {
client := wxworkhook.GetClient(clientId)
var sendErr error
if strings.HasPrefix(conversationID, "R:") {
// 群聊:发送群@消息
_, sendErr = client.SendAtMsg(conversationID, reply, []string{sender})
} else {
// 私聊:发送普通文本消息
_, sendErr = client.SendTxtMsg(conversationID, reply)
}
if sendErr != nil {
log.Printf("[企微Hook回调] 自动回复失败: %v\n", sendErr)
}
}
} else {
log.Printf("[企微Hook回调] Coze配置未完成,跳过自动回复\n")
}
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "ok"})
}
回调流程拆解:
| 步骤 | 操作 | 说明 |
|---|---|---|
| ① 接收 | 读取 HTTP Body | 企业微信 Hook 回调 POST JSON |
| ② 分发 | 根据 payload.type 判断 | 11035→更新账号信息,其他→消息处理 |
| ③ 入库 | 写入 LiveComment 表 | 所有消息持久化存储,可追溯 |
| ④ 防回环 | 比对 sender 与 myUserId | 跳过自己发出的消息,避免无限回复 |
| ⑤ AI 调用 | coze.ChatCozeAPI() | 以发送者身份调用扣子 API |
| ⑥ 会话区分 | 检查 conversationID 前缀 | R: → 群@消息;S: → 普通消息 |
| ⑦ 回复 | SendTxtMsg / SendAtMsg | 通过 Hook 协议驱动企微客户端发送 |
六、配置管理
系统使用 SQLite 存储运行时配置(Setting 表),通过 key-value 方式管理:
func FindSettingDefault(key string, def string) string {
val := FindSetting(key).Value
if val == "" {
return def
}
return val
}
三个关键配置项:
| Key | 说明 | 示例值 |
|---|---|---|
wxworkUserId | 当前员工号 UserID | 用于防止自我回复 |
cozeBotId | 扣子机器人 Bot ID | 从 Coze 控制台获取 |
cozeApiKey | 扣子 API Key | Bearer Token 认证 |
这些配置通过后台管理页面 /admin/setting 可视化配置。
七、技术栈总结
| 层级 | 技术选型 | 用途 |
|---|---|---|
| Web 框架 | Gin (Go) | HTTP 路由、中间件、模板渲染 |
| 数据库 | SQLite (GORM) | 配置存储、消息记录 |
| 缓存 | Redis | 可选,热数据缓存 |
| WebSocket | gorilla/websocket | 实时消息推送(直播评论屏等) |
| AI 引擎 | 扣子 Coze API v3 | 知识库问答、上下文会话 |
| 通信协议 | 企业微信 Hook | 接收消息回调 + 驱动发消息 |
| 前端 | 原生 HTML + JS | 管理后台 Dashboard |
- 轻量:单 Go 二进制,SQLite 无外部依赖,Windows/Linux/Mac 通吃
- 低侵入:Hook 注入方式,不依赖企业微信官方 API 权限审批
- 可扩展:模块化设计,Hook 客户端、Coze 引擎均可替换为其他实现
- 完整闭环:消息入库 → AI 生成 → 自动回复→ 前端实时展示
如果你也有类似需求——让企业微信员工号变身智能客服,不妨参考这套方案。