feat(channels): add Feishu (Lark) channel adapter#4379
Conversation
|
TQL |
There was a problem hiding this comment.
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-feishupackage 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.
|
@yuanyuanAli 太强了!感谢你的贡献,我们 review 后加快合并 |
… timeout, and reaction cleanup
|
@yuanyuanAli 接前面的承诺,这里给出完整的 review。 1. PR 描述中文翻译
关联 issue #4378 正文:
2. 核心诉求与动机
3. 站在 Qwen Code 技术产品负责人角度的判断结论:方向上接受,但当前版本不能直接 merge — 需要先完成 4 项小改动后再合,预计 1-2 个 reroll。 ✅ 认可的部分
❌ 不能直接 merge 的理由(按严重度排序)
建议但不 block(可在 follow-up PR 处理)
4. 潜在风险反问(收益和风险同等重要)请额外回应这几点,确认都看过了再合:
总结:方向很对、实现很扎实。麻烦先把 4 项 blocking 处理掉(NOTICES.txt 剥离 / — 山果 |
|
「问题:merge 后是否同步给百炼 / 集团相关 BD 同学打个招呼,避免外部解读偏差?」这AI一定是偷学了《大厂生存指南》🐶 |
可以对接微信,为什么不能对接飞书,阿里云很多产品也是支持飞书的。 |
| token, | ||
| ); | ||
| if (media) { | ||
| const dir = join(tmpdir(), 'channel-files', randomUUID()); |
There was a problem hiding this comment.
[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
There was a problem hiding this comment.
文件清理的正确时机是 bridge 读取完毕后,但 ChannelBase 目前没有 onFileConsumed 类生命周期回调。在 onPromptEnd时删文件不安全(bridge 可能仍在读取),需要对 channel-base 做架构改动。此外 dingtalk/weixin channel 的 media下载同样没有清理机制,是共性问题。
建议:单独开 issue 在 ChannelBase 层统一解决,增加文件消费完成回调后再各 channel 补清理逻辑。
| messageId: string, | ||
| fileKey: string, | ||
| resourceType: 'image' | 'file', | ||
| accessToken: string, |
There was a problem hiding this comment.
[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
There was a problem hiding this comment.
downloadMedia 依赖 fetch 访问飞书 Open API,测试需要引入 HTTP mock 库,本 PR 优先补齐了核心状态机测试(24 个用例),覆盖了更高风险的路径。
建议:follow-up PR 统一补齐各 channel 的 media 单测,可以复用 weixin 的 media.test.ts 模式。
|
感谢 @pomelo-nwu @wenshao 详细的 review!已在最新 commit 中完成修复,逐项回应如下: Blocking 项(全部已修复)
Critical 项(全部已修复)
Medium/Suggestion 项(已修复)
潜在风险回应
未修复项(建议 follow-up PR)
CI 失败是 package-lock.json 与 main 冲突,rebase 后重跑即可。 |
…oad safety from CR round 2
f5f84e6 to
7c3b3a3
Compare
| let displayContent = atPrefix | ||
| ? `${atPrefix}\n\n${cs.accumulatedText}` | ||
| : cs.accumulatedText; | ||
| if (displayContent.length > MAX_CARD_CHARS) { |
There was a problem hiding this comment.
[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.
| 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; |
There was a problem hiding this comment.
[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.
| 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 () => { |
There was a problem hiding this comment.
[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( |
There was a problem hiding this comment.
[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.
| 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 = |
There was a problem hiding this comment.
[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.
| 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) { |
There was a problem hiding this comment.
[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) => { |
There was a problem hiding this comment.
[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.
| 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]; |
There was a problem hiding this comment.
[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); |
There was a problem hiding this comment.
[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; | ||
| } |
There was a problem hiding this comment.
[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)); |
There was a problem hiding this comment.
[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.
| 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
98427c5 to
c939754
Compare
|
|
||
| async sendMessage(chatId: string, text: string): Promise<void> { | ||
| const token = await this.getTenantAccessToken(); | ||
| if (!token) { |
There was a problem hiding this comment.
[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.
| 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
There was a problem hiding this comment.
ChannelBase.sendMessage 抽象签名是 Promise,TS 不允许子类窄化为 Promise。需要先改 base class + DingTalk adapter,跨包变更,本轮 defer
|
|
||
| /** Validate Feishu ID format to prevent SSRF path traversal in URL interpolation. */ | ||
| const FEISHU_ID_RE = /^[a-zA-Z0-9_.:-]+$/; | ||
|
|
There was a problem hiding this comment.
[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.
| const FEISHU_ID_RE = /^(?!\.{1,2}$)[a-zA-Z0-9_.:-]+$/; |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
不认可。.. 只在浏览器 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)) { |
There was a problem hiding this comment.
[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
There was a problem hiding this comment.
触发条件:用户必须在 creating=true 时点 Stop + cancelSession 恰好在 busy-wait 期间完成 + finalizing 已被 set — 卡片创建通常几百ms内完成,手动触发这个窗口几乎不可能。建议降为 Suggestion
| mkdirSync(dir, { recursive: true }); | ||
| const rawName = basename(content.fileName).replace(/\0/g, ''); | ||
| const safeName = | ||
| rawName.replace(/[^\w.-]/g, '_').replace(/^\.+/, '_') || |
There was a problem hiding this comment.
[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.
| 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
There was a problem hiding this comment.
50MB 文件在 SSD 上 writeFileSync 约 10-30ms,在单条消息的 async 链中执行,影响可忽略。改 async 是锦上添花,不影响正确性,建议降为 Nice to have
|
感谢 review,逐条回应: ✅ 已修复
🟡 Defer
❌ 不认可
|
c939754 to
b86572f
Compare
| ); | ||
| // 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) { |
There was a problem hiding this comment.
[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.
| 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); |
There was a problem hiding this comment.
[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.
| 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); |
There was a problem hiding this comment.
[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.
| 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; |
There was a problem hiding this comment.
[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) => { |
There was a problem hiding this comment.
[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.
| 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'); |
There was a problem hiding this comment.
[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
Local Verification ReportPR: #4379 — 1. Build Verification
TypeCheck: 2. Unit Tests
* Pre-existing failures in 3. E2E Integration (tmux)
4. Test Coverage Analysis (70 tests)The adapter test file (968 lines) covers:
5. Security Review
6. Architecture Notes
7. SummaryAll 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; |
There was a problem hiding this comment.
[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:
- Both call
bridge.cancelSession(sessionId)— double cancellation - Both attempt
updateCard— the second may 400 on an already-finalized card - If either
updateCardfails, itsdeleteCard + sendMessagefallback 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, |
There was a problem hiding this comment.
[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({}); |
There was a problem hiding this comment.
[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
|
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:
No action needed from you at this point — handing off to code review. |
yiliang114
left a comment
There was a problem hiding this comment.
Looks good — cohesive channel adapter following the established pattern, with solid test coverage and documentation.
Summary
Fixes #4378
Test plan
Demo
default.mp4