Skip to content

fix(feishu): approval card never updates — use im.v1.message.patch instead of update #8847

@89ao

Description

@89ao

Bug

Feishu approval cards (interactive message cards with Approve/Deny buttons) never visually update after the user clicks a button or the request times out. The buttons remain active but non-functional, causing user confusion.

Root Cause

_update_approval_card() in gateway/platforms/feishu.py uses im.v1.message.update (PUT) to replace the card content. However, Feishu's message.update API only supports updating text and rich-text messages — it silently ignores updates to interactive (card) messages without raising an error.

The correct API for updating card messages is im.v1.message.patch (PATCH), which is specifically designed for interactive card updates. Additionally, the original card must be sent with "update_multi": true in its config for the patch API to work.

Feishu docs: https://open.feishu.cn/document/server-docs/im-v1/message-card/patch

Fix

1. Add PatchMessage imports

from lark_oapi.api.im.v1 import (
    ...
    PatchMessageRequest,
    PatchMessageRequestBody,
)

2. Add update_multi: true to card config when sending

In send_exec_approval():

card = {
    "config": {"wide_screen_mode": True, "update_multi": True},
    ...
}

3. Switch from message.update to message.patch in _update_approval_card()

# Before (broken — silently fails on interactive cards)
body = self._build_update_message_body(msg_type="interactive", content=payload)
request = self._build_update_message_request(message_id=message_id, request_body=body)
await asyncio.to_thread(self._client.im.v1.message.update, request)

# After (correct — uses patch API designed for cards)
body = self._build_patch_message_body(content=payload)
request = self._build_patch_message_request(message_id=message_id, request_body=body)
await asyncio.to_thread(self._client.im.v1.message.patch, request)

4. Add builder helpers

@staticmethod
def _build_patch_message_body(*, content: str) -> Any:
    if "PatchMessageRequestBody" in globals():
        return PatchMessageRequestBody.builder().content(content).build()
    return SimpleNamespace(content=content)

@staticmethod
def _build_patch_message_request(message_id: str, request_body: Any) -> Any:
    if "PatchMessageRequest" in globals():
        return PatchMessageRequest.builder().message_id(message_id).request_body(request_body).build()
    return SimpleNamespace(message_id=message_id, request_body=request_body)

Additional improvements needed for timeout/expired flows

  • Timeout callback infrastructure (tools/approval.py): Add register_gateway_timeout_notify() / _gateway_timeout_cbs so the adapter gets notified when a 5-minute approval timeout fires.
  • Gateway bridge (gateway/run.py): Add _approval_timeout_sync() using asyncio.run_coroutine_threadsafe to bridge sync agent thread to async adapter.
  • Feishu expire_approvals_for_session(): New method to iterate _approval_state, remove expired entries, update cards to grey "Timed Out".
  • Feishu stale button click: Extract open_message_id from callback context, update card to "Expired".
  • Telegram parity: Same timeout/expired logic for Telegram adapter.

Tested

  • Approve -> card turns green "Approved"
  • Deny -> card turns red "Denied"
  • Timeout (5 min) -> card turns grey "Timed Out" automatically
  • Stale button click -> card turns grey "Expired"

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium — degraded but workaround existscomp/gatewayGateway runner, session dispatch, deliveryplatform/feishuFeishu / Lark adaptersweeper:implemented-on-mainSweeper: behavior already present on current maintype/bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions