不开编辑器,在飞书里也能找 WES Code 的 AI 干活

0 阅读13分钟

人不在电脑前,突然想让 AI 看一眼 CI 为什么挂了,或者同事在飞书里贴了一段报错,想让 AI 先分析一下。这时候还得回到电脑前、打开编辑器、把报错复制进去,就有点麻烦了。

WES Code 可以接入飞书、钉钉这些办公聊天工具。接好以后,在多人会话或者单聊里给机器人发消息,就是在找编辑器里的那个 AI 干活,它回复的内容也直接发回原来的会话。这篇讲能接哪些平台、怎么配,以及一条消息进来以后,引擎都做了哪些事:归到哪段对话、交给哪位助手、有哪些保护、回复怎么一边生成一边更新。最后把这几步的代码拿出来跑一遍。

能接哪些平台,怎么接

打开侧边栏的设置,「连接」分组下面有一项「IM 渠道」,用来接入各种即时通讯平台。点「添加」,选择接入平台,以最常用的两个为例:

平台接入方式
飞书 / Lark企业自建应用,WebSocket 长连接,不需要公网 IP
钉钉Stream SDK 长连接,不需要公网 IP

另外还有四种接法,在「选择接入平台」里都能看到,其中只有一种需要准备公网能访问的 HTTPS 地址。

大多数平台走的是长连接:由你这边主动连到平台,平台把消息推过来,所以在自己电脑上就能用,不用准备服务器。选好平台以后,按页面上的提示填凭证就行,比如飞书要的 App ID 和 App Secret,页面会提示去「飞书开放平台 → 应用凭证」里复制;每个平台都带一份「配置指引」,飞书的是四步:在开放平台创建企业自建应用,在凭证与基础信息页拿到 App ID 和 App Secret,在「机器人」能力页开启机器人,最后把应用发布到企业、等管理员审批。填完点「保存并连接」,状态变成「运行中」就接好了。

这么多平台能用同一套流程,是因为引擎给每个平台都包了一层适配器,所有适配器都实现同一组方法:

type Adapter interface {
	// PlatformID returns the stable, lowercase channel identifier.
	// Examples: "feishu", "dingtalk", "wecom", "weixin", "qqbot".
	// Used as SessionKey.Platform and as the HomeChannels map key.
	// Must be constant for the lifetime of the adapter.
	PlatformID() string

	// ……Connect / Disconnect:建立和断开平台连接……

	// Receive returns a read-only stream of inbound messages.
	// Every MediaItem in every RawMessage satisfies:
	//   item.LocalPath ≠ ""  (INV-GW-01: media downloaded before delivery)
	// The channel is closed when Disconnect is called or Connect fails internally.
	Receive() <-chan RawMessage

	// Send delivers a message to the target conversation.
	// Returns a SendResult whose MessageID can be used with EditMessage (Level 2).
	Send(ctx context.Context, target Target, msg OutMessage) (SendResult, error)
}

Receive 把平台推来的消息整理成统一的格式交出来,Send 把回复发回去。注释里还有一条约定:消息里的图片、文件,在交出来之前必须已经下载到本地。所以到了后面的环节,不管消息来自哪个平台,处理方式都一样。

飞书里的一条消息,怎么变成一次对话

以飞书为例。飞书推过来的是它自己格式的事件,适配器先把它转成统一的消息(节选):

	if sender.SenderType != nil && *sender.SenderType == "app" {
		return nil
	}
	// ……
	msgType := strVal(msg.MessageType)
	text := extractText(msgType, strVal(msg.Content))

	chatType := "dm"
	if ct := strVal(msg.ChatType); ct == "group" || ct == "topic_group" {
		chatType = "group"
	}

第一段是:机器人自己发的消息直接忽略,不然它回复一句,自己又收到一句,就成了自言自语。中间从消息内容里取出文字,普通文字和富文本都能取。最后判断是不是多人会话:普通的多人会话和话题形式的会话,都按多人会话处理,其余的按单聊处理。图片、文件、语音、视频这类消息,适配器会先记下文件的标识,下载到本地以后再往下交。

统一的消息交给分发器,分发器按固定的顺序一步步处理:

  1. 检查附件都已经下载到本地
  2. 检查认得出发送人,认不出的消息不处理
  3. 决定这条消息归到哪段对话
  4. 检查访问权限,不允许的消息直接丢弃,不回复
  5. 检查发送频率,超出限制的也直接丢弃
  6. 如果是 /new、/stop 这类命令,就地处理,不交给 AI
  7. 扫描消息里有没有隐形字符、泄露的凭证,并给这一轮打上「来自外部渠道」的标记
  8. 交给对应的助手开始这一轮对话,同一段对话里的消息按顺序一条条处理

第 2 步要特别说一下:发送人是必填的。引擎的记忆是按人分开存的,如果认不出是谁发的,就没法知道该用谁的记忆,与其把大家混成一个人,不如不处理。所以就算一个多人会话里大家共用一段对话,每个人的个人记忆仍然是分开的。

谁的消息归到哪段对话

第 3 步决定的是「这条消息接着哪段对话往下聊」,规则在这个函数里:

func BuildSessionKey(
	raw RawMessage,
	groupPerUser bool,
	threadPerUser bool,
	fixedAgentID string,
) engine.SessionKey {
	chatID := raw.ChatID
	chatType := raw.ChatType

	// thread isolation: use ThreadID as the conversation boundary
	if threadPerUser && raw.ThreadID != "" {
		chatID = raw.ThreadID
		chatType = "thread"
	}

	var userID string
	if chatType != "dm" && groupPerUser {
		userID = raw.UserID
	}

	return engine.SessionKey{
		Platform: raw.Platform,
		ChatType: chatType,
		ChatID:   chatID,
		UserID:   userID,
		AgentID:  fixedAgentID,
	}
}

单聊天然是一人一段对话。多人会话默认是整个会话共用一段:张三问了一个问题,李四接着问「那这个呢」,AI 知道「这个」指的是什么。引擎还支持两种更细的分法:按成员分开(groupPerUser),会话里每个人和 AI 各聊各的;按话题分开(threadPerUser),飞书的话题里每个话题一段对话。如果这个会话固定交给了某位助手,助手的 id 也会写进对话的标识里。

这个标识最后会用冒号拼成一个字符串,当作对话的 id:平台、类型、会话 id 是固定的三段,成员和助手按需往后加。没有成员但有助手时,中间会留一个空位,保证助手 id 总在第五段,不会和成员 id 混淆。后面的实测里能看到拼出来的样子。

交给哪位助手

在渠道设置里,每个渠道可以「绑定助手」,页面上的说明是:选择此渠道使用的固定助手,不选则使用路由自动匹配。比如把飞书渠道绑定给预装的代码审查员,飞书里来的消息就都由它来回答。

引擎这一层的规则更细一些:可以按平台、按机器人、按会话分别绑定,一条消息可能同时符合好几条规则,这时按「越具体越优先」来选:

func (d *MessageDispatcher) bindingScore(b *config.ChannelBinding, raw channel.RawMessage) int {
	platformMatch := b.Platform == "" || b.Platform == raw.Platform
	accountMatch := b.AccountID == "" || b.AccountID == raw.AccountID
	chatMatch := b.ChatID == "" || b.ChatID == raw.ChatID

	if !platformMatch || !accountMatch || !chatMatch {
		return -1
	}

	switch {
	case b.Platform != "" && b.AccountID != "" && b.ChatID != "":
		return 0 // most specific: platform + accountID + chatID
	case b.Platform != "" && b.AccountID != "":
		return 1 // platform + accountID
	case b.Platform != "" && b.ChatID != "":
		return 2 // platform + chatID
	case b.Platform != "":
		return 3 // platform only
	default:
		return 4 // global fallback
	}
}

分数越低越优先:指定到具体某个会话的规则,压过整个平台的默认规则;什么都没指定的那条,是最后的兜底。绑定了助手的对话,引擎还会在提示词的最后补一句,提醒它「你的身份是某某」,这样在会话里被问到「你是谁」时,它会以这位助手的身份回答。

交给 AI 之前的几道保护

多人会话里人多,消息也杂,所以在交给 AI 之前,还有几道把关。

频率限制。 默认每个人每分钟最多 10 条,每个会话每分钟最多 60 条:

func DefaultChannelRateLimitConfig() RateLimitConfig {
	return RateLimitConfig{
		PerUserPerMinute:    10,
		PerChannelPerMinute: 60,
		MaxBuckets:          10_000,
	}
}

超出的消息会被直接丢掉,不回复,也不会占用 AI。按人和按会话各算各的,一个人刷屏,不影响会话里其他人正常使用。

几个随手可用的命令。 在会话里直接发 /new、/reset、/stop,不会交给 AI,而是就地处理:/new 和 /reset 清空这段对话,从头再聊,机器人会回一句「✓ 会话已重置」;/stop 中止正在进行的这一轮。判断时要求整句以命令开头,后面要么结束、要么跟一个空格,所以「/newbie」或者句子中间提到的「/new」,都不会被误当成命令。

来自外部渠道的消息,单独标记。 每条消息都会先扫描一遍,看有没有零宽字符这类肉眼看不见的字符,以及有没有夹带密码、令牌、私钥这类凭证。扫描不会拒收消息,而是把结果和来源记在这一轮对话上:

	params.Tags["trust_level"] = "channel_untrusted"
	params.Tags["trust_source"] = raw.Platform
	if len(threatFindings) > 0 {
		var ids []string
		for _, f := range threatFindings {
			ids = append(ids, f.PatternID)
		}
		params.Tags["trust_threat_findings"] = strings.Join(ids, ",")

所有从 IM 渠道来的对话,都带着 channel_untrusted 这个标记和消息来源,扫描命中的规则也记在上面,这些信息会一路带进每次工具调用的上下文。引擎原有的那些安全边界,比如设置里「禁止访问的路径」、命中就拦的硬性规则,对这些对话同样生效。

回复怎么发回去

AI 的回答是一段段生成出来的。对飞书、钉钉这类支持编辑消息的平台,会先发一条消息,再随着生成不断更新这条消息的内容,会话里看到的就是一行行往外长的效果(节选):

	cursor := p.cfg.Cursor
	if cursor == "" {
		cursor = "▌"
	}
	editInterval := p.cfg.EditInterval
	if editInterval <= 0 {
		editInterval = 800 * time.Millisecond
	}

生成过程中,末尾带着一个「▌」光标,表示还在写;默认每 800 毫秒更新一次,既能看到进度,又不会因为更新太频繁被平台限流。写完的最后一次更新会把光标去掉。

不支持编辑消息时,换一种发法:有的平台攒够 80 个字就先发出一段;还有的平台会先回一句「⏳ 消息已收到,正在思考中…」,让你知道消息收到了,等内容生成好再一次发出去。

实测:把这几步跑一遍

我在 WES Code 用的引擎里写了两个测试:一个用飞书事件的真实格式构造了四条消息,交给飞书适配器转换;另一个直接调用分发器里的几步规则。测试文件用 -overlay 临时放进引擎里跑,不往仓库写文件:

go test ./adapter/channel/feishu ./internal/imchannel -run '^TestZZ' -count=1 -v -overlay /tmp/imdemo/overlay.json

四条飞书消息的转换结果:

多人会话里的文字:ChatType=group ChatID=oc_team UserID=ou_alice ThreadID="" Text="帮我看下 CI 为什么挂了" 待下载=<nil>
单聊的富文本:ChatType=dm ChatID=oc_dm_bob UserID=ou_bob ThreadID="" Text="这段报错:nil pointer dereference" 待下载=<nil>
话题里的图片:ChatType=group ChatID=oc_topic UserID=ou_alice ThreadID="omt_7" Text="{\"image_key\":\"img_v3_abc\"}" 待下载=[{img_v3_abc image }]
机器人自己发的:忽略,不进入对话

单聊里那条富文本,原本是两段分开的文字,转换后拼成了一句;话题里的图片,记下了图片的标识等着下载,同时带上了话题的 id;机器人自己发的那条被直接忽略了。

分发器的几条规则。 第二个测试依次做了五件事:用同一个多人会话里 Alice、Bob 的消息,在整个会话共用、按成员分开、按话题分开、固定交给代码审查员几种设置下算对话标识;配了四条绑定规则(全局默认编程助手、飞书默认架构师、飞书的 oc_review 会话给代码审查员、钉钉默认测试工程师),看四个会话各落到谁;判断五句话是不是命令;让 Alice 一分钟内连发 12 条;最后扫描四句话。输出如下:

单聊 => feishu:dm:oc_dm_alice
多人会话里 Alice,整个会话共用 => feishu:group:oc_team
多人会话里 Bob,整个会话共用 => feishu:group:oc_team
多人会话里 Alice,按成员分开 => feishu:group:oc_team:ou_alice
多人会话里 Bob,按成员分开 => feishu:group:oc_team:ou_bob
话题里 Alice,按话题分开 => feishu:thread:omt_7
会话固定交给代码审查员 => feishu:group:oc_team::builtin-reviewer
feishu 的 oc_review => builtin-reviewer(得分 2)
feishu 的 oc_team => builtin-architect(得分 3)
dingtalk 的 cid_ops => builtin-test(得分 3)
wecom 的 wr_sales => builtin-coder(得分 4)
"/new" 是命令吗:true
"/stop 先别跑了" 是命令吗:true
"/reset" 是命令吗:true
"/newbie 是什么意思" 是命令吗:false
"帮我 /new 一个分支" 是命令吗:false
Alice 一分钟内连发 12 条:放行 10 条,拦下 2 条;Bob 接着发一条:true
"帮我看下 CI 为什么挂了" => []
"数据库配置 password = \"q7Lm2Xv9Rt4Np8Ws3Kd6\"" => [hardcoded_secret]
"看看\u200b这个" => [invisible_unicode_U+200B]
"请求头 Authorization: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl" => [bearer_token]

几处对一下:整个会话共用时,Alice 和 Bob 落在同一段对话里;按成员分开后,两人各有一段。四条绑定规则里,指定了会话 oc_review 的那条得分最低、最优先;wecom 那条消息所在的平台没有专门的规则,落到了全局兜底的编程助手。Alice 连发 12 条,前 10 条放行,后 2 条被拦,Bob 不受影响。最后的扫描里,普通的问题什么都没命中,写死的密码、零宽字符、请求头里的令牌都被认了出来。

接进来以后,怎么用更顺手

上面这些规则,落到日常使用上,有几条值得记住:

  • 先单聊,再拉进多人会话:单聊是一人一段对话,最适合先试一试它答得靠不靠谱;确认没问题,再拉进团队的会话里
  • 一个会话一个用途:多人会话默认整个会话共用一段对话,前面聊过的它都记得。代码审查和运维分成不同的会话,各自绑定合适的助手,上下文就不会串
  • 聊偏了就 /new:同一段对话聊得太长、话题换了,发一句 /new 从头开始,比在旧对话里反复纠正省事
  • 别一口气刷屏:每人每分钟 10 条,超出的会被安静丢掉。想让它做好几件事,写在一条消息里分点说清楚,比连发十几条更有效
  • 贴报错直接贴文字或截图:文字、富文本、图片、文件都能收,图片和文件会先下载到本地,把本地路径附在消息里交给 AI

写在最后

把 WES Code 接进飞书以后,找 AI 干活就和找同事一样:在会话里说一句,它就开始看。引擎在中间替你把了几道关:认清是谁发的、接着哪段对话、交给哪位助手、发得太快就拦一拦、外部来的消息单独标记,回复也会一边生成一边更新。先从单聊开始试,确认它答得靠谱,再拉进团队的会话里。

文中的 IM 渠道是 WES Code 里的功能,官网是 weisyn.com。你们团队平时最常用哪个聊天工具,欢迎评论区聊聊。

觉得有用的朋友,欢迎点赞、收藏、关注,后面会继续分享 AI 编程的实战经验。