@larksuite/channel
v0.7.1
Published
Channel SDK — let agents and external services integrate with the Feishu/Lark messaging system: reliable inbound events, message normalization, streaming replies, media upload, card interactions.
Downloads
23,078
Readme
@larksuite/channel
English | 简体中文
Channel SDK —— 让 agent 或外部服务顺畅地集成飞书消息系统:一行 import 就能拿到
一个能可靠收发消息、归一化事件、流式回复、上传媒体、响应卡片按钮的集成实例,而不必
关心 WebSocket 状态、十几种 msg_type 分支、@-mention placeholder 怎么拼。
它构建在 @larksuiteoapi/node-sdk 之上,
对外只暴露一个入口,使用者不再需要直接 import node-sdk。
安装
npm install @larksuite/channel
# 或:pnpm add @larksuite/channel最小可运行示例
import { createLarkChannel } from '@larksuite/channel';
const channel = createLarkChannel({
appId: process.env.LARK_APP_ID!,
appSecret: process.env.LARK_APP_SECRET!,
});
channel.on('message', async (msg) => {
await channel.send(
msg.chatId,
{ markdown: `received: ${msg.content}` },
{ replyTo: msg.messageId },
);
});
await channel.connect();不需要管 WS 怎么连、不需要管事件怎么解析、不需要管引用消息怎么展开。
能力清单
- L1 传输:WS 长连接 / 自动重连 / 心跳保活 / 握手超时 / webhook 模式
- L2 归一化:NormalizedMessage / @-mention 处理 / merge_forward 展开 / card / reaction / comment / botAdded 归一化
- L3 策略与安全:requireMention / 白名单 / 去重 / 过期丢弃 / 按 chat 串行
- L4 出站:send(11 种 input)/ 流式打字机卡片 / updateCard / reaction / 媒体上传(含 SSRF 防护)/ 自动回退
API
入口
| API | 说明 |
|---|---|
| createLarkChannel(opts: LarkChannelOptions): LarkChannel | 工厂函数(推荐) |
| new LarkChannel(opts) | 类形式,等价 |
只读实例成员:channel.comments(评论 surface)、channel.rawClient(底层 Client,逃生通道)、channel.rawWsClient(底层 WSClient)、channel.botIdentity(connect() 后可用)。
一键扫码注册 — registerApp
通过二维码设备码流程引导出一个 app 的 appId / appSecret(无需预先有凭据)。onQRCodeReady 回调里拿到二维码 URL,用户扫码创建/授权 app 后,resolve 出凭据,直接喂给 createLarkChannel。
import { registerApp, createLarkChannel } from '@larksuite/channel';
const { client_id, client_secret } = await registerApp({
onQRCodeReady: ({ url, expireIn }) => console.log('扫码注册:', url),
onStatusChange: (s) => console.log('状态:', s.status),
});
const channel = createLarkChannel({ appId: client_id, appSecret: client_secret });RegisterAppOptions:onQRCodeReady(必填)· onStatusChange? · appPreset?(预填 app 名称/描述/头像)· domain? / larkDomain? · signal?(AbortSignal)· source? —— 可选的来源标识,拼进二维码 URL 的 source/<name>(原样透传,不设默认)。
构造参数 LarkChannelOptions
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| appId / appSecret | string | — | 必填 |
| transport | 'websocket' \| 'webhook' | 'websocket' | 传输方式 |
| webhook | WebhookOptions | — | webhook 模式配置 |
| policy | PolicyConfig | — | 谁能触发 bot(入站策略) |
| safety | SafetyConfig | — | 去重 / 过期 / 按 chat 串行 / 批合并 |
| outbound | OutboundConfig | — | 出站行为(分片、流式、SSRF、重试) |
| resolveChatMode | boolean | false | 填充 NormalizedMessage.chatMode(每 chat 一次 cached chat.get) |
| resolveSenderNames | boolean | false | 从群成员 roster 填充 NormalizedMessage.senderName(每 chat 一次 cached getChatMembers) |
| resolveChatMembers | (chatId) => ChatMember[] \| undefined \| Promise<…> | — | 覆写 getChatMembers 的 roster 来源(返回 undefined 回落 API) |
| keepalive | { enabled; onUnrecoverable?; intervalMs? } | — | 连接保活看门狗(仅 WS) |
| respectProxyEnv | boolean | false | 读 HTTPS_PROXY / HTTP_PROXY,WS + REST 都走代理 |
| httpTimeoutMs | number | — | REST 调用超时 |
| agent | http(s).Agent | — | 自定义 WS agent(优先于 respectProxyEnv) |
| handshakeTimeoutMs | number | — | 单次 WS 握手尝试的超时 |
| connectTimeoutMs | number | 15000 | connect() 最多等多久连上,超时即放弃(强制重连同样适用) |
| wsConfig | WSConfigOverrides | — | WS 客户端设置(pingTimeout) |
| domain | Domain \| string | Feishu | 飞书 / Lark 域名 |
| cache | Cache | 内置 | 缓存实例(去重 / 凭据) |
| logger / loggerLevel | Logger / LoggerLevel | info | 日志 |
| httpInstance | HttpInstance | 共享默认 | 自定义 HTTP 实例(自带时 timeout/代理由你自行配置) |
| source | string | — | User-Agent 标记 |
| includeRawEvent | boolean | false | 每个事件附带原始载荷 evt.raw |
升级到 0.6.1 注意:
handshakeTimeoutMs不再决定强制重连最多等多久,这件事现在 由connectTimeoutMs(默认15000)负责。如果你此前是靠调小handshakeTimeoutMs来缩短强制重连的等待,请把同样的数值显式设到connectTimeoutMs,才能保持原来的行为。handshakeTimeoutMs仍然管单次握手的超时,也照旧转发给底层传输层。
PolicyConfig:requireMention · dmMode('open' \| 'allowlist' \| 'pair' \| 'disabled')· dmAllowlist · groupAllowlist · respondToMentionAll · botLoopGuard(见 Bot-at-bot)。dmAllowlist 填发送方 id(ou_… / user_id / union_id),groupAllowlist 填群 id(oc_…)——应用 id(cli_…)两者都不属于,填了会告警。
SafetyConfig:dedup(ttl/maxEntries/sweepIntervalMs)· chatQueue(enabled、mergeWhileBusy、cardActions)· batch.text / batch.media · staleMessageWindowMs。
生命周期
| 方法 | 签名 | 说明 |
|---|---|---|
| connect | connect(): Promise<void> | 建连;WS 首次握手成功后 resolve |
| disconnect | disconnect(): Promise<void> | 断连并清理 |
| getConnectionStatus | (): WSConnectionStatus \| undefined | 连接快照(webhook 模式 / 未连时为 undefined) |
事件 — channel.on(name, handler)
on('message', fn) 订阅单事件,或 on({ message, cardAction }) 批量;返回取消订阅函数。
| 事件 | 回调参数 | 触发时机 |
|---|---|---|
| message | NormalizedMessage | 收到(已过策略/安全/批合并的)消息 |
| cardAction | CardActionEvent | 卡片按钮 / 表单提交(handler 可返回 CardActionResponse,见下) |
| reaction | ReactionEvent | 消息表情增删 |
| botAdded | BotAddedEvent | bot 被加入群 |
| comment | CommentEvent | 云文档评论 @bot |
| reject | RejectEvent | 消息被策略拒绝(reason) |
| error | LarkChannelError | 内部错误 |
| reconnecting / reconnected | () | WS 重连生命周期 |
interface NormalizedMessage {
messageId: string;
chatId: string;
chatType: 'p2p' | 'group';
chatMode?: 'p2p' | 'group' | 'topic'; // 需 resolveChatMode
senderId: string;
senderName?: string; // 需 resolveSenderNames
senderType?: string; // 'user' | 'bot' | 'system' | 'anonymous'(透传自原始事件;缺失则 undefined)
senderIsBot?: boolean; // senderType === 'bot' 时为 true;senderType 缺失时为 undefined
content: string; // 归一化后的可读内容
rawContentType: string; // 原始 msg_type
resources: ResourceDescriptor[];
mentions: MentionInfo[];
mentionAll: boolean;
mentionedBot: boolean;
rootId?: string;
threadId?: string;
replyToMessageId?: string;
createTime: number;
raw?: unknown; // includeRawEvent 时附带
}
interface CardActionEvent {
messageId: string; chatId: string;
operator: { openId: string; userId?: string; name?: string };
action: { value: unknown; tag: string; name?: string; option?: string; formValue?: Record<string, unknown> };
}
interface ReactionEvent { messageId: string; operator: { openId: string; userId?: string }; emojiType: string; action: 'added' | 'removed'; actionTime?: number; }
interface BotAddedEvent { chatId: string; operator: { openId: string; userId?: string }; botName?: string; external?: boolean; }
interface CommentEvent { fileToken: string; fileType: string; commentId: string; replyId?: string; operator: { openId: string; userId?: string; unionId?: string }; mentionedBot: boolean; timestamp: number; }
interface RejectEvent { messageId: string; chatId: string; senderId: string; reason: RejectReason; }
type RejectReason = 'group_not_allowed' | 'sender_not_allowed' | 'no_mention' | 'dm_disabled' | 'mention_all_blocked' | 'bot_loop';卡片回调响应
cardAction handler 可返回一个 CardActionResponse,给点击用户原生的即时反馈
——最常见是 toast,无需更新整张卡片:
channel.on('cardAction', async (evt) => {
await handleAction(evt);
return { toast: { type: 'success', content: '已提交' } };
// 或就地更新卡片:{ card: { type: 'raw', data: { ... } } }
});返回的对象会原样回传给 Feishu/Lark 作为该次点击的回调响应。不返回(undefined)
即「无即时响应」——与旧行为一致,现有 handler 无需改动。
注意:
- 响应是同步回传,且无论哪种模式,同一 chat 内的点击都按到达顺序执行。耗时
handler 会让响应延迟、甚至超过 Feishu 回调超时——重活仍建议 detach 到后台、用卡片
更新反映进度。
cardActionhandler 里不要 await 同一个 chat 的后续点击:那次点击 排在它自己后面。多步确认应由messagehandler 持有。 - 默认(
safety.chatQueue.cardActions: 'same')卡片动作与消息共用该 chat 的队列:点击 排在该 chat 在途工作之后,之后到达的消息也会等它。 cardActions: 'separate'给卡片动作一条独立的 per-chat 队列,与消息队列双向互不 等待:点击不再排在在途的messagehandler 后面,消息也不等点击。适用于messagehandler 需要等待卡片点击的场景——比如 agent 一轮里请求工具审批;在'same'下这是 死锁。此时应用需要自己负责:- 用
evt.operator.openId核验操作者——群里任何人都能点。 message与cardAction两个 handler 共享的应用侧 per-chat 状态不再由 SDK 串行保护,二者现在是并发的。- 给等待点击的
messagehandler 设超时,否则该 chat 的消息队列会一直被占住。 - 点击顺序只在单进程内成立。多实例部署仍要对挂起的审批做幂等、首个决定生效式的 结算。
- 用
- 对象会原样发给 Feishu:勿放内部 secret / PII,且须可被 JSON 序列化。
出站方法
| 方法 | 签名 | 说明 |
|---|---|---|
| send | send(to: string, input: SendInput, opts?: SendOptions): Promise<SendResult> | to 支持 open_id / chat_id / user_id(自动识别) |
| reply | reply(msg, input: SendInput, opts?: SendOptions): Promise<SendResult> | 回复收到的消息——默认 replyTo 指向它、原本在话题内则留话题内(Bot-at-bot) |
| stream | stream(to, input: StreamInput, opts?): Promise<SendResult> | 流式回复 |
| updateCard | updateCard(messageId, card): Promise<void> | 整卡更新 |
| editMessage | editMessage(messageId, text): Promise<void> | 编辑 text/post |
| recallMessage | recallMessage(messageId): Promise<void> | 撤回 |
| addReaction | addReaction(messageId, emojiType): Promise<string> | 加表情,返回 reaction_id |
| removeReaction | removeReaction(messageId, reactionId): Promise<void> | 按 id 删 |
| removeReactionByEmoji | removeReactionByEmoji(messageId, emojiType): Promise<boolean> | 删 bot 自己的 |
| downloadResource | downloadResource(messageId, fileKey, type): Promise<Buffer> | 下载收到的消息里的媒体;type: 'image' / 'file' |
| getChatInfo | getChatInfo(chatId): Promise<ChatInfo> | 群信息 |
| getChatMode | getChatMode(chatId): Promise<'p2p' \| 'group' \| 'topic'> | 群模式 |
| getChatMembers | getChatMembers(chatId, opts?): Promise<ChatMember[]> | 群成员(仅用户——飞书过滤 bot),翻页 + 缓存(Bot-at-bot) |
| getChatBots | getChatBots(chatId, opts?): Promise<ChatMember[]> | 群内机器人(isBot: true);缓存;写入 roster,使 bot 可按名字 @(Bot-at-bot) |
| getBotIdentity | getBotIdentity(): BotIdentity | 本 bot 自身 { openId, name };connect() 前调用抛 not_connected |
| fetchMessage | fetchMessage(messageId): Promise<NormalizedMessage \| undefined> | 取并归一化某条消息 |
type SendInput =
| { markdown: string } | { text: string } | { post: object }
| { image: { source: string | Buffer } }
| { file: { source: string | Buffer; fileName: string } }
| { audio: { source: string | Buffer; duration?: number } }
| { video: { source: string | Buffer; duration?: number; coverImageKey?: string } }
| { card: object }
| { shareChat: { chatId: string } } | { shareUser: { userId: string } }
| { sticker: { fileKey: string } };
interface SendOptions { replyTo?: string; replyInThread?: boolean; mentions?: MentionInfo[]; resolveMentionsInText?: boolean; }
interface SendResult { messageId: string; chunkIds?: string[]; }
type StreamInput =
| { markdown: (c: MarkdownStreamController) => Promise<void> } // c.append(chunk) / c.setContent(full)
| { card: { initial: object; producer: (c: CardStreamController) => Promise<void> } }; // c.update(next)媒体 source 支持 URL / 本地路径 / Buffer 三种,内置 SSRF 防护。
运行期策略
| 方法 | 签名 | 说明 |
|---|---|---|
| updatePolicy | updatePolicy(partial: Partial<PolicyConfig>): void | 热改策略(部分合并,立即生效) |
| getPolicy | getPolicy(): Readonly<PolicyConfig> | 读取当前策略 |
云文档评论 — channel.comments
| 方法 | 签名 | 说明 |
|---|---|---|
| resolveTarget | resolveTarget(fileToken, fileType): Promise<CommentTarget \| null> | wiki 节点 → obj_token;不支持的类型返回 null |
| fetch | fetch(target, commentId): Promise<FetchedComment \| null> | .get 失败自动回退 .list 翻页 |
| reply | reply(target, commentId, text): Promise<void> | 整文档评论拒绝时回退为新顶层评论 |
| addReaction / removeReaction | (target, replyId, emojiType = 'Typing') | 评论表情 |
会议通道 — channel.joinMeeting / channel.followMyMeeting
两个入口分别对应用户身份与应用身份,均返回 MeetingSession,其事件与方法完全一致。
| 方法 | 签名 | 身份 |
|---|---|---|
| followMyMeeting | (opts: FollowMeetingOptions): Promise<MeetingSession> | User access token。跟随 token 持有者当前所在的会议,会议中不出现任何机器人 |
| joinMeeting | (meetingNo: string, opts?: JoinMeetingOptions): Promise<MeetingSession> | 应用(tenant)凭据。Bot 作为真实参会者入会 |
| getMeetingEventHealth | (): MeetingEventHealth | 会中事件链路的诊断数据 |
| getRetainedMeetings | (): MeetingMembership[] | Bot 仍在会中、但没有会话在监听的会议 |
// 用户身份 —— 不需要 connect(),这条路径只走 REST 轮询
const m = await channel.followMyMeeting({
userAccessToken: () => myAuth.freshToken(), // 每轮轮询前现取
stabilizeMs: 800, // 一句说完再投递
});
// 应用身份 —— 需要 connect(),它依赖事件推送
channel.on('meetingInvited', async (invite) => {
const m = await channel.joinMeeting(invite.meetingNo, { callId: invite.callId });
m.on('chat', async ({ content, selfEcho }) => {
if (selfEcho) return; // 见下面「自己跟自己对话」
await m.sendMessage(`收到:${content}`);
});
});| 会话事件 | 载荷 | 说明 |
|---|---|---|
| transcript | { actor, text, sentenceId, language, startMs, endMs, selfEcho } | 字幕。同 sentenceId 的后续投递为覆盖,需要会议已开启字幕 / 转写 |
| chat | { actor, content, messageId, messageType, sendTime, selfEcho } | 会中聊天。Bot 自己发出的消息也会以此回流,selfEcho 为 true |
| participant | { actor, action: 'joined' \| 'left', joinTime, leaveTime, leaveReason } | 参会人进出。leaveReason 为平台原始取值 |
| share | { actor, action: 'started' \| 'ended', shareId, doc, time } | 文档共享开始 / 结束。文档切换会在一次推送内成对出现,顺序即语义 |
| documentContext | { contextType, commentFocus \| sectionLocation \| elementPreview, … } | 共享文档内的上下文变化(评论聚焦 / 章节定位 / 元素预览)。只带标识,不带正文或素材 |
| end | { meetingId, reason } | 会话结束。reason 取值见 MeetingEndReason:meeting_ended / no_longer_active / idle_timeout / error / left / disposed |
| error | LarkChannelError | 会话内的错误(轮询失败、拆包异常、handler 抛错)。未注册该 handler 时降级为日志,不会打挂进程 |
session.on() 是多播(与单槽的 channel.on() 不同):同一事件可注册多个 handler,返回的退订函数只摘掉自己注册的那一个。
| 方法 | 说明 |
|---|---|
| sendMessage(text) | 发会中消息。跟随模式下抛 not_supported —— Bot 并不在会议里 |
| leave() | 离会并归还并发槽位,然后结束会话。幂等;接口失败也照样完成回收,且会话已结束之后仍然有效 |
| dispose() | 只停定时器与订阅,不离会。幂等 |
| getStats() | 本会话按 activity 类型的解析计数 |
语义说明
dispose() 与 leave() — dispose() 停止本会话的定时器与事件订阅,不调用任何接口,Bot 仍留在会议中。leave() 调用 bots/leave 使 Bot 退出会议、归还并发槽位,然后结束会话;在会话已结束之后(包括 dispose() 或 disconnect() 之后)仍然可以调用。disconnect() 对所有活跃会话执行 dispose()。
disconnect() 之后需要重新 attach —— disconnect() 只 dispose 会话、不退出会议,Bot 仍留在会中;之后再 connect() 会重新注册事件 handler,但不会重建会话,这些会议的推送随即被丢弃。会话以 reason: 'disposed' 结束作为信号。getRetainedMeetings() 以 { meetingId, meetingNo } 列出这些会议;用 joinMeeting(meetingNo) 重新 attach(对已持有的会议不占用新的并发槽位),或在拿到新会话后调 leave() 归还槽位。放着不处理,Bot 会一直留在会议里收不到事件、并占着槽位,直到会议结束。
meeting.idleTimeoutMs — 空闲回收阈值(毫秒),默认 0 即关闭。设为正值时,会话在该时长内未收到任何会中活动事件即结束、调用 bots/leave 离会并归还槽位。仅作用于应用身份会话。
meeting.livenessProbeIntervalMs — 探活间隔(毫秒),默认 300000。周期性确认 Bot 仍在会议中,用于覆盖不产生 meeting_ended_v1 的场景(被移出会议、会议转让)。仅作用于应用身份会话。
meeting.maxConcurrentSessions — 并发会话上限,默认 32。达到上限时 joinMeeting() 抛 too_many_sessions,且不会发出 bots/join 请求。计数按「已入会且未离会」的会议统计,因此 disconnect() 不释放计数、leave() 才释放。
meeting.sendRateLimitPerMinute — 每会话每分钟的会中消息上限,默认 20。超出时 sendMessage() 抛 rate_limited。
selfEcho — 标记该条目由本 Bot 产生:Bot 发出的会中消息会以 chat 回流,其发言经会议转写后进入 transcript。带该标记的条目照常投递,由调用方决定是否忽略。Bot 自身 open_id 尚未解析完成时取 true;跟随模式下恒为 false。
投递顺序 — 同一次推送内的条目按数组顺序串行投递,异步 handler 的返回值会被 await 后再投递下一条。
stabilizeMs — 字幕定稿防抖窗口(毫秒),默认 0。0 时每次文本变化都投递;正值时同一 sentenceId 在该时长内没有新内容才投递一次。同 sentenceId 的后续投递为覆盖语义,调用方应按其 upsert。
「后续」以 endMs 判定,而非以到达顺序:一个会话同时从事件推送和探活的 REST 读取摄入,因此同一句话的较早、较短版本可能最后到达。定稿窗口内,endMs 比已持有版本更旧的投递会被忽略;相等时保持「后到者覆盖」,因为转写纠错会在不延长这句话的前提下改写文本。stabilizeMs: 0 时没有缓冲可比较,顺序由调用方自行处理。
getMeetingEventHealth() — 按两条入站链路分别返回计数,形状为 { push, poll }。两者都带 received(已收到的活动数)、lastAt,以及按 activity 类型统计的 { received, empty };empty 指该类型的活动拆包后条目数为 0,用于区分「平台未推送」与「已推送但未能解析」。
push 另外带 registered(channel 内部的 vc.bot.* handler 是否已注册,由 connect() 置上)以及未注册时的 reason。registered 描述的是注册状态而非连接状态:WebSocket 断开重连期间它仍为 true。poll 另外带 sessions,即存活的跟随会话数,因此 received: 0 且 sessions: 0 表示「没有可轮询的对象」,而不是故障。
两条链路分开计数,因为它们独立失效——推送停了而轮询照常工作是可能的,合成一个总数会让其中一条的流量替另一条背书。划分依据是传输方式而非会话身份:应用身份会话的探活走 REST 读取,因此它补读到的活动计入 poll。
跟随模式的可见性 — 跟随模式不入会,参会者列表中不出现机器人,同时可读取全部参会者的发言。告知参会者并取得同意由接入方负责,SDK 不代为提示。
示例:examples/10-meeting-follow.ts、examples/11-meeting-join.ts。
未封装事件 — channel.onRawEvent
const off = channel.onRawEvent('vc.bot.meeting_started_v1', (payload) => { … });
off(); // 只移除这一个 handler这个 API 做什么 — 按飞书事件类型名注册一个回调,回调拿到的是解密后的原始事件 payload(平台发来的样子)。同一个事件类型可以注册多个回调,彼此不覆盖;返回值用于移除刚注册的那一个。
它解决什么问题 — channel 只封装了固定几类事件:IM 消息、卡片回调、表情回复、机器人入群、云文档评论,以及会议通道所依赖的三个会议推送(vc.bot.meeting_invited_v1、_activity_v1、_ended_v1)。其余类型 —— 审批、日历、通讯录变更,以及上面示例里的 vc.bot.meeting_started_v1 —— 都没有自己的入口。两种绕法都不好:直接写 dispatcher 的私有 handler map,版本一升就坏;为同一个应用再开一条长连接,飞书会把事件在两条连接之间分流投递,channel 自己的 IM 消息就会时有时无。onRawEvent 让这些事件类型走 channel 已经持有的那条连接。
需要关注的副作用 — 回调收到的是未经处理的事件,channel 的入站防护对它不生效。验签与解密仍然会做(那在更前面一层),但归一化之后的每一步都跳过了:PolicyGate(dmMode / dmAllowlist / groupAllowlist / requireMention)、去重、按 chat 串行的处理锁、防回环、过期事件丢弃。具体来说:
- 给 channel 已经封装的事件类型注册 raw handler,等于为它开了第二条不设防的入口。最需要留意的是
im.message.receive_v1:内建路径上被白名单拒掉的消息,raw handler 照样收得到。 - payload 不做脱敏,也不受
includeRawEvent: false影响 —— 里面有tenant_key、完整用户 ID、消息原文。要打日志或转发给第三方,得自己处理。 - raw handler 是纯观察者:它的返回值一律被丢弃(签名即
=> void | Promise<void>),因此改不了回给飞书的内容,但会推迟这个内容发出的时间。一次事件的处理顺序是:内建 handler 跑完 → 你的 raw handler 逐个跑完 → 才向飞书返回响应。响应内容只可能来自内建 handler;未封装的事件类型没有内建 handler,回给飞书的就固定是"无响应"—— 也就是说onRawEvent无法用来给飞书回内容。多数事件类型不受影响(飞书只要一个确认)。但card.action.trigger的响应内容就是用户点按钮后看到的结果(如 toast、卡片更新),飞书对它有超时限制 —— raw handler 里做一次耗时几秒的请求,内建 handler 早就把正确结果算好了,也会因为迟迟发不出去而让用户看到操作失败。所以这个事件类型上的 raw handler 要立即返回,真正的活儿交给队列异步做。
normalize 工具函数(高级)
normalize / normalizeCardAction / normalizeReaction / normalizeBotAdded / normalizeComment —— 把原始 Feishu 事件载荷归一化,供自定义传输或单测使用。normalize 必返回结果;其余 4 个在缺少必需身份字段时返回 null。
错误处理 — LarkChannelError
出站 / 连接失败统一 reject 出 LarkChannelError,带稳定 code:
| code | 含义 |
|---|---|
| format_error | 内容格式错误(已尝试降级纯文本) |
| target_revoked | 回复目标已撤回(已尝试去 replyTo 重发) |
| rate_limited | 触发限流 |
| permission_denied | 权限 / 鉴权失败 |
| upload_failed / ssrf_blocked | 媒体上传失败 / URL 被 SSRF 拦截 |
| send_timeout / not_connected / unknown | 超时 / 未连接 / 其它 |
| not_supported | 当前模式下不可用(如对「跟随」的会议调 sendMessage) |
| meeting_not_found | 没有可跟随的活跃会议,或目标会议已不活跃 |
| too_many_sessions | 达到 meeting.maxConcurrentSessions |
权限不足时,会议链路可能在 context.consoleUrl 上带回飞书返回的带签名一键授权链接。请当凭据对待:它逐字节原样透传(重新编码会让签名失效),非 https: 取值会被直接丢弃。SDK 自身不会把它写进日志,但也不清洗日志 —— 日志安全(含 node-sdk 对失败请求写出的内容)由接入方负责。把它交给运维,不要回显到聊天、前端或工单里。
try {
await channel.send(chatId, { markdown });
} catch (e) {
const err = e as LarkChannelError;
console.log(err.code, err.message, err.context); // err.cause 是原始错误
}入站 handler 内部抛的错不会冒泡到你的
await,而是统一进error事件。
Bot-at-bot
多个 bot 在同一群里协作(互相 @ 接力)需要一些额外信号与守卫。以下能力默认全部关闭、 需要时才手动打开,且都是新增功能——不开就跟现在的行为完全一样,不会影响你已有的代码。
分清谁发的。 每条 message 带 senderType('user' / 'bot' / …)和便捷布尔
senderIsBot,agent 能区分人、自己、别的 bot。用 getBotIdentity() 拿本 bot 身份写进
system prompt。开 resolveSenderNames 从群成员 roster 填 senderName。
收得到别的 bot 的事件。 除非应用开了 im:message.group_at_msg / include_bot
权限,飞书默认不投递「别的 bot @ 我」的事件——且失败静默。平台无自查 API;若
bot 间 @ 收不到,先确认该权限。只 @ 一下也能唤醒 bot。 有人 @ 了 bot 但没打任何字时,
这条消息照常投递(不会被当成空消息丢弃):mentionedBot 为 true、content 为空。
用 mentionedBot && !content.trim() 就能识别这种「只戳一下 bot」的情况。
回到正确位置。 用 channel.reply(msg, input) 代替手算回复目标。它回复 msg,并
跟随触发消息本来的形态:replyTo 默认取 msg.messageId,replyInThread 默认取
Boolean(msg.threadId)——触发消息在话题里就留在话题里,是平铺的就平铺回复。
| 触发消息 | 默认 replyInThread | 结果 |
|---|---|---|
| 话题群(每条消息都归属话题) | true | 回复落回同一个话题 |
| 普通群,平铺消息(不在任何话题里) | false | 普通引用式回复——不会开话题 |
| 普通群,消息本就在某话题里 | true | 回复留在那个已存在的话题里 |
reply() 只「跟随」触发消息,不会主动把平铺消息升级成话题。需要时用 opts 覆写:
channel.reply(msg, input, { replyInThread: true }); // 对平铺消息强制起一个话题
channel.reply(msg, input, { replyInThread: false }); // 在话题里也发普通回复按名字 @。 要 @ 回某人,要么传结构化 mentions(只带 { name } 会用群 roster 补
open_id),要么设 resolveMentionsInText: true 把 text/markdown 正文里的 @名字 归一。
名字来源 = getChatMembers(用户)+ getChatBots(机器人)+ 之前入站 mention 里观察到的
身份。因此按名字 @ 别的 bot 需先调一次 getChatBots(chatId) 预热,或依赖它已在群里露过面;
否则需显式传入它的 open_id。名字未知或被多个成员共用时,会原样保留为纯文本、不会误 @
——安全敏感的接力使用显式 open_id 更稳妥。
限定谁能触发 bot:按群名单,而不是逐个发送方。 发送方的 open_id 通常事先拿不到,靠
dmAllowlist 一个个列发送方并不现实。更省事的做法:用 groupAllowlist: ['oc_…'] 只允许
指定的群,再配 requireMention: true 要求 @ 才响应——这样就把 bot 圈定在这些群里,不必
关心具体是谁发的。
打断 ping-pong 死循环。 两 bot 可能互 @ 停不下来。默认关闭、需手动开启的
policy.botLoopGuard 只统计「别的 bot @ 我」的消息(人发言会清零),在滑动窗口内超阈值即命中:
policy: {
botLoopGuard: {
enabled: true,
windowMs: 60_000, // 滑动窗口 W
maxBotMentions: 5, // W 内到 N 条 bot @ 即命中
scope: 'chat', // 或 'chat+sender'
onTrip: 'reject', // 'drop'(默认)静默停回;'reject' 触发 reject 事件
},
}按业务节奏调 windowMs / maxBotMentions——设太低会误伤正常高频接力。默认
onTrip: 'drop' 会静默停回(仅首次命中打一条 warn);需要感知被静默时用 'reject'
(触发 reason: 'bot_loop' 的 reject 事件)。这是启发式兜底,非协议级保证。
License
MIT
