Skip to content

fix(feishu): comprehensive reply mechanism — outbound replyToId forwarding + topic-aware reply targeting#33789

Merged
Takhoffman merged 7 commits intoopenclaw:mainfrom
guoqunabc:fix/feishu-reply-mechanism
Mar 5, 2026
Merged

fix(feishu): comprehensive reply mechanism — outbound replyToId forwarding + topic-aware reply targeting#33789
Takhoffman merged 7 commits intoopenclaw:mainfrom
guoqunabc:fix/feishu-reply-mechanism

Conversation

@guoqunabc
Copy link
Copy Markdown
Contributor

@guoqunabc guoqunabc commented Mar 4, 2026

Summary

Root cause: PR #29968 changed the reply target from ctx.messageId to ctx.rootId ?? ctx.messageId. This is correct for topic-mode groups, but in normal groups it causes replies to silently enter topic threads invisible in the main chat view. This PR auto-detects the correct reply target by checking groupSessionScope and replyInThread config — no new config options needed.

Change Type

  • Bug fix

Scope

  • Integrations

Linked Issue/PR

User-visible / Behavior Changes

  1. Outbound reply forwarding: Messages sent via the message tool with replyTo now correctly create Feishu reply messages.
  2. Normal group reply targeting: Bot replies to the triggering message instead of the thread root, keeping replies visible in the main chat view.
  3. Topic-mode groups: No behavior change — replies still target the topic root as expected.

Security Impact

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Steps (Problem 2 — #32980)

  1. Configure a normal Feishu group (no topic mode, no replyInThread)
  2. User quote-replies to a message and @mentions the bot
  3. Bot reply was directed to root_id → enters invisible topic thread
  4. After fix: bot replies to message_id → visible in main chat

Steps (Problem 1 — outbound replyToId)

  1. Use message tool to send a Feishu message with replyTo parameter
  2. Before fix: replyToId was silently dropped
  3. After fix: message is sent as a reply to the specified message

Evidence

  • 60 tests passing (10 outbound + 50 bot), including new regression tests
  • pnpm check passes (format + types + lint)

Human Verification

  • Verified: outbound replyToId forwarding through both text and card render paths
  • Verified: normal group with root_id replies to triggering message
  • Verified: topic-mode group with root_id replies to topic root
  • Verified: explicit replyInThread: "enabled" preserves root-targeting behavior
  • Not verified: live Feishu delivery (no test tenant available in CI)

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

Failure Recovery

  • Revert this commit to restore previous behavior.
  • Watch for: bot replies going to wrong message in either normal or topic-mode groups.

Relation to Other PRs

PR Status Overlap Our approach
#33748 Closed Added replyTarget config option Auto-detects via groupSessionScope — no new config needed
#33575 Open Outbound replyToId/threadId forwarding Included in this PR (Problem 1 fix)
#33011 Open Changed to always use ctx.messageId We preserve rootId for topic-mode groups and explicit replyInThread

@openclaw-barnacle openclaw-barnacle bot added channel: feishu Channel integration: feishu size: M labels Mar 4, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Greptile Summary

This PR fixes two related Feishu reply-mechanism bugs: (1) outbound replyToId was silently dropped in sendText/sendMedia — it is now correctly forwarded to all downstream send functions; (2) the blanket ctx.rootId ?? ctx.messageId reply target introduced in #29968 was causing normal group replies to silently enter topic threads — the PR replaces this with a topic-aware decision that uses rootId only for group_topic/group_topic_sender scopes or when replyInThread is explicitly enabled in config.

  • outbound.ts: replyToId (typed string | null | undefined) is null-coerced to replyToMessageId and threaded through sendOutboundText, sendMarkdownCardFeishu, sendMessageFeishu, and sendMediaFeishu. Style note: when sendMedia is called with both a text caption and a media URL, both messages are sent with the same replyToMessageId, producing duplicate reply-preview headers in Feishu — worth a follow-up to decide if only the first message should carry the reply marker.
  • bot.ts: The new isTopicSession + configReplyInThread logic correctly separates the "admin-configured reply-in-thread" flag from the auto-detected threadReply flag (which fires on any message with a root_id), preventing accidental thread-push in normal groups.
  • Tests: New tests cover both new behaviors well; group_topic_sender is the one scope in isTopicSession without a dedicated test case.

Confidence Score: 4/5

  • This PR is safe to merge — all changes are backward compatible and no critical logic errors were found.
  • The core logic is sound: the topic-aware reply-targeting and the replyToId forwarding are both correctly implemented and well-tested. The minor gaps are: the group_topic_sender scope lacks a dedicated test, and the dual-reply-header UX when text+media are both sent with a replyToId deserves a follow-up design decision. Neither is a blocker.
  • extensions/feishu/src/outbound.ts — the dual-reply behavior when both text and mediaUrl are provided with replyToId.

Last reviewed commit: 0d50406

textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId }) => {
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const replyToMessageId = replyToId ?? undefined;
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.

Redundant null-coercing assignment

replyToId is typed as string | null | undefined (from ChannelOutboundContext). The expression replyToId ?? undefined converts nullundefined but this intermediate variable adds a layer of indirection with no explanation. Consider either a comment or an inline cast at the call sites:

Suggested change
const replyToMessageId = replyToId ?? undefined;
const replyToMessageId = replyToId ?? undefined; // coerce null → undefined for downstream APIs

The same pattern appears at line 101 in sendMedia. Both are semantically correct but the intent would be clearer with a brief inline comment, since a reader unfamiliar with the type might assume this is a no-op.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/outbound.ts
Line: 70

Comment:
**Redundant null-coercing assignment**

`replyToId` is typed as `string | null | undefined` (from `ChannelOutboundContext`). The expression `replyToId ?? undefined` converts `null``undefined` but this intermediate variable adds a layer of indirection with no explanation. Consider either a comment or an inline cast at the call sites:

```suggestion
    const replyToMessageId = replyToId ?? undefined; // coerce null → undefined for downstream APIs
```

The same pattern appears at line 101 in `sendMedia`. Both are semantically correct but the intent would be clearer with a brief inline comment, since a reader unfamiliar with the type might assume this is a no-op.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +1553 to 1596
rootId: "om_original_msg",
}),
);
});

it("replies to topic root in topic-mode group with root_id", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);

const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;

const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-topic-user" } },
message: {
message_id: "om_topic_reply",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello in topic group" }),
},
};

await dispatchMessage({ cfg, event });

expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_topic_root",
rootId: "om_topic_root",
}),
);
});

it("forces thread replies when inbound message contains thread_id", async () => {
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.

Missing test for group_topic_sender scope

The new tests cover group_topic (line 1555) and the normal group scope (line 1520), but group_topic_sender is equally handled by the isTopicSession branch:

const isTopicSession =
  isGroup &&
  (groupSession?.groupSessionScope === "group_topic" ||
    groupSession?.groupSessionScope === "group_topic_sender");

A test mirroring the group_topic case with groupSessionScope: "group_topic_sender" would ensure this path isn't inadvertently broken by future changes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/bot.test.ts
Line: 1553-1596

Comment:
**Missing test for `group_topic_sender` scope**

The new tests cover `group_topic` (line 1555) and the normal `group` scope (line 1520), but `group_topic_sender` is equally handled by the `isTopicSession` branch:

```typescript
const isTopicSession =
  isGroup &&
  (groupSession?.groupSessionScope === "group_topic" ||
    groupSession?.groupSessionScope === "group_topic_sender");
```

A test mirroring the `group_topic` case with `groupSessionScope: "group_topic_sender"` would ensure this path isn't inadvertently broken by future changes.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Additional Comments (1)

extensions/feishu/src/outbound.ts
Dual reply-preview headers when text + media are both sent

When sendMedia is called with both a non-empty text caption and a mediaUrl, the caption is sent as a reply to replyToMessageId and the image is also sent as a reply to the same replyToMessageId. In Feishu's UI this renders two independent messages, each showing a "reply preview" quote of the original message — a potentially cluttered appearance.

A common alternative is to mark only the first (text caption) message as the explicit reply and let the image follow as a plain message in the same conversation. If the intent is to always quote the original for both messages, a code comment explaining this decision would help future readers understand why replyToMessageId is forwarded to both calls.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/outbound.ts
Line: 103-124

Comment:
**Dual reply-preview headers when text + media are both sent**

When `sendMedia` is called with both a non-empty `text` caption and a `mediaUrl`, the caption is sent as a reply to `replyToMessageId` and the image is also sent as a reply to the same `replyToMessageId`. In Feishu's UI this renders two independent messages, each showing a "reply preview" quote of the original message — a potentially cluttered appearance.

A common alternative is to mark only the first (text caption) message as the explicit reply and let the image follow as a plain message in the same conversation. If the intent is to always quote the original for both messages, a code comment explaining this decision would help future readers understand why `replyToMessageId` is forwarded to both calls.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

…orwarding + topic-aware reply targeting

- Forward replyToId from ChannelOutboundContext through sendText/sendMedia
  to sendMessageFeishu/sendMarkdownCardFeishu/sendMediaFeishu, enabling
  reply-to-message via the message tool.

- Fix group reply targeting: use ctx.messageId (triggering message) in
  normal groups to prevent silent topic thread creation (openclaw#32980). Preserve
  ctx.rootId targeting for topic-mode groups (group_topic/group_topic_sender)
  and groups with explicit replyInThread config.

- Add regression tests for both fixes.

Fixes openclaw#32980
Fixes openclaw#32958
Related openclaw#19784
@guoqunabc guoqunabc force-pushed the fix/feishu-reply-mechanism branch from 0d50406 to 45cef46 Compare March 4, 2026 02:36
Jimmy-xuzimo and others added 5 commits March 3, 2026 22:26
…l targets

- Add normalizeDeliveryTarget helper to strip user:/chat: prefixes for Feishu
- Apply normalization in matchesMessagingToolDeliveryTarget before comparison
- This ensures cron duplicate suppression works when session uses prefixed targets
  (user:ou_xxx) but messaging tool extract uses normalized bare IDs (ou_xxx)

Fixes review comment on PR openclaw#32755

(cherry picked from commit fc20106)
The Feishu Lark SDK can throw exceptions (SDK errors with .code or
AxiosErrors with .response.data.code) for withdrawn/deleted reply
targets, in addition to returning error codes in the response object.

Wrap reply calls in sendMessageFeishu and sendCardFeishu with
try-catch to handle thrown withdrawn/not-found errors (230011,
231003) and fall back to client.im.message.create, matching the
existing response-level fallback behavior.

Also extract sendFallbackDirect helper to deduplicate the
direct-send fallback block across both functions.

Closes openclaw#33496

(cherry picked from commit ad0901a)
@Takhoffman Takhoffman merged commit 63ce7c7 into openclaw:main Mar 5, 2026
27 of 28 checks passed
mrosmarin added a commit to mrosmarin/openclaw that referenced this pull request Mar 5, 2026
* main:
  fix(feishu): comprehensive reply mechanism — outbound replyToId forwarding + topic-aware reply targeting (openclaw#33789)
  fix: restore auto-reply system events timeline (openclaw#34794) (thanks @anisoptera) (openclaw#34794)
  docs(changelog): document dependency security fixes
  fix(deps): bump tar to 7.5.10
  fix(security): avoid prototype-chain account path checks (openclaw#34982)
  fix(deps): patch hono transitive audit vulnerabilities
  fix: add spanish locale support (openclaw#35038) (thanks @DaoPromociones)
  fix: finalize spanish locale support
  Changelog: add gateway restart health entry (openclaw#34874)
  Changelog: add daemon systemd user-bus fallback entry (openclaw#34884)
  fix: align AGENTS.md template section names with post-compaction extraction (openclaw#25029) (openclaw#25098)
  agents: preserve totalTokens on request failure instead of using contextWindow (openclaw#34275)
  Fix Linux daemon install checks when systemd user bus env is missing (openclaw#34884)
Kansodata pushed a commit to Kansodata/openclaw that referenced this pull request Mar 5, 2026
…rding + topic-aware reply targeting (openclaw#33789)

* fix(feishu): comprehensive reply mechanism fix — outbound replyToId forwarding + topic-aware reply targeting

- Forward replyToId from ChannelOutboundContext through sendText/sendMedia
  to sendMessageFeishu/sendMarkdownCardFeishu/sendMediaFeishu, enabling
  reply-to-message via the message tool.

- Fix group reply targeting: use ctx.messageId (triggering message) in
  normal groups to prevent silent topic thread creation (openclaw#32980). Preserve
  ctx.rootId targeting for topic-mode groups (group_topic/group_topic_sender)
  and groups with explicit replyInThread config.

- Add regression tests for both fixes.

Fixes openclaw#32980
Fixes openclaw#32958
Related openclaw#19784

* fix: normalize Feishu delivery.to before comparing with messaging tool targets

- Add normalizeDeliveryTarget helper to strip user:/chat: prefixes for Feishu
- Apply normalization in matchesMessagingToolDeliveryTarget before comparison
- This ensures cron duplicate suppression works when session uses prefixed targets
  (user:ou_xxx) but messaging tool extract uses normalized bare IDs (ou_xxx)

Fixes review comment on PR openclaw#32755

(cherry picked from commit fc20106)

* fix(feishu): catch thrown SDK errors for withdrawn reply targets

The Feishu Lark SDK can throw exceptions (SDK errors with .code or
AxiosErrors with .response.data.code) for withdrawn/deleted reply
targets, in addition to returning error codes in the response object.

Wrap reply calls in sendMessageFeishu and sendCardFeishu with
try-catch to handle thrown withdrawn/not-found errors (230011,
231003) and fall back to client.im.message.create, matching the
existing response-level fallback behavior.

Also extract sendFallbackDirect helper to deduplicate the
direct-send fallback block across both functions.

Closes openclaw#33496

(cherry picked from commit ad0901a)

* feishu: forward outbound reply target context

(cherry picked from commit c129a69)

* feishu extension: tighten reply target fallback semantics

(cherry picked from commit f85ec61)

* fix(feishu): align synthesized fallback typing and changelog attribution

* test(feishu): cover group_topic_sender reply targeting

---------

Co-authored-by: Xu Zimo <[email protected]>
Co-authored-by: Munem Hashmi <[email protected]>
Co-authored-by: bmendonca3 <[email protected]>
Co-authored-by: Tak Hoffman <[email protected]>