从零搭建企业微信 AI 客服:Hook 注入 + 扣子知识库实战

让你的企业微信员工号秒变 AI 智能客服,支持私聊和群聊自动回复。

原文地址


一、项目背景

企业微信已成为国内企业内外沟通的核心工具。但员工号需要人工回复客户消息,效率低、响应慢。有没有办法让一个普普通通的企业微信员工号,自动拥有 AI 大脑,7×24 小时智能回复客户?

答案是:企业微信 Hook 注入协议 + 扣子(Coze)知识库机器人

本项目完整实现了这一方案——使用 Go 语言构建了一套轻量级的 SCRM 服务,通过 Hook 协议与企业微信客户端通信,接入扣子知识库 API,实现私聊&群聊全自动回复。


二、整体架构

flowchart LR subgraph 企业微信客户端 A[企业微信<br/>注入版客户端] end subgraph Hook 协议层 B[本地 Hook 服务<br/>127.0.0.1:8061] end subgraph SCRM 服务 C[Go 后端<br/>Gin 框架 :8089] D[(SQLite/MySQL)] end subgraph AI 能力 E[扣子 Coze API<br/>api.coze.cn] end subgraph 用户端 F[客户私聊] G[客户群聊] end F --> A G --> A A <-->|WebSocket/HTTP| B B -->|回调通知| C C -->|读取配置| D C -->|调用 AI| E E -->|返回回复| C C -->|发送消息 API| B B -->|路由至| A A -->|回复| F A -->|@回复| G

核心链路

  1. 客户在企业微信中给员工号发消息
  2. 注入版客户端拦截消息,通过 Hook 协议回调到我们的 Go 服务
  3. Go 服务提取消息内容,调用扣子知识库 API
  4. 扣子返回 AI 生成的回复,Go 服务再通过 Hook 协议驱动客户端发送回复
  5. 一次完整的 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 封装实现

扣子 API v3 的核心流程:

sequenceDiagram participant S as SCRM服务 participant C as Coze API Note over S,C: 每个用户首次对话 S->>C: POST /v1/conversation/create C-->>S: conversation_id S->>S: 缓存 conversation_id Note over S,C: 每次发消息 S->>C: POST /v3/chat?conversation_id=xxx C-->>S: chat_id + conversation_id loop 轮询等待(最多30秒) S->>C: GET /v3/chat/retrieve C-->>S: status: in_progress/completed end S->>C: GET /v3/chat/message/list C-->>S: assistant 回复内容 S->>S: 提取 answer 消息返回

关键代码——轮询等待完成后获取 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 所有消息持久化存储,可追溯
④ 防回环 比对 sendermyUserId 跳过自己发出的消息,避免无限回复
⑤ 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 生成 → 自动回复→ 前端实时展示

如果你也有类似需求——让企业微信员工号变身智能客服,不妨参考这套方案。


发表于 2026 年 6 月 4 日 | 标签:Go, 企业微信, Coze, AI, Hook, SCRM


posted @ 2026-06-04 15:30  程序员老狼  阅读(376)  评论(0)    收藏  举报