npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@larksuite/channel

v0.4.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.

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.botIdentityconnect() 后可用)。

一键扫码注册 — 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 });

RegisterAppOptionsonQRCodeReady(必填)· 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 握手超时 | | 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 |

PolicyConfigrequireMention · dmMode'open' \| 'allowlist' \| 'pair' \| 'disabled')· dmAllowlist · groupAllowlist · respondToMentionAll · botLoopGuard(见 Bot-at-bot)。dmAllowlist发送方 idou_… / user_id / union_id),groupAllowlist群 idoc_…)——应用 id(cli_…)两者都不属于,填了会告警。

SafetyConfigdedupttl/maxEntries/sweepIntervalMs)· chatQueueenabledmergeWhileBusy)· 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 串行执行(排在该 chat 在途工作之后)。 耗时 handler 会让响应延迟、甚至超过 Feishu 回调超时——重活仍建议 detach 到后台、 用卡片更新反映进度。
  • 对象会原样发给 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') | 评论表情 |

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 | 超时 / 未连接 / 其它 |

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 在同一群里协作(互相 @ 接力)需要一些额外信号与守卫。以下能力默认全部关闭、 需要时才手动打开,且都是新增功能——不开就跟现在的行为完全一样,不会影响你已有的代码。

分清谁发的。 每条 messagesenderType'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 但没打任何字时, 这条消息照常投递(不会被当成空消息丢弃):mentionedBottruecontent 为空。 用 mentionedBot && !content.trim() 就能识别这种「只戳一下 bot」的情况。

回到正确位置。channel.reply(msg, input) 代替手算回复目标。它回复 msg,并 跟随触发消息本来的形态replyTo 默认取 msg.messageIdreplyInThread 默认取 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