Skip to content

feat(channel): add replyFallbackOnWithdrawn config for recalled reply targets#512

Open
easonlh wants to merge 5 commits into
larksuite:mainfrom
easonlh:fix/reply-fallback-on-withdrawn-message
Open

feat(channel): add replyFallbackOnWithdrawn config for recalled reply targets#512
easonlh wants to merge 5 commits into
larksuite:mainfrom
easonlh:fix/reply-fallback-on-withdrawn-message

Conversation

@easonlh
Copy link
Copy Markdown
Contributor

@easonlh easonlh commented May 15, 2026

Summary

When replying to a message that has been withdrawn (error code 230011) or deleted (231003), the Feishu API returns a terminal error. Previously the reply was silently lost. This PR adds a configurable fallback strategy so the behavior can be tuned per deployment.

Scenarios

Scenario A — Group chat (reply may be valuable):
User A asks a question in a group chat. The bot starts processing (AI inference takes a few seconds). During that time, User A withdraws the message. Other group members are still waiting for the answer. With replyFallbackOnWithdrawn: 'top-level', the bot's response is delivered as a new message at the top level of the chat, so everyone can still see it.

Scenario B — Private chat / user intent (reply should be suppressed):
User sends a message, then realizes it was a mistake and withdraws it. The bot replies anyway, exposing content the user wanted to hide. With replyFallbackOnWithdrawn: 'silent' (default), the reply is silently discarded, respecting the user's intent.

Future improvement — Stop AI inference on withdrawal:
The ideal behavior is to stop AI inference immediately when the message is withdrawn, rather than waiting for inference to complete and then discarding the reply. This would save compute and respond faster. This requires core OpenClaw support for interrupting in-progress inference based on channel events (see openclaw/openclaw#XXXX).

Configuration

New optional config field under channels.feishu (or per-account):

{
  "channels": {
    "feishu": {
      "replyFallbackOnWithdrawn": "top-level"
    }
  }
}
Value Behavior
'silent' (default) Silently discard the reply — respects user intent
'top-level' Fall back to sending as a new message at the top level of the chat

Scope

This PR covers the static reply pathsendMessageFeishu(), sendCardFeishu(), and sendImMessage() in deliver.ts. These are the code paths where a reply is sent as a single API call with no subsequent updates.

Changes

  • src/core/config-schema.ts — Add replyFallbackOnWithdrawn enum field to FeishuAccountConfigSchema
  • src/messaging/outbound/send.tssendMessageFeishu() and sendCardFeishu(): read config, conditionally fallback or discard
  • src/messaging/outbound/deliver.tssendImMessage(): accept fallback mode param, pass from sendTextLark/sendCardLark; guard sentinel recording on silent discard; call markMessageUnavailable for cache symmetry with send.ts
  • tests/reply-fallback-mode.test.ts — Account-scoped config resolution + regression tests for sentinel leak and cache symmetry

How it works

Both send.ts functions already had separate reply and create code paths. The reply path is wrapped in a try/catch that only intercepts message-unavailable errors; all other errors propagate normally. When caught, the config is checked:

  • 'silent' → returns empty result immediately (default)
  • 'top-level' → execution falls through to the create path below

deliver.ts's sendImMessage catches the raw API error codes directly, with the fallback mode passed as a parameter from callers. On silent discard, sentinel recording is skipped (empty messageId = nothing was sent). The unavailable cache is updated symmetrically with send.ts's runWithMessageUnavailableGuard.

Known limitation

CardKit streaming path (sendCardByCardId in cardkit.ts): In streaming mode, the 230011 error may occur during card update (not initial send). The streaming controller catches MessageUnavailableError and falls back to sendCardFeishu, which also checks the unavailable cache — so the cache-based guard already short-circuits correctly. The explicit replyFallbackOnWithdrawn fallback logic does not apply here since the initial send already succeeded. This is an inherent limitation of the streaming architecture and does not affect the static reply paths covered by this PR.

Related

@evandance Key decisions to review:

  1. Default is now 'silent' — withdrawn message = no reply (safer for most users)
  2. 'top-level' is opt-in for group chat scenarios
  3. Future: stop AI inference on withdrawal (requires core support)

Test plan

  • pnpm lint — no new errors
  • pnpm test — 295 tests pass (including 4 new regression tests)
  • Manual test (silent, default): send a message, withdraw it → verify no reply
  • Manual test (top-level): set replyFallbackOnWithdrawn: 'top-level', repeat → verify reply at top level

@easonlh
Copy link
Copy Markdown
Contributor Author

easonlh commented May 15, 2026

@evandance

关于这个 PR 的产品策略想请教下:

核心问题: 引用的消息被撤回后,机器人是否还应该回复?

已实现的配置方案:

  • replyFallbackOnWithdrawn: 'top-level'(默认)— 回退到群聊顶层发送
  • replyFallbackOnWithdrawn: 'silent' — 静默丢弃

两个典型场景:

  1. 群聊:用户提问后撤回,但群里其他人还在等答案 → 回退到顶层发送有价值
  2. 私聊:用户发错了消息主动撤回,机器人再回复反而暴露了用户想隐藏的内容 → 静默丢弃更合理

待确认:

  1. 默认值用 'top-level' 是否合适?还是应该默认 'silent' 更安全?
  2. 是否需要增加 'auto' 模式(群聊→顶层、私聊→静默)?

请给意见,谢谢!

@easonlh
Copy link
Copy Markdown
Contributor Author

easonlh commented May 15, 2026

Updated: default changed to 'silent'.

理由:用户撤回消息 = 明确不想让内容可见,静默丢弃回复是最安全的默认行为。需要群聊回退的用户可以手动配置 'top-level'

另外,关于"消息撤回时应停止 AI 推理"的想法,我会向 OpenClaw 核心提一个 issue —— 这需要 channel 事件能中断正在进行的 inference,插件层面做不到。

Copy link
Copy Markdown
Collaborator

@evandance evandance left a comment

Choose a reason for hiding this comment

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

Thanks for the fix, and for already flipping the default to silent — agreed that withdrawal is a strong signal that the user may not want the original content surfaced again, so the conservative default is the right call.

A few things to address before merge:

  1. The new getReplyFallbackMode(cfg) only reads cfg.channels?.feishu.replyFallbackOnWithdrawn. It takes no accountId and does not resolve the merged account config via getLarkAccount() / createAccountScopedConfig(). The same files already use this account-scoping pattern elsewhere, so an account-level replyFallbackOnWithdrawn override is silently ignored today. This should be account-aware to match the schema and the PR description.

  2. The inline comments in send.ts (both sendMessageFeishu and sendCardFeishu) and in deliver.ts still describe top-level as the default. Please update them to match the current silent default.

  3. Please add focused coverage for fallback-mode resolution, particularly the per-account override case: default silent, top-level override, and account-scoped override.

The auto mode you raised is an interesting follow-up, but I don't think it needs to be part of this PR. The streaming card-update path and the "stop AI inference on withdrawal" piece are upstream / separate-PR concerns, so it makes sense to keep those out of this scope.

Minor: ReplyFallbackMode and getReplyFallbackMode are declared inside the import block in send.ts; please move them below the imports.

@evandance evandance added bug Something isn't working messaging src/messaging/ + src/card/ — message rendering, cards, streaming core src/core/ — accounts, owner-policy, token, security, config changes requested Need do changes labels May 15, 2026
@easonlh easonlh requested a review from evandance May 15, 2026 09:50
easonlh added 4 commits May 15, 2026 20:59
When replying to a message that has been withdrawn (230011) or deleted
(231003), the Feishu API returns a terminal error. Previously the reply
was silently lost. Now all three send paths (sendMessageFeishu,
sendCardFeishu, sendImMessage) catch these errors and fall back to
sending the message at the top level of the chat.

Reuses existing isMessageUnavailableError / isTerminalMessageApiCode
from core/message-unavailable.ts.
Add a configurable strategy for handling replies to withdrawn/deleted
messages:

- 'top-level' (default): fall back to sending as a new message
- 'silent': silently discard the reply

This addresses the product concern that in private chats, replying to
a withdrawn message may expose content the user intended to hide,
while in group chats the fallback is valuable for other participants.
When a message is withdrawn, the user's intent is clear — they don't
want the content to be visible. Silently discarding the reply is the
safer default. Users who need top-level fallback in group chats can
opt in via config.
- Move ReplyFallbackMode/getReplyFallbackMode below imports in send.ts
- Add accountId parameter to getReplyFallbackMode in both send.ts and deliver.ts
- Use createAccountScopedConfig for per-account override resolution
- Update comments to reflect 'silent' as the default value
- Add tests for account-scoped fallback mode resolution
@easonlh easonlh force-pushed the fix/reply-fallback-on-withdrawn-message branch from 3b7e0f5 to 5bd7226 Compare May 15, 2026 13:00
Copy link
Copy Markdown
Collaborator

@evandance evandance left a comment

Choose a reason for hiding this comment

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

Round-1 asks all landed cleanly in 5bd7226 — thanks. Going through it a second time, four items need to clear before merge.

1. Sentinel store leak under default 'silent' (src/messaging/outbound/deliver.ts, sendTextLark). When sendImMessage silently discards a reply and returns { messageId: '', chatId: '' }, the call site still unconditionally runs recordSentinelsForChat(...), registering pending @-mention sentinels for a message that was never sent. Please skip recordSentinelsForChat on discarded sends — either check for an empty messageId in the result, or move the call into the success branch inside sendImMessage.

2. Asymmetric message-unavailable cache between deliver.ts and send.ts. send.ts wraps the API call in runWithMessageUnavailableGuard, so markMessageUnavailableFromError updates the cache before the error is thrown. The new catch block in deliver.ts reads the raw error via extractLarkApiCode(err) and never calls markMessageUnavailable, so subsequent operations on the same recalled messageId keep round-tripping the API. Please call markMessageUnavailable({ messageId: replyToMessageId, apiCode, operation }) in the terminal-code branch in deliver.ts so both paths stay symmetric.

3. CardKit streaming initial-send is uncovered. The known-limitation note in the PR body mentions mid-stream card updates. The more common pre-card flow lands in streaming-card-controller.ts:~885, which calls sendCardByCardId in cardkit.ts — that function isn't touched by this PR. So replyFallbackOnWithdrawn: 'top-level' doesn't apply to the most common streaming-card initial-create path. Please either add coverage for sendCardByCardId, or update the PR body to scope the fallback explicitly to the static reply path (sendMessageFeishu / sendCardFeishu / sendImMessage).

4. Title and label. Default 'silent' preserves current main behavior on recall (clean abort via UnavailableGuard.terminate() plus a warn log), so the user-visible new value is the opt-in 'top-level' mode. That makes it functionally a feat: rather than a fix:. Please retitle (e.g. feat(channel): add replyFallbackOnWithdrawn config for recalled reply targets); I'll update the label from bug to feature request after the retitle.

Tests for round 3: a regression test asserting 'silent' does NOT write to the sentinel store for a discarded reply, and a test asserting the deliver.ts catch path marks the unavailable cache symmetrically with send.ts.

Two non-blocking nits, can be follow-ups: getReplyFallbackMode is duplicated between send.ts and deliver.ts with identical implementation; and the as Record<string, unknown> cast in the helper bypasses what FeishuConfigSchema.extend(...) should produce on the type side.

If the next push addresses all four blockers with the test coverage above, this is ready to merge. If any remain open after one more round, I'll close this and ask for a fresh PR scoped to the correctness fixes only.

…t discard

- Skip sentinel recording when sendImMessage returns empty messageId
  (silent discard path) to prevent stale sentinel entries
- Call markMessageUnavailable in deliver.ts catch block for terminal
  codes (230011/231003), matching send.ts runWithMessageUnavailableGuard
- Add regression tests: silent-discard sentinel guard and cache symmetry
@easonlh easonlh changed the title fix: fallback to top-level send when reply target is recalled or deleted feat(channel): add replyFallbackOnWithdrawn config for recalled reply targets May 16, 2026
@easonlh easonlh requested a review from evandance May 17, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working changes requested Need do changes core src/core/ — accounts, owner-policy, token, security, config messaging src/messaging/ + src/card/ — message rendering, cards, streaming

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants