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

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

联系微信:llike620


一、项目背景

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

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

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


二、整体架构

核心链路

  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 封装实现

关键代码——轮询等待完成后获取 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 KeyBearer Token 认证

这些配置通过后台管理页面 /admin/setting 可视化配置。


七、技术栈总结

层级技术选型用途
Web 框架Gin (Go)HTTP 路由、中间件、模板渲染
数据库SQLite (GORM)配置存储、消息记录
缓存Redis可选,热数据缓存
WebSocketgorilla/websocket实时消息推送(直播评论屏等)
AI 引擎扣子 Coze API v3知识库问答、上下文会话
通信协议企业微信 Hook接收消息回调 + 驱动发消息
前端原生 HTML + JS管理后台 Dashboard
  • 轻量:单 Go 二进制,SQLite 无外部依赖,Windows/Linux/Mac 通吃
  • 低侵入:Hook 注入方式,不依赖企业微信官方 API 权限审批
  • 可扩展:模块化设计,Hook 客户端、Coze 引擎均可替换为其他实现
  • 完整闭环:消息入库 → AI 生成 → 自动回复→ 前端实时展示

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

程序员老狼

程序员老狼

新浪前高级开发工程师,Golang、PHP 全栈开发者,十余年后端架构实战经验。自研唯一客服系统及配套浏览器自动化插件,专注企业客服生态与 RPA 自动化技术。

了解更多 → 企业备案域名 · 聊城变量网络科技有限公司