* fix(feishu): correct invalid scope name in permission grant URL
The Feishu API returns error code 99991672 with an authorization URL
containing the non-existent scope `contact:contact.base:readonly`
when the `contact.user.get` endpoint is called without the correct
permission. The valid scope is `contact:user.base:readonly`.
Add a scope correction map that replaces known incorrect scope names
in the extracted grant URL before presenting it to the user/agent,
so the authorization link actually works.
Closes#31761
* chore(changelog): note feishu scope correction
---------
Co-authored-by: SidQin-cyber <sidqin0410@gmail.com>
* feat(feishu): add broadcast support for multi-agent group observation
When multiple agents share a Feishu group chat, only the @mentioned
agent receives the message. This prevents observer agents from building
session memory of group activity they weren't directly addressed in.
Adds broadcast support (reusing the same cfg.broadcast schema as
WhatsApp) so all configured agents receive every group message in their
session transcripts. Only the @mentioned agent responds on Feishu;
observer agents process silently via no-op dispatchers.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): guard sequential broadcast dispatch against single-agent failure
Wrap each dispatchForAgent() call in the sequential loop with try/catch
so one agent's dispatch failure doesn't abort delivery to remaining agents.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): avoid duplicate messages in broadcast observer mode and normalize agent IDs
- Skip recordPendingHistoryEntryIfEnabled for broadcast groups when not
mentioned, since the message is dispatched directly to all agents.
Previously the message appeared twice in the agent prompt.
- Normalize agent IDs with toLowerCase() before membership checks so
config casing mismatches don't silently skip valid agents.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): set WasMentioned per-agent and normalize broadcast IDs
- buildCtxPayloadForAgent now takes a wasMentioned parameter so active
agents get WasMentioned=true and observers get false (P1 fix)
- Normalize broadcastAgents to lowercase at resolution time and
lowercase activeAgentId so all comparisons and session key generation
use canonical IDs regardless of config casing (P2 fix)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): canonicalize broadcast agent IDs with normalizeAgentId
* fix(feishu): match ReplyDispatcher sync return types for noop dispatcher
The upstream ReplyDispatcher changed sendToolResult/sendBlockReply/
sendFinalReply to synchronous (returning boolean). Update the broadcast
observer noop dispatcher to match.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): deduplicate broadcast agent IDs after normalization
Config entries like "Main" and "main" collapse to the same canonical ID
after normalizeAgentId but were dispatched multiple times. Use Set to
deduplicate after normalization.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): honor requireMention=false when selecting broadcast responder
When requireMention is false, the routed agent should be active (reply
on Feishu) even without an explicit @mention. Previously activeAgentId
was null whenever ctx.mentionedBot was false, so all agents got the
noop dispatcher and no reply was sent — silently breaking groups that
disabled mention gating.
Hoist requireMention out of the if(isGroup) block so it's accessible
in the dispatch code.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): cross-account broadcast dedup to prevent duplicate dispatches
In multi-account Feishu setups, the same message event is delivered to
every bot account in a group. Without cross-account dedup, each account
independently dispatches broadcast agents, causing 2×N dispatches instead
of N (where N = number of broadcast agents).
Two changes:
1. requireMention=true + bot not mentioned: return early instead of
falling through to broadcast. The mentioned bot's handler will
dispatch for all agents. Non-mentioned handlers record to history.
2. Add cross-account broadcast dedup using a shared 'broadcast' namespace
(tryRecordMessagePersistent). The first handler to reach the broadcast
block claims the message; subsequent accounts skip. This handles the
requireMention=false multi-account case.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): strip CommandAuthorized from broadcast observer contexts
Broadcast observer agents inherited CommandAuthorized from the sender,
causing slash commands (e.g. /reset) to silently execute on every observer
session. Now only the active agent retains CommandAuthorized; observers
have it stripped before dispatch.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): use actual mention state for broadcast WasMentioned
The active broadcast agent's WasMentioned was set to true whenever
requireMention=false, even when the bot was not actually @mentioned.
Now uses ctx.mentionedBot && agentId === activeAgentId, consistent
with the single-agent path which passes ctx.mentionedBot directly.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): skip history buffer for broadcast accounts and log parallel failures
1. In requireMention groups with broadcast, non-mentioned accounts no
longer buffer pending history — the mentioned handler's broadcast
dispatch already writes turns into all agent sessions. Buffering
caused duplicate replay via buildPendingHistoryContextFromMap.
2. Parallel broadcast dispatch now inspects Promise.allSettled results
and logs rejected entries, matching the sequential path's per-agent
error logging.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Changelog: note Feishu multi-agent broadcast dispatch
* Changelog: restore author credit for Feishu broadcast entry
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* fix(feishu): preserve block streaming text when final payload is missing
When Feishu card streaming receives block payloads without matching final/partial
callbacks, keep block text in stream state so onIdle close still publishes the
reply instead of an empty message. Add a regression test for block-only streaming.
Closes#30628
* Feishu: preserve streaming block fallback when final text is missing
---------
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* fix(feishu): skip typing indicator keepalive re-adds to prevent notification spam
The typing keepalive loop calls addTypingIndicator() every 3 seconds,
which creates a new messageReaction.create API call each time. Feishu
treats each re-add as a new reaction event and fires a push notification,
causing users to receive repeated notifications while waiting for a
response.
Unlike Telegram/Discord where typing status expires after a few seconds,
Feishu reactions persist until explicitly removed. Skip the keepalive
re-add when a reaction already exists (reactionId is set) since there
is no need to refresh it.
Closes#28660
* Changelog: note Feishu typing keepalive suppression
---------
Co-authored-by: yuxh1996 <yuxh1996@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
- Remove vi.hoisted() wrapper from exported mock in shared module
(Vitest cannot export hoisted variables)
- Inline vi.hoisted + vi.mock in startup test so Vitest's per-file
hoisting registers mocks before production imports
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(extensions/feishu/src/reply-dispatcher.ts): missing privacy check / data leak
Pattern from PR #24969
The fix addresses the critical race condition by placing the 'block' filter check at the very top of the `deliver` function. This ensures that for internal 'block' reasoning chunks, the function returns immediately, preventing any text processing (lines 195-203) and, crucially, preventing the initialization of the streaming state for these payloads (lines 212-216). This ensures that the `streaming` object is not initialized with empty data, and subsequent 'final' payloads will correctly initialize and stream only the final content. The fix also addresses the 'incomplete' validation issue by using `info?.kind !== 'block'`. While the contract likely ensures `info` is present, this defensive approach ensures that if `info` is missing (and the payload is unrelated to internal blocking), the message is still delivered to the user, preventing a 'silent failure' bug. The validation logic at line 205 (`!hasText && !hasMedia`) ensures we do not send empty messages.
* Fix indentation: remove extra 4 spaces from deliver function body
The deliver function is inside the createReplyDispatcherWithTyping call,
so it should be indented at 2 levels (8 spaces), not 3 levels (12 spaces).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(feishu): cover block payload suppression in reply dispatcher
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* feat(feishu): add markdown tables, insert, color_text, table ops, and image fixes
Extends feishu_doc on top of #20304 with capabilities that are not yet covered:
Markdown → native table rendering:
- write/append now use the Descendant API instead of Children API,
enabling GFM markdown tables (block_type 31/32) to render as native
Feishu tables automatically
- Adaptive column widths calculated from cell content (CJK chars 2x weight)
- Batch insertion for large documents (>1000 blocks, docx-batch-insert.ts)
New actions:
- insert: positional markdown insertion after a given block_id
- color_text: apply color/bold to a text block via [red]...[/red] markup
- insert_table_row / insert_table_column: add rows or columns to a table
- delete_table_rows / delete_table_columns: remove rows or columns
- merge_table_cells: merge a rectangular cell range
Image upload fixes (affects write, append, and upload_image):
- upload_image now accepts data URI and plain base64 in addition to
url/file_path, covering DALL-E b64_json, canvas screenshots, etc.
- Fix: pass Buffer directly to drive.media.uploadAll instead of
Readable.from(), which caused Content-Length mismatch for large images
- Fix: same Readable bug fixed in upload_file
- Fix: pass drive_route_token via extra field for correct multi-datacenter
routing (per API docs: required when parent_node is a document block ID)
* fix(feishu): add documentBlockDescendant mock to docx.test.ts
write/append now use the Descendant API (documentBlockDescendant.create)
instead of Children API. The existing test mock was missing this SDK
method, causing processImages to never be reached and fetchRemoteMedia
to go uncalled.
Added blockDescendantCreateMock returning an image block so the
'skips image upload when markdown image URL is blocked' test flows
through processImages as expected.
* fix(feishu): address bot review feedback
- resolveUploadInput: remove length < 1024 guard on file path detection.
Prefix patterns (isAbsolute / ~ / ./ / ../) already correctly distinguish
file paths from base64 strings at any length. The old guard caused file
paths ≥1024 chars to fall through to the base64 branch incorrectly.
- parseColorMarkup: add comment clarifying that mismatched closing tags
(e.g. [red]text[/green]) are intentional — opening tag style is applied,
closing tag is consumed regardless of name.
* fix(feishu): address second-round codex bot review feedback
P1 - Reject single oversized subtrees in batch insert (docx-batch-insert.ts):
A first-level block whose descendant count exceeds BATCH_SIZE (1000) cannot
be split atomically (e.g. a very large table). Previously such a block was
silently added to the current batch and sent as an oversized request,
violating the API limit. Now throws a descriptive error so callers know to
reduce the content size.
P2 - Preserve unmatched brackets in color markup parser (docx-color-text.ts):
Text like 'Revenue [Q1] up' contains a bracket pair with no matching '[/...]'
closer. The original regex dropped the '[' character in this case, silently
corrupting the text. Fixed by appending '|\[' to the plain-text alternative
so any '[' that does not open a complete tag is captured as literal text.
* fix(feishu): address third-round codex bot review feedback
P2 - Throw ENOENT for non-existing absolute image paths (docx.ts):
Previously a non-existing absolute path like /tmp/missing.png fell
through to Buffer.from(..., 'base64') and uploaded garbage bytes.
Now throws a descriptive ENOENT error and hints at data URI format
for callers intending to pass JPEG binary data (which starts with /9j/).
P2 - Fail clearly when insert anchor block is not found (docx.ts):
insertDoc previously set insertIndex to -1 (append) when after_block_id
was absent from the parent's child list, silently inserting at the wrong
position. Two fixes:
1. Paginate through all children (documentBlockChildren.get returns up to
200 per page) before searching for the anchor.
2. Throw a descriptive error if after_block_id is still not found after
full pagination, instead of silently falling back to append.
* fix(feishu): address fourth-round codex bot review feedback
- Enforce mutual exclusivity across all three upload sources (url, file_path,
image): throw immediately when more than one is provided, instead of silently
preferring the image branch and ignoring the others.
- Validate plain base64 payloads before decoding: reject strings that contain
characters outside the standard base64 alphabet ([A-Za-z0-9+/=]) so that
malformed inputs fail fast with a clear error rather than decoding to garbage
bytes and producing an opaque Feishu API failure downstream.
Also throw if the decoded buffer is empty.
* fix(feishu): address fifth-round codex bot review feedback
- parseColorMarkup: restrict opening tag regex to known colour/style names
(bg:*, bold, red, orange, yellow, green, blue, purple, grey/gray) so that
ordinary bracket tokens like [Q1] can no longer consume a subsequent real
closing tag ([/red]) and corrupt the surrounding styled spans. Unknown tags
now fall through to the plain-text alternatives and are emitted literally.
- resolveUploadInput: estimate decoded byte count from base64 input length
(ceil(len * 3 / 4)) BEFORE allocating the full Buffer, preventing oversized
payloads from spiking memory before the maxBytes limit is enforced. Applies
to both the data-URI branch and the plain-base64 branch.
* fix(feishu): address sixth-round codex bot review feedback
- docx-table-ops: apply MIN/MAX_COLUMN_WIDTH clamping in the empty-table
branch so tables with 15+ columns don't produce sub-50 widths that Feishu
rejects as invalid column_width values.
- docx.ts (data URI branch): validate the ';base64' marker before decoding
so plain/URL-encoded data URIs are rejected with a clear error; also validate
the payload against the base64 alphabet (same guard already applied in the
plain-base64 branch) so malformed inputs fail fast rather than producing
opaque downstream Feishu errors.
* Feishu: align docx descendant insertion tests and changelog
---------
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* feat(feishu): add parentId and rootId to inbound context
Add ParentMessageId and RootMessageId fields to Feishu inbound message context,
enabling agents to:
- Identify quoted/replied messages
- Fetch original message content via Feishu API
- Build proper message thread context
The parent_id and root_id fields already exist in FeishuMessageContext but were
not being passed to the agent's inbound context.
Fixes: Allows proper handling of quoted card messages and message thread reconstruction.
* feat(feishu): parse interactive card content in quoted messages
Add support for extracting readable text from interactive card messages
when fetching quoted/replied message content.
Previously, only text messages were parsed. Now interactive cards
(with div and markdown elements) are also converted to readable text.
* 更新 bot.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* fix(types): add RootMessageId to MsgContext type definition
* style: fix formatting in bot.ts
* ci: trigger rebuild
* ci: retry flaky tests
* Feishu: add reply-context and interactive-quote regressions
---------
Co-authored-by: qiangu <qiangu@qq.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: 牛牛 <niuniu@openclaw.ai>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* feat(feishu): extract embedded video/media from post (rich text) messages
Previously, parsePostContent() only extracted embedded images (img tags)
from rich text posts, ignoring embedded video/audio (media tags). Users
sending post messages with embedded videos would not have the media
downloaded or forwarded to the agent.
Changes:
- Extend parsePostContent() to also collect media tags with file_key
- Return new mediaKeys array alongside existing imageKeys
- Update resolveFeishuMediaList() to download embedded media files
from post messages using the messageResource API
- Add appropriate logging for embedded media discovery and download
* Feishu: keep embedded post media payloads type-safe
* Feishu: format post parser after media tag extraction
---------
Co-authored-by: laopuhuluwa <laopuhuluwa@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
- Respect groupConfig.enabled flag (was parsed but never enforced)
- Fix misleading log: group allowlist rejection now logs group ID and
policy instead of sender open_id
* feat(feishu): parse post rich text as markdown
* chore: rerun ci
* Feishu: resolve post parser rebase conflicts and gate fixes
---------
Co-authored-by: Wilson Liu <wilson.liu@example.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
## Summary
- honor Feishu wildcard group policy fallback via `channels.feishu.groups["*"]` when no explicit group entry matches
- keep exact and case-insensitive explicit group matches higher precedence than wildcard fallback
- add changelog credit and TypeScript-safe test assertions
## Verification
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini
Co-authored-by: Wayne Pika <262095977+WaynePika@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
Add an optional `header` parameter to `FeishuStreamingSession.start()`
so that streaming cards can display a colored title bar, matching the
appearance of non-streaming interactive cards.
The Card Kit API already supports `header` alongside `streaming_mode`,
but the current implementation omits it, producing headerless cards.
This change is fully backward-compatible: when `header` is not provided,
behavior is identical to before.
Closes#13267 (partial)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): pass proxy agent to WSClient for environments behind HTTPS proxy
The Lark SDK WSClient uses the `ws` library which does not automatically
respect https_proxy/HTTP_PROXY environment variables. This causes WebSocket
connection failures in proxy environments (e.g. WSL2 with a local proxy).
Detect proxy env vars and pass an HttpsProxyAgent to WSClient via the
existing `agent` constructor option.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): add generic type parameter to HttpsProxyAgent return type
Fix TS2314: `HttpsProxyAgent<Uri>` requires a type argument.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(feishu): wire ws proxy dependency and coverage
* chore(lockfile): resolve axios peer lock entry after rebase
---------
Co-authored-by: lirui <lirui@fxiaoke.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* fix(feishu): chunk large documents for write/append to avoid API 400 errors
The Feishu API limits documentBlockChildren.create to 50 blocks per
request and document.convert has content size limits for large markdown.
Previously, writeDoc and appendDoc would send the entire content in a
single API call, causing HTTP 400 errors for long documents.
This commit adds:
- splitMarkdownByHeadings(): splits markdown at # or ## headings
- chunkedConvertMarkdown(): converts each chunk independently
- chunkedInsertBlocks(): batches blocks into groups of ≤50
Both writeDoc and appendDoc now use the chunked helpers while
preserving backward compatibility for small documents. Image
processing correctly receives all inserted blocks across batches.
* fix(feishu): skip heading detection inside fenced code blocks
Addresses review feedback: splitMarkdownByHeadings() now tracks
fenced code blocks (``` or ~~~) and skips heading-based splitting
when inside one, preventing corruption of code block content.
* Feishu/Docx: add convert fallback chunking + tests
---------
Co-authored-by: lml2468 <lml2468@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
In DM (p2p) chats, use message.create instead of message.reply
so that bot responses don't show a 'Reply to' quote. Group chats
retain the reply-to behavior for context clarity.
The typing indicator (emoji reaction on the user's message) is
preserved in DMs — only the reply reference in sent messages is
removed.
Changes:
- Add skipReplyToInMessages param to createFeishuReplyDispatcher
- In bot.ts, set skipReplyToInMessages: !isGroup for both dispatch sites
- In reply-dispatcher.ts, use sendReplyToMessageId (undefined for DMs)
for message sending while keeping replyToMessageId for typing indicator
When the sender-name lookup fails with a Feishu permission error (code
99991672), the bot was dispatching two separate agent turns:
1. A dedicated permission-error notification turn
2. The regular inbound user message turn
This caused two bot replies for a single user message, degrading UX and
wasting tokens.
Fix: instead of a separate dispatch, append the permission error notice
directly to the main messageBody. The agent receives both the user's
message and the system notice in a single turn, and responds once.
Fixes#27372
Previously feishu_doc always used accounts[0], so multi-account setups created docs under the first bot regardless of the calling agent.
This change resolves accountId via a before_tool_call hook (defaulting from agentAccountId) and selects the Feishu client per call.
Fixes#27321
The bitable tool registration was reading credentials directly from
top-level feishuCfg.appId/appSecret, missing the accounts.* path used
in multi-account mode. Align with drive.ts and wiki.ts by using
listEnabledFeishuAccounts() which handles both legacy and multi-account
configurations.
- Prefix memoryCache keys with namespace to prevent cross-account false
positives when different accounts receive the same message_id
- Add inflight tracking map to prevent TOCTOU race where concurrent
async calls for the same message both pass the check and both proceed
- Remove expired-entry deletion from has() to avoid silent cache/disk
divergence; actual cleanup happens probabilistically inside record()
- Add time-based cache invalidation (30s) to DedupStore.load() so
external writes are eventually picked up
- Refresh cacheLoadedAt after flush() so we don't immediately re-read
data we just wrote
Co-authored-by: Cursor <cursoragent@cursor.com>
Closes#23369
Feishu may redeliver the same message during WebSocket reconnects or process
restarts. The existing in-memory dedup map is lost on restart, so duplicates
slip through.
This adds a dual-layer dedup strategy:
- Memory cache (fast synchronous path, unchanged capacity)
- Filesystem store (~/.openclaw/feishu/dedup/) that survives restarts
TTL is extended from 30 min to 24 h. Disk writes use atomic rename and
probabilistic cleanup to keep each per-account file under 10 k entries.
Disk errors are caught and logged — message handling falls back to
memory-only behaviour so it is never blocked.
stripBotMention() passed mention.name and mention.key directly into
new RegExp() without escaping, allowing regex injection and ReDoS via
crafted Feishu mention metadata. extractMessageBody() in mention.ts
already escapes correctly — this applies the same pattern.
Ref: GHSA-c6hr-w26q-c636