Skip to content

feat(channels): add Feishu (Lark) channel adapter#4379

Merged
yiliang114 merged 16 commits into
QwenLM:mainfrom
yuanyuanAli:feat/feishu-channel
May 28, 2026
Merged

feat(channels): add Feishu (Lark) channel adapter#4379
yiliang114 merged 16 commits into
QwenLM:mainfrom
yuanyuanAli:feat/feishu-channel

Conversation

@yuanyuanAli

Copy link
Copy Markdown
Contributor

Summary

  • Add Feishu (Lark) channel adapter with WebSocket/Webhook support
  • Interactive card streaming with real-time updates and stop button
  • Quote/reply context retrieval for both text and card messages
  • Concurrent message handling (per-message state isolation)
  • Documentation added at docs/users/features/channels/feishu.md

Fixes #4378

Test plan

  • Unit tests pass (19 tests in markdown.test.ts)
  • Full project build passes
  • Lint and format checks pass
  • Manual testing: DM, group chat, concurrent messages, quote/reply, stop button, image/file attachments

Demo

default.mp4

@yiliang114

Copy link
Copy Markdown
Collaborator

TQL

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new Feishu (Lark) channel adapter to Qwen Code, integrating Feishu messaging via WebSocket (default) or webhook, with interactive-card streaming, quoting/reply context retrieval, attachments support, and accompanying documentation.

Changes:

  • Added @qwen-code/channel-feishu package implementing a Feishu adapter (WS/Webhook), streaming interactive cards (incl. stop button), quote context, and media download handling.
  • Registered the new channel in the CLI built-in registry and workspace package wiring (workspace + lockfile).
  • Added end-user setup documentation for Feishu channel configuration and operation.

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/vscode-ide-companion/NOTICES.txt Updates third-party notices; currently adds a new entry that appears unresolved.
packages/cli/src/commands/channel/channel-registry.ts Registers Feishu as a built-in channel plugin via dynamic import.
packages/cli/package.json Adds CLI dependency on the new Feishu channel workspace package.
packages/channels/feishu/vitest.config.ts Adds Vitest configuration for Feishu package tests.
packages/channels/feishu/tsconfig.json Adds TS build configuration for Feishu package output.
packages/channels/feishu/src/media.ts Implements Feishu Open API resource download helper for images/files.
packages/channels/feishu/src/markdown.ts Implements card rendering helpers (table splitting, chunking, titles).
packages/channels/feishu/src/markdown.test.ts Adds unit tests for markdown/card utilities.
packages/channels/feishu/src/index.ts Exposes Feishu adapter exports and provides the channel plugin entrypoint.
packages/channels/feishu/src/FeishuAdapter.ts Core Feishu channel adapter: WS/webhook event handling, streaming cards, stop action, quote context, attachments.
packages/channels/feishu/package.json Declares the new Feishu channel package and its dependencies.
package.json Adds Feishu channel package to the workspace list.
package-lock.json Updates lockfile for the new package and dependency graph changes.
docs/users/features/channels/feishu.md Adds end-user setup and feature documentation for the Feishu channel.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/vscode-ide-companion/NOTICES.txt
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/markdown.ts Outdated
@pomelo-nwu

Copy link
Copy Markdown
Collaborator

@yuanyuanAli 太强了!感谢你的贡献,我们 review 后加快合并

@pomelo-nwu

Copy link
Copy Markdown
Collaborator

@yuanyuanAli 接前面的承诺,这里给出完整的 review。

1. PR 描述中文翻译

概要

  • 新增飞书(Lark)channel adapter,支持 WebSocket / Webhook 两种接入方式
  • 交互式卡片流式更新,含实时刷新与「停止」按钮
  • 支持引用 / 回复上下文获取(文本消息与卡片消息均可)
  • 并发消息处理(按 message 维度做状态隔离)
  • 用户文档已添加至 docs/users/features/channels/feishu.md

Fixes #4378

测试计划

  • 单元测试通过(markdown.test.ts 19 个用例)
  • 完整工程 build 通过
  • Lint 与 format 通过
  • 人工验证:私聊 / 群聊 / 并发消息 / 引用回复 / 停止按钮 / 图片与文件附件

关联 issue #4378 正文:

支持 QwenCode 能够通过 channel 的方式打通到飞书,支持 WebSocket/Webhook、卡片流式、引用上下文等能力。

2. 核心诉求与动机

  • 诉求:在已有 telegram / weixin / dingtalk 三个 channel 之外,补齐飞书这个国内最重要的企业办公场景。
  • 触发点:issue Add Feishu (Lark) channel adapter #4378 与本 PR 是同一作者自报需求 + 拿出可工作实现一起提交。
  • 价值:国内 B 端办公三件套覆盖完整;卡片流式 + 停止按钮 + 引用上下文已与 dingtalk adapter 体验对齐。
  • 改动形式:新增独立 workspace 包 packages/channels/feishu(1353 行 adapter + 216 行 markdown 工具 + 62 行 media + 171 行单测 + 170 行用户文档),在 cli 的 channel-registry 里追加一行注册,复用 ChannelBase 抽象,没有动 core

3. 站在 Qwen Code 技术产品负责人角度的判断

结论:方向上接受,但当前版本不能直接 merge — 需要先完成 4 项小改动后再合,预计 1-2 个 reroll。

✅ 认可的部分

  • 战略契合度高。飞书是国内三件套的最后一块拼图,该做,作者抢先做了,是难得的高质量社区贡献
  • 架构纪律好。完全复用 ChannelBase + ChannelPlugin 抽象,入口是 import('@qwen-code/channel-feishu') 一行,没有为了飞书去动 core 或 base,没有制造跨 channel 耦合
  • 实现完整度超出社区贡献平均水位。WebSocket + Webhook 双模式、token 缓存、消息去重、卡片流式、停止按钮、引用上下文、@mention 检测、图片/文件下载、并发隔离、错误降级(卡片表格 fallback → 截断 fallback)—— 一次性都做了。
  • 用户文档质量高。170 行 feishu.md 含安装、权限、配置、Webhook 进阶、群聊、Troubleshooting;与现有 dingtalk.md 风格完全对齐。
  • Demo 视频已附。对 B 端 channel adapter 非常关键,避免 reviewer 必须自建飞书 app 才能验证。

❌ 不能直接 merge 的理由(按严重度排序)

  1. packages/vscode-ide-companion/NOTICES.txt 多出一条可疑条目:diff 里引入 `@qwen-code/sdk@undefined (License text not found.)`,与飞书无关,看起来是某个 NOTICES 自动生成脚本对 `packages/sdk-*` 处理失败的产物。要么单独提 fix PR 处理,要么从本 PR revert 这条改动 — channel PR 不该改一个跟 channel 无关、且明显有 bug 的法律合规文件。
  2. 文档侧边栏漏注册docs/users/features/channels/_meta.ts 没加 `feishu: 'Feishu'`,意味着 170 行写得很认真的文档在网站侧边栏看不到。一行修复,必须补。
  3. 核心交互逻辑零单测:1353 行 FeishuAdapter.ts 包含 4 个 Map 的状态机(`cardSessions` / `sessionToInboundMsg` / `msgToQuestion` / `msgToSenderName`)、stop button 时序、reply 取上下文、卡片创建/更新/finalize 的多个 fallback 分支 — 所有单测都集中在 171 行的 markdown.ts 工具函数上。不要求一次补齐完整集成测试(飞书 SDK mock 成本高),但至少要给以下三类高风险路径补 unit test
    • 「stop button 在 card 还在 creating 状态时被点击」
    • 「processMessage 抛错时各 Map 是否被 cleanupCard 回收」
    • 「dedup 在 5 分钟 TTL 边界的行为」
  4. 版本号不一致@qwen-code/channel-feishu 是 `0.15.10`,而同期 dingtalk / plugin-example 是 `0.15.11`。对齐到 `0.15.11` 再合,避免 release 流水线踩坑。

建议但不 block(可在 follow-up PR 处理)

  • 类型安全config as unknown as Record<string, unknown> 类强转应改为扩展 ChannelConfig 子类型(如 FeishuChannelConfig)。这是与 dingtalk 一致的「既有问题」,建议单独提一个 channel-base 类型增强 PR 一起治。
  • 日志:24+ 处 process.stderr.write 直接打 stderr,且 fetchMessageContent 把引用消息前 1000 字符直接落日志,有泄漏用户消息内容的风险(用户可能引用 .env、API key 截图等)。建议引入分级 logger 并把 message body 的日志降到 debug。这是全 channel 包通病,可统一治。
  • token 缓存非并发安全:首次未缓存时多个请求会并发刷 token,浪费但不致命。
  • CLI bundle 体积@larksuiteoapi/node-sdk 拖入 axios / protobufjs / qs / 3 个 lodash.* / ws;与 weixin / dingtalk 同病,不该让飞书 PR 单独背锅,但需列入后续 channel 加载策略讨论。

4. 潜在风险反问(收益和风险同等重要)

请额外回应这几点,确认都看过了再合:

  1. Webhook 模式的安全暴露面connectWebhook 直接 http.createServer().listen(port),依赖 verificationToken / encryptKey 校验签名,但任何能访问到该端口的请求都会触发 JSON.parsedispatcher.invoke。如果用户在生产机把 webhookPort 绑到 0.0.0.0,就是攻击面。问题:是否默认 bind 127.0.0.1 并在文档强调走 reverse proxy?至少加一条 troubleshooting 提醒 inbound IP 限制?
  2. 状态机泄漏的累计风险:4 个 Map + pendingUpdateTimer 回收只在 cleanupCard 一个出口dedup map 有 TTL 兜底,这 4 个 Map 没有。问题:长跑(连续 24h、1000+ 条消息)时这几个 Map 的 size 测过吗?要不要补一个保底 TTL 清理?
  3. 跨 channel 一致性 —— stop button 体验语义:飞书有「停止生成」,dingtalk / weixin / telegram 没有;用户在不同 channel 间会感觉「飞书版功能更全」,反而拉低其他 channel 的口碑。问题:是否在 follow-up 把 stop 抽到 ChannelBase 让其他 channel 都补上?
  4. 品牌站位 —— Qwen Code 适配飞书的对外解读:从开放生态角度这是好事;对外讲「打通国内所有主流办公平台」是强证据。问题:merge 后是否同步给百炼 / 集团相关 BD 同学打个招呼,避免外部解读偏差?(process 风险,不是技术风险)
  5. CLI 安装体积@larksuiteoapi/node-sdk ~1.4MB + axios / protobufjs / qs / 3 个 lodash 子包,对不使用飞书的用户也是强制下载。当前已有 3 个 channel,飞书是第 4 个,再下去呢? 问题:是否考虑把 channel 改为 optionalDependencies 或「按 settings.json 里出现的 type 才 require」的 lazy install 模式?本 PR 不要求解决,但希望参与讨论。

总结:方向很对、实现很扎实。麻烦先把 4 项 blocking 处理掉(NOTICES.txt 剥离 / _meta.ts 注册 / 核心状态机 3 类单测补齐 / 版本号对齐),然后 fast-track 合。剩下的类型安全 / 日志 / bundle 体积是历史债,会拉单独 issue 一起规划。👍

— 山果

@pomelo-nwu

Copy link
Copy Markdown
Collaborator

「问题:merge 后是否同步给百炼 / 集团相关 BD 同学打个招呼,避免外部解读偏差?」这AI一定是偷学了《大厂生存指南》🐶

@wenshao

wenshao commented May 21, 2026

Copy link
Copy Markdown
Collaborator

「问题:merge 后是否同步给百炼 / 集团相关 BD 同学打个招呼,避免外部解读偏差?」这AI一定是偷学了《大厂生存指南》🐶

可以对接微信,为什么不能对接飞书,阿里云很多产品也是支持飞书的。

Comment thread packages/cli/src/commands/channel/channel-registry.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
token,
);
if (media) {
const dir = join(tmpdir(), 'channel-files', randomUUID());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Downloaded files are written to tmpdir()/channel-files/ but never cleaned up — not after handleInbound completes, not in disconnect(), and not via any periodic sweep. Over time on a busy bot, this gradually exhausts disk space.

Consider cleaning up the temp directory after handleInbound resolves, or adding a periodic sweep in the existing dedupTimer callback to remove files older than N hours.

— qwen-latest-series-invite-beta-v36 via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

文件清理的正确时机是 bridge 读取完毕后,但 ChannelBase 目前没有 onFileConsumed 类生命周期回调。在 onPromptEnd时删文件不安全(bridge 可能仍在读取),需要对 channel-base 做架构改动。此外 dingtalk/weixin channel 的 media下载同样没有清理机制,是共性问题。

建议:单独开 issue 在 ChannelBase 层统一解决,增加文件消费完成回调后再各 channel 补清理逻辑。

Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
messageId: string,
fileKey: string,
resourceType: 'image' | 'file',
accessToken: string,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] downloadMedia has input validation, HTTP error handling, and a catch-all error handler, but no tests. The sibling weixin channel has media.test.ts (142 lines) covering equivalent functionality. A regression in URL construction or auth headers would silently return null, dropping attachments without user-visible feedback.

— qwen-latest-series-invite-beta-v36 via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

downloadMedia 依赖 fetch 访问飞书 Open API,测试需要引入 HTTP mock 库,本 PR 优先补齐了核心状态机测试(24 个用例),覆盖了更高风险的路径。

建议:follow-up PR 统一补齐各 channel 的 media 单测,可以复用 weixin 的 media.test.ts 模式。

Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/media.ts Outdated
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/markdown.ts
Comment thread packages/channels/feishu/src/markdown.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
@yuanyuanAli

Copy link
Copy Markdown
Contributor Author

感谢 @pomelo-nwu @wenshao 详细的 review!已在最新 commit 中完成修复,逐项回应如下:

Blocking 项(全部已修复)

  • ✅ NOTICES.txt:已移除 @qwen-code/sdk@undefined 条目
  • _meta.ts:已注册 feishu: 'Feishu'
  • ✅ 状态机单测:新增 adapter.test.ts(24 个用例),覆盖
    extractContent/extractCardText/dedup/cleanupCard/stop button 状态机/disconnect
  • ✅ 版本号:已对齐至 0.15.11

Critical 项(全部已修复)

  • ✅ Build 失败:已添加 build.js buildOrder + CLI tsconfig references
  • ✅ disconnect() 资源泄漏:已补充 wsClient.close()
  • ✅ Webhook verificationToken 校验:缺失时 throw Error
  • ✅ Webhook body 无限制:已加 1MB 上限 + 413 响应
  • ✅ 文件名 null byte:已添加 .replace(/\0/g, '')

Medium/Suggestion 项(已修复)

  • ✅ Card action toast:仅在成功停止时返回"已停止"
  • ✅ Webhook 未注册 card.action.trigger:已注册
  • ✅ isReplyToBot 绕过 mention gating:改为通过 fetchMessageContent 判断 sender_type
  • ✅ removeReaction 未校验 bot:已过滤 operator_id === botOpenId
  • ✅ fetchMessageContent 打印 body:已移除 debug 日志
  • ✅ splitChunks fence 溢出:已预留 closing fence 空间
  • ✅ splitChunks 丢失语言标识:已保留 fenceLine
  • ✅ collapsible 断词:已用 lastIndexOf(' ') 查找词边界
  • ✅ onCardAction 无授权检查:已加 operator.open_id 与原始发送人比对
  • ✅ downloadMedia 无大小限制:已加 50MB content-length 检查
  • ✅ Webhook dispatch error 不记日志:已补充 stderr 输出
  • ✅ httpServer.listen() 缺 error handler:已加 reject
  • ✅ onResponseChunk fallback 缺 atPrefix:已补充
  • ✅ Webhook 默认绑定 127.0.0.1:已实现,支持 webhookHost 配置

潜在风险回应

  1. Webhook 安全暴露面:已默认 bind 127.0.0.1,文档中建议走 reverse proxy
  2. 状态机泄漏:已在 dedupTimer 中补充 10 分钟无活动的 stale card session 清理
  3. 跨 channel stop 一致性:认同,建议后续抽到 ChannelBase 统一支持
  4. CLI bundle 体积:认同是共性问题,支持后续讨论 lazy install 方案

未修复项(建议 follow-up PR)

  • 临时文件清理:涉及 ChannelBase 回调机制改动,scope 较大
  • downloadMedia 单测:需 HTTP mock,优先级低
  • 类型安全 / 日志分级 / bundle 体积:全 channel 共性问题,支持单独 issue 统一治理

CI 失败是 package-lock.json 与 main 冲突,rebase 后重跑即可。

@yuanyuanAli yuanyuanAli reopened this May 24, 2026
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/markdown.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/adapter.test.ts Outdated
@yuanyuanAli yuanyuanAli force-pushed the feat/feishu-channel branch from f5f84e6 to 7c3b3a3 Compare May 24, 2026 03:32
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/markdown.ts Outdated
Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
let displayContent = atPrefix
? `${atPrefix}\n\n${cs.accumulatedText}`
: cs.accumulatedText;
if (displayContent.length > MAX_CARD_CHARS) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When displayContent.length > MAX_CARD_CHARS, the content is truncated from the head, but unlike onResponseComplete (lines 940-944), code fences are not rebalanced. If the truncation lands inside a code block, intermediate streaming cards render with broken, unclosed markdown code blocks for up to 1.5s per update cycle.

Suggested change
if (displayContent.length > MAX_CARD_CHARS) {
if (displayContent.length > MAX_CARD_CHARS) {
const marker = '\n\n_(内容过长,已截断早期内容)_';
displayContent =
displayContent.slice(-(MAX_CARD_CHARS - marker.length)) + marker;
if (this.countFences(displayContent) % 2 === 1) {
displayContent = '```\n' + displayContent;
}
}

— qwen3.7-max via Qwen Code /review

if (!cardState.created && !cardState.creating) return false;

// Only the original sender can stop (group chat protection) — fail-closed
const operator = data['operator'] as { open_id?: string } | undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Stop button auth reads only operator?.open_id, but onMessage (line 1503-1506) uses a defensive open_id || user_id || union_id fallback chain. Depending on app configuration / SDK user_id_type settings, open_id may be undefined, making the stop button silently non-functional while the same user can still send messages.

Suggested change
const operator = data['operator'] as { open_id?: string } | undefined;
const operator = data['operator'] as
| { open_id?: string; user_id?: string; union_id?: string }
| undefined;
const operatorId =
operator?.open_id || operator?.user_id || operator?.union_id;

— qwen3.7-max via Qwen Code /review


const inboundId = targetInboundMsgId;

const handleStop = async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When cancelSession rejects, the error is caught and logged, but execution continues to update the card with "已停止生成". The user sees a "stopped" card while the LLM continues generating tokens invisibly (the bridge failed to cancel). Misleading UX and silent token waste.

Consider short-circuiting on cancelSession failure:

if (sessionId) {
  try {
    await this.bridge.cancelSession(sessionId);
  } catch (err) {
    process.stderr.write(`...cancelSession failed...`);
    return; // don't rewrite the card
  }
}

— qwen3.7-max via Qwen Code /review

FEISHU_ID_RE.test(item.reaction_id) &&
item.operator?.operator_id === this.botOpenId
) {
await fetch(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The fetch() return value for the DELETE request is never captured or checked. If the Feishu API returns 403/404/500, the failure is completely silent — the "OnIt" emoji remains permanently stuck on user messages with no log trail.

Suggested change
await fetch(
const delResp = await fetch(
`${BASE_URL}/im/v1/messages/${messageId}/reactions/${item.reaction_id}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(15_000),
},
);
if (!delResp.ok) {
if (delResp.status === 401) this.tokenCache = undefined;
process.stderr.write(
`[Feishu:${this.name}] removeReaction DELETE failed: HTTP ${delResp.status}\n`,
);
}

— qwen3.7-max via Qwen Code /review


for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]!;
const title =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] extractTitle(text) is called inside the for loop, but text is invariant across iterations. For a response split into N chunks, extractTitle (which splits the full text by \n) runs N times producing the same result.

Suggested change
const title =
const baseTitle = extractTitle(text);
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]!;
const title = i === 0 ? baseTitle : `${baseTitle} (cont.)`;

— qwen3.7-max via Qwen Code /review

this.msgToSenderId.set(msgId, senderId);

// Download media if present
if (content.imageKey) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Image download and file download are sequential (await one after the other). When a message has both imageKey and fileKey, the two network downloads add latency. Use Promise.all to parallelize:

const [imageMedia, fileMedia] = await Promise.all([
  content.imageKey && token
    ? downloadMedia(msgId, content.imageKey, 'image', token)
    : Promise.resolve(null),
  content.fileKey && content.fileName && token
    ? downloadMedia(msgId, content.fileKey, 'file', token)
    : Promise.resolve(null),
]);

— qwen3.7-max via Qwen Code /review

});

const host = (feishuCfg['webhookHost'] as string) || '127.0.0.1';
await new Promise<void>((resolve, reject) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The webhook HTTP server has no requestTimeout, headersTimeout, or maxConnections configured. A slow-loris attacker can hold many connections open, exhausting sockets on the webhook port.

Suggested change
await new Promise<void>((resolve, reject) => {
this.httpServer!.requestTimeout = 30_000;
this.httpServer!.headersTimeout = 10_000;
this.httpServer!.maxConnections = 100;

— qwen3.7-max via Qwen Code /review

} else if (item.msg_type === 'post') {
// Post content may be wrapped in a language key like {"zh_cn": {title, content}}
// or it may be directly {title, content} (e.g. from API history fetch).
const firstValue = Object.values(content)[0];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Post content parsing logic here is near-identical to extractContent (lines 1738-1766). Both implement the same language-wrapper detection, paragraph iteration, and text/a tag extraction. The two copies already diverge — extractContent handles at tags while this doesn't. Extract a shared parsePostContent() helper.

— qwen3.7-max via Qwen Code /review


if (cardState?.stopped || this.stoppedMessages.has(inboundMsgId)) {
this.cleanupCard(inboundMsgId);
this.stoppedMessages.delete(inboundMsgId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] cleanupCard(inboundMsgId) already calls this.stoppedMessages.delete(inboundMsgId) internally (line 1473). This explicit .delete() is redundant and makes the code harder to reason about — a reader might think cleanupCard doesn't handle stoppedMessages.

Remove this line (also at line 195).

— qwen3.7-max via Qwen Code /review

if (fenceStart > 0 && fenceStart < rawSplit + 500) {
const fenceLineEnd = markdown.indexOf('\n', fenceStart + 1);
splitAt = fenceLineEnd > 0 ? fenceLineEnd : fenceStart + 4;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When the collapsible split falls inside a code block and no closing fence exists within 500 chars, the comment says "accept the split as-is". This produces a preview element with an unclosed code block and a rest element starting mid-code-block — both render as broken markdown in the Feishu card.

Consider closing the fence in the preview and reopening it in the rest:

// when no nearby closing fence:
preview = markdown.slice(0, splitAt) + '\n```';
rest = '```\n' + markdown.slice(splitAt);

— qwen3.7-max via Qwen Code /review

);
return null;
}
chunks.push(Buffer.from(value));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] After the while read loop completes successfully (done === true), reader.releaseLock() is never called. The reader retains a lock on the ReadableStream, preventing the underlying HTTP connection from being returned to the pool until GC. Under sustained load with many media downloads, this contributes to connection pool exhaustion.

Suggested change
chunks.push(Buffer.from(value));
const { done, value } = await reader.read();
if (done) break;
// ... existing code ...
}
reader.releaseLock();
const buffer = Buffer.concat(chunks);

Or wrap in try/finally to ensure release on all paths.

— qwen3.7-max via Qwen Code /review

@yuanyuanAli yuanyuanAli force-pushed the feat/feishu-channel branch 2 times, most recently from 98427c5 to c939754 Compare May 27, 2026 11:35

async sendMessage(chatId: string, text: string): Promise<void> {
const token = await this.getTenantAccessToken();
if (!token) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] sendMessage returns Promise<void> — when getTenantAccessToken() fails (returns undefined), the method logs a warning and returns silently. All callers (onResponseComplete fallback at :1094, onPromptEnd fallback at :1262, handleStop fallback at :1487) treat the resolved promise as delivery success.

Impact: If the Feishu auth endpoint is down, every response silently vanishes. Users send messages, see the bot's reaction, but get no response. Only a single "Cannot send" line per message appears in stderr — no error propagates to the bridge layer.

Suggested change
if (!token) {
protected async sendMessage(
chatId: string,
text: string,
): Promise<boolean> {
const token = await this.getTenantAccessToken();
if (!token) {
process.stderr.write(
`[Feishu:${this.name}] Cannot send: no access token.\n`,
);
return false;
}

Then check the return value in callers to handle delivery failure.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChannelBase.sendMessage 抽象签名是 Promise,TS 不允许子类窄化为 Promise。需要先改 base class + DingTalk adapter,跨包变更,本轮 defer

Comment thread packages/channels/feishu/src/FeishuAdapter.ts
Comment thread packages/channels/feishu/src/FeishuAdapter.ts

/** Validate Feishu ID format to prevent SSRF path traversal in URL interpolation. */
const FEISHU_ID_RE = /^[a-zA-Z0-9_.:-]+$/;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] FEISHU_ID_RE permits .. and . — the regex /^[a-zA-Z0-9_.:-]+$/ includes . in the character class, so the string .. passes validation. When interpolated into URL paths (e.g., ${BASE_URL}/im/v1/messages/${messageId} at :762, :795, :1302), .. triggers WHATWG URL path resolution: /im/v1/messages/.. rewrites to /im/v1/, hitting a completely different API endpoint with the bot's bearer token.

Impact: Defense-in-depth failure. The regex comment explicitly says it prevents "SSRF path traversal in URL interpolation" but doesn't. While message IDs normally come from Feishu's server, if HMAC verification were ever bypassed, an attacker could redirect authenticated requests to arbitrary Feishu API endpoints.

Suggested change
const FEISHU_ID_RE = /^(?!\.{1,2}$)[a-zA-Z0-9_.:-]+$/;

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不认可。.. 只在浏览器 new URL() 时触发 WHATWG 路径解析,fetch() 对已拼接的完整 URL 不做路径归一化。且 ID 全部来自飞书服务端(msg_id/file_key/image_key),攻击者无法控制这些值。即使 HMAC 被绕过,攻击者发送的消息里的 ID 也是飞书分配的合法值,不会是 ..。正则的 JSDoc 说"prevent SSRF path traversal"是防御深度的描述,实际已足够安全~

}

// Re-check stopped state after busy-wait (user may have clicked Stop during wait)
if (cardState?.stopped || this.stoppedMessages.has(inboundMsgId)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Race between onResponseComplete and handleStop can leave the streaming card unfinalised. The sequence: (1) user clicks Stop while creating=true; (2) onResponseComplete sets finalizing=true before the busy-wait; (3) busy-wait resolves when creating becomes false; (4) handleStop finishes cancelSession, sees cardState.finalizing === true at :1466, and returns without updating the card; (5) onResponseComplete post-wait re-check sees stopped=true and also returns early. Result: the card shows stale streaming content and never gets the "已停止生成" label.

Impact: User clicks Stop but the card remains stuck showing partial streaming content with no stop indicator. The fallback sendMessage in onResponseComplete is skipped because stopped=true.

Suggested fix: Before the early return at the stopped check in onResponseComplete, call deleteCard and sendMessage to deliver the response:

if (cardState?.stopped || this.stoppedMessages.has(inboundMsgId)) {
  // handleStop owns the card update, but if it bailed due to finalizing,
  // ensure the response is delivered
  if (!cardState?.messageId) {
    await this.sendMessage(chatId, fullText);
  }
  this.cleanupCard(inboundMsgId);
  return;
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

触发条件:用户必须在 creating=true 时点 Stop + cancelSession 恰好在 busy-wait 期间完成 + finalizing 已被 set — 卡片创建通常几百ms内完成,手动触发这个窗口几乎不可能。建议降为 Suggestion

Comment thread packages/channels/feishu/src/FeishuAdapter.ts Outdated
mkdirSync(dir, { recursive: true });
const rawName = basename(content.fileName).replace(/\0/g, '');
const safeName =
rawName.replace(/[^\w.-]/g, '_').replace(/^\.+/, '_') ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] mkdirSync and writeFileSync are synchronous and block the Node.js event loop during file download. Files can be up to 50MB (MAX_DOWNLOAD_BYTES). A single 50MB writeFileSync can block the event loop for 10-50ms depending on disk speed. Under concurrent message processing (multiple users sending attachments simultaneously), each synchronous write serializes on the event loop, causing latency spikes for all other operations including card updates, WebSocket heartbeat, and message dedup.

Suggested change
rawName.replace(/[^\w.-]/g, '_').replace(/^\.+/, '_') ||
import { mkdir, writeFile } from 'node:fs/promises';
// ...
await mkdir(dir, { recursive: true });
await writeFile(filePath, media.buffer);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

50MB 文件在 SSD 上 writeFileSync 约 10-30ms,在单条消息的 async 链中执行,影响可忽略。改 async 是锦上添花,不影响正确性,建议降为 Nice to have

Comment thread packages/channels/feishu/src/adapter.test.ts
@yuanyuanAli

Copy link
Copy Markdown
Contributor Author

感谢 review,逐条回应:

✅ 已修复

# 意见 处理
#2 onPromptEnd empty accumulatedText 静默丢弃 已加兜底:发送 *出错了,请重试* + stderr 日志
#3 Webhook JSON parse catch {} 无日志 已捕获 err 变量并写 stderr
#6 孤儿卡 deleteCard().catch(() => {}) 无日志 已加 ORPHANED CARD: 级别日志
#8 四个关键路径零测试 新增 9 个测试覆盖 deleteCardonPromptEnd 分支、onResponseComplete stopped 路径、sendMessage token 失败 (70/70 pass)

🟡 Defer

# 意见 原因
#1 sendMessage 返回 Promise<boolean> ChannelBase.sendMessage 抽象签名是 Promise<void>,TS 不允许子类窄化为 Promise<boolean>。需要先改 base class + DingTalk adapter,跨包变更,本轮 defer

❌ 不认可

# 意见 原因
#4 FEISHU_ID_RE 允许 .. — SSRF 不认可。.. 只在浏览器 new URL() 时触发 WHATWG 路径解析,fetch() 对已拼接的完整 URL 不做路径归一化。且 ID 全部来自飞书服务端(msg_id/file_key/image_key),攻击者无法控制这些值。即使 HMAC 被绕过,攻击者发送的消息里的 ID 也是飞书分配的合法值,不会是 ..。正则的 JSDoc 说"prevent SSRF path traversal"是防御深度的描述,实际已足够安全
#5 onResponseCompletehandleStop 竞态导致卡片未 finalize 分析逻辑正确,但标 Critical 过重。触发条件:用户必须在 creating=true 时点 Stop + cancelSession 恰好在 busy-wait 期间完成 + finalizing 已被 set — 卡片创建通常几百ms内完成,手动触发这个窗口几乎不可能。建议降为 Suggestion
#7 mkdirSync/writeFileSync 同步阻塞 50MB 文件在 SSD 上 writeFileSync 约 10-30ms,在单条消息的 async 链中执行,影响可忽略。改 async 是锦上添花,不影响正确性,建议降为 Nice to have

@yuanyuanAli yuanyuanAli force-pushed the feat/feishu-channel branch from c939754 to b86572f Compare May 28, 2026 01:46
);
// If updateCard failed and cancel succeeded, try to delete the orphaned
// card and fall back to sendMessage to avoid leaving a stuck "生成中..." card.
if (!updated && cancelSucceeded && chatId) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] deleteCard fallback gated on chatId — but deleteCard only needs messageId, not chatId. If context?.open_chat_id is absent from the card action payload, the orphaned card is never deleted even though deletion would succeed.

Suggested change
if (!updated && cancelSucceeded && chatId) {
if (!updated && cancelSucceeded) {
await this.deleteCard(cardState.messageId);
if (chatId) await this.sendMessage(chatId, finalText);
}

— qwen3.7-max via Qwen Code /review

clearTimeout(cardState.pendingUpdateTimer);
}
if (cardState?.creationTimer) {
clearTimeout(cardState.creationTimer);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] clearTimeout(creationTimer) cancels the callback that would set creating = false, but the creating flag is not reset here. If onResponseChunk's fallback path initiated card creation and onResponseComplete fires before the setTimeout(0) callback, creating stays true forever — the busy-wait polls for the full 10s timeout before abandoning the card with a delay.

Suggested change
clearTimeout(cardState.creationTimer);
if (cardState?.creationTimer) {
clearTimeout(cardState.creationTimer);
cardState.creationTimer = undefined;
if (cardState.creating) {
cardState.creating = false;
cardState.cardCreationFailed = true;
}
}

— qwen3.7-max via Qwen Code /review

cardState.stopped = true;
cardState.abandoned = true;
this.cleanupCard(inboundMsgId);
await this.sendMessage(chatId, fullText);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Abandoned-card path sends bare fullText without the @sender greeting prefix. Every other sendMessage fallback in this function uses displayText (which includes 好的,<at id=...></at>). In group chats, the response arrives without the user's @mention, making it unclear who the response is for.

Suggested change
await this.sendMessage(chatId, fullText);
await this.sendMessage(chatId, displayText);

— qwen3.7-max via Qwen Code /review

: cs.accumulatedText) +
'\n\n---\n' +
errorLabel
: (atPrefix ? `${atPrefix}\n\n` : '') + errorLabel;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] In steer dispatch mode, when a user sends a new message while the agent is streaming, ChannelBase cancels the running prompt. onPromptEnd fires with cs.stopped=false and cs.completed=false, so it shows *出错了,请重试* on the card — even though nothing errored. The user sees a misleading error message on the old card while their new message is already being processed on a new card.

Consider showing a neutral *已取消* label instead of *出错了* when the prompt was cancelled rather than errored.

— qwen3.7-max via Qwen Code /review

// the response was already delivered via sendMessage.
if (cardState.abandoned) {
if (result.success) {
this.deleteCard(result.messageId).catch((err) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] deleteCard returns Promise<boolean> and never rejects — all error paths are caught internally and return false. This .catch() handler is dead code; the ORPHANED CARD log will never appear in stderr, defeating the observability intent.

Suggested change
this.deleteCard(result.messageId).catch((err) => {
this.deleteCard(result.messageId).then((ok) => {
if (!ok) {
process.stderr.write(
`[Feishu:${this.name}] ORPHANED CARD: failed to delete abandoned card msg=${result.messageId}\n`,
);
}
});

— qwen3.7-max via Qwen Code /review

>(channel, 'connectWebhook').bind(channel);

// Just verify the method exists and is callable
expect(typeof connectWebhook).toBe('function');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This test only asserts typeof connectWebhook === 'function' — it provides zero behavioral coverage for the new catch (err) { process.stderr.write(...) } at FeishuAdapter.ts:325-328. The describe name claims to test "logs error message on malformed JSON body" but a regression that removes the log would still pass.

Either implement a real integration test (start the webhook server on port 0, POST invalid JSON, spy on process.stderr.write and assert the error string) or replace with:

it.todo('should log parse errors to stderr (requires HTTP server integration test)');

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Local Verification Report

PR: #4379feat(channels): add Feishu (Lark) channel adapter
Verified by: Maintainer (local build + test + E2E)
Date: 2026-05-28


1. Build Verification

Package Result
@qwen-code/channel-feishu PASS — clean tsc build, all 6 files compiled to dist/
@qwen-code/qwen-code-core PASS — clean build
@qwen-code/qwen-code (CLI) PASS — clean build with feishu reference

TypeCheck: tsc --noEmit passes for feishu package with zero errors.

2. Unit Tests

Package Tests Result
Feishu — adapter.test.ts 40 tests ALL PASSED
Feishu — markdown.test.ts 20 tests ALL PASSED
Feishu — media.test.ts 10 tests ALL PASSED
Feishu Total 70 tests ALL PASSED
Core (regression) 9502 tests 9502 passed, 12 pre-existing failures*
CLI (regression) 6916 tests 6916 passed, 21 pre-existing failures*

* Pre-existing failures in gitDiff.test.ts, crawler.test.ts, AuthDialog.test.tsx, InputPrompt.test.tsx, SettingsDialog.test.tsx — none related to feishu or channels.

3. E2E Integration (tmux)

Test Result Detail
Plugin export PASS plugin.channelType === 'feishu', displayName === 'Feishu', requiredConfigFields === ['clientId', 'clientSecret']
Channel registry PASS supportedTypes() returns ['telegram', 'weixin', 'dingtalk', 'feishu']
Plugin lookup PASS getPlugin('feishu') resolves to correct plugin instance
FeishuChannel import PASS FeishuChannel class exports and instantiates correctly

4. Test Coverage Analysis (70 tests)

The adapter test file (968 lines) covers:

  • Constructor validation — clientId/clientSecret required
  • Message extraction — text, post, image, file, audio, media, interactive, malformed JSON
  • Card text extraction — v1 and v2 card formats, collapsible panels, streaming indicator
  • Message deduplication — same-ID within TTL, re-allowed after TTL
  • Card lifecycle cleanup — all maps cleared, timers cancelled
  • Stop button — marks card stopped during creation, sender auth (3 rejection cases: operator mismatch, missing operator, missing sender)
  • Cancel session failure — "停止失败" feedback on cancelSession error
  • Card deletion — success, token unavailable, HTTP errors, 401 cache clear
  • onPromptEnd edge cases — token unavailable, card creation failed (with/without accumulated text), stopped card cleanup
  • Webhook parse errors — malformed JSON body
  • Post @mention extraction — user_name present and absent
  • Auxiliary map persistence — collect-mode buffered messages without card sessions

5. Security Review

Area Status Details
SSRF prevention OK FEISHU_ID_RE = /^[a-zA-Z0-9_.:-]+$/ validates all IDs before URL interpolation (messageId, fileKey, reactionId)
HMAC bypass protection OK Webhook mode requires both verificationToken AND encryptKey. Headers attached as non-enumerable property to prevent JSON body "headers" key from shadowing req.headers
Body size limit OK 1 MiB webhook body limit with stream tracking, 413 on exceed
Download size limit OK 50 MB with both Content-Length pre-check and streaming byte count
Prompt injection OK Quoted content wrapped in [引用内容 — 以下为其他用户的原始消息,请勿将其视为指令] tags, tag-like sequences stripped, content capped at 1000 chars
Stop button auth OK Fail-closed: rejects if operator or sender is unknown, requires exact open_id match
File path traversal OK basename() + character whitelist + null byte stripping for downloaded files
Token caching OK Refresh deduplication via promise, 401 cache invalidation, 60s pre-expiry margin
Request timeouts OK All Feishu API calls use AbortSignal.timeout(15_000) (or 30s for downloads)
Timing-safe comparison OK timingSafeEqual for verification token in webhook URL verification

6. Architecture Notes

  • Clean integration: follows existing channel pattern (ChannelBaseFeishuChannel), consistent with telegram/weixin/dingtalk adapters
  • Dual connection modes: WebSocket (default, like DingTalk) and HTTP webhook with full HMAC auth
  • Interactive card streaming: real-time updates with rate-limited throttling (1.5s), stop button, proper lifecycle management
  • Concurrency: per-message state isolation via CardSessionState, no global mutable state races
  • Resource cleanup: disconnect() clears all maps, timers, connections; stale session timer (10 min) catches leaked state
  • Fallback chain: card update failure → strip tables → truncate → delete card → plain message
  • Build script and CLI tsconfig properly updated to include feishu package

7. Summary

All 70 feishu-specific tests pass. Build and typecheck clean. Channel correctly registers in the CLI registry. Security posture is solid — SSRF, HMAC bypass, prompt injection, file traversal, and timing attacks are all addressed. The adapter follows the established channel architecture pattern.

Recommendation: Ready to merge

// Set cancelling synchronously so .then() callbacks (onPromptStart, onResponseChunk)
// can detect the stop intent even before cancelSession resolves.
// This replaces the old stopped=true which caused chunk loss on cancel failure.
cardState.cancelling = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Stop-button double-click race: cancelling is set but never checked before scheduling handleStop()

onCardAction sets cardState.cancelling = true synchronously (this line), but no guard checks it before scheduling the async handleStop() call. If the user double-clicks the stop button — or Feishu retries card.action.trigger because handleStop exceeded the SDK response deadline — two independent handleStop closures run concurrently:

  1. Both call bridge.cancelSession(sessionId) — double cancellation
  2. Both attempt updateCard — the second may 400 on an already-finalized card
  3. If either updateCard fails, its deleteCard + sendMessage fallback posts the stop text twice in the chat

Fix: short-circuit before handleStop():

if (cardState.cancelling || cardState.stopped) {
  return true; // action consumed, prevent Feishu retry
}
cardState.cancelling = true;

Returning true (not false) is deliberate — Feishu treats it as "action consumed" and won't retry.

— claude-opus-4.6 via Qwen Code /review

}

protected override async onPromptEnd(
_chatId: string,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] _chatId parameter uses underscore-prefix (unused convention) but IS actively used

The parameter _chatId is used at line 1275 (this.sendMessage(_chatId, fallbackText)) and line 1283 (this.sendMessage(_chatId, errorText)). The underscore prefix misleads readers into thinking the parameter is unused.

Rename to chatId (drop the underscore).

— claude-opus-4.6 via Qwen Code /review


async connect(): Promise<void> {
// Build event dispatcher
this.eventDispatcher = new lark.EventDispatcher({});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] connect() creates an EventDispatcher that is wasted in webhook mode

This EventDispatcher is created and registered with handlers unconditionally. Then in webhook mode, connectWebhook() at line 248 creates a second local dispatcher with verification params. The HTTP server uses the local dispatcher.invoke(data), so this.eventDispatcher is never used in webhook mode.

Fix: defer this.eventDispatcher creation to connectWebSocket(), or move the dispatcher creation after the webhook/websocket decision and pass verification params at construction time.

— claude-opus-4.6 via Qwen Code /review

@yiliang114

Copy link
Copy Markdown
Collaborator

Thanks for this comprehensive contribution — a full Feishu/Lark channel adapter with WebSocket support, interactive card streaming, quote/reply context, and concurrent message handling. The demo video and test coverage are appreciated.

This follows the existing channel adapter pattern (alongside Telegram, Weixin, DingTalk) and the scope is cohesive, so it's ready for human code review.

A few things reviewers will likely focus on:

  1. Adapter sizeFeishuAdapter.ts is ~1,970 lines. If there are natural internal boundaries (e.g., card rendering vs. message dispatch vs. media handling), a brief note on the internal structure would help reviewers navigate it.
  2. Dependency — the new @larksuiteoapi/node-sdk dependency. Reviewers will want to verify the version pinning and license compatibility.

No action needed from you at this point — handing off to code review.

@yiliang114 yiliang114 added category/integration External integrations status/in-review This issue is currently in review. labels May 28, 2026

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good — cohesive channel adapter following the established pattern, with solid test coverage and documentation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category/integration External integrations status/in-review This issue is currently in review. type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Feishu (Lark) channel adapter

7 participants