Skip to content

fix: route feishu thread sends via reply API instead of invalid thread_id receive_id - #75940

Open
Cassius0924 wants to merge 2 commits into
NousResearch:mainfrom
Cassius0924:fix/feishu-thread-file-send-99992402
Open

fix: route feishu thread sends via reply API instead of invalid thread_id receive_id#75940
Cassius0924 wants to merge 2 commits into
NousResearch:mainfrom
Cassius0924:fix/feishu-thread-file-send-99992402

Conversation

@Cassius0924

@Cassius0924 Cassius0924 commented Aug 1, 2026

Copy link
Copy Markdown

Problem

Sending files/media into a Feishu group thread fails with [99992402] field validation failed when the send has no reply anchor. See issue for repro.

Root cause

The Feishu message.create API only accepts chat_id / open_id / user_id as receive_id. When media/file delivery carries only thread_id (no reply anchor), _send_raw_message() built a create request with receive_id=thread_id, which the API rejects with [99992402]. The existing retry only covered audio messages, so file/media sends failed outright.

Fix

Never build the invalid request. In _send_raw_message(), the thread_id fallback branch now:

  1. Lists the thread via ListMessageRequest (container_id_type=thread, page_size=1) and replies to the last message with reply_in_thread=true (message lands in the topic)
  2. If the thread is empty, logs a warning and falls back to a plain chat_id create, keeping the feishu_user_id:user_id and ou_open_id receive_id mapping

This fixes all message types (file/media/audio/captioned post payloads) at the source — no per-type retry guards needed. Credit to @kevinjihk for this approach (comment on the earlier revision).

Verification

  • Added 3 regression tests: test_source_fix_thread_no_anchor_lists_then_replies (never calls create with thread_id), test_source_fix_thread_no_anchor_no_last_msg_creates_chat_id (empty thread falls back to chat_id with user_id mapping) and test_source_fix_caption_thread_no_anchor_lists_then_replies (captioned post path gets the same routing)
  • Full tests/gateway/test_feishu.py suite: 79 passed
  • Live test: sending a .js file into a thread now succeeds with no error in gateway logs

Fixes #75939

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins platform/feishu Feishu / Lark adapter labels Aug 1, 2026

@teknium1 teknium1 left a comment

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.

Thanks for tracing the existing audio fallback and extending the actual reported document path; current main still has the audio-only guard at plugins/platforms/feishu/adapter.py:4711-4714.

Problems

  • The retry remains inside the non-caption branch (plugins/platforms/feishu/adapter.py:4701-4738). Captioned file/video sends take the post path at plugins/platforms/feishu/adapter.py:4688-4700 and still have no 99992402 recovery. send_animation() always reaches that captioned document path at plugins/platforms/feishu/adapter.py:2366-2374.
  • The no-anchor test's create mock always fails with 99992402 (tests/gateway/test_feishu.py:1352-1358) and the test only checks the final request (tests/gateway/test_feishu.py:1399-1401), so it does not establish that the fallback succeeds.

Suggested changes

  • Share the retry across both initial payload branches and cover a captioned file/media send.
  • Make the second create call succeed in the no-anchor fixture, then assert result.success.

Automated hermes-sweeper review.

if (not self._response_succeeded(message_response)
and getattr(message_response, "code", None) == 99992402
and resolved_message_type == "audio"

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.

Removing this guard fixes the non-captioned document/video path, but this retry is still entirely inside the else branch. Captioned file/video sends use the preceding post branch and continue to return 99992402 without recovery; please share the retry across both payload branches.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Resolved — the PR was reworked to a source-level fix. The retry guard (and its per-branch limitation) is gone: _send_uploaded_file_message's caption and non-caption branches both call _feishu_send_with_retry_send_raw_message, which no longer builds the invalid receive_id=thread_id request at all (it lists the thread and replies to the last message instead). Captioned file/video sends can no longer hit 99992402. Added test_source_fix_caption_thread_no_anchor_lists_then_replies covering the captioned post path.

Comment thread tests/gateway/test_feishu.py Outdated
def create(self, request):
calls.append(("create", request))
return SimpleNamespace(
success=lambda: False,

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.

This fixture returns 99992402 for both creates, while the test only verifies the final request. Make the chat-id create succeed and assert result.success so the regression proves the fallback delivers rather than merely routes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Resolved — the old no-anchor fixture was removed with the retry approach. The replacement test test_source_fix_thread_no_anchor_no_last_msg_creates_chat_id makes the chat_id create succeed and asserts result.success, so the regression proves the fallback delivers (and preserves the user_id receive_id mapping).

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Aug 1, 2026
@kevinjihk

Copy link
Copy Markdown

Thanks for this PR — this is the exact failure I hit on a multi-profile deployment (Feishu gateway, thread_id routing, 99992402 on non-audio sends). I am totally new to coding/github but my deepseek LLM seems to have a different approach to this, so I took some courage and post it here in case it might help.

Our approach: fix at the _send_raw_message level instead of the retry level

Instead of extending the 99992402 retry to more message types, we changed the fallback path in _send_raw_message (the topic/thread branch that previously passed thread_id as receive_id) to:

Try to anchor the reply on the last message in the thread via ListMessageRequest (container_id_type="thread", page_size=1) — the existing _fetch_last_message_in_thread helper.

If an anchor is found → use the reply API with reply_in_thread=True (message lands in the topic, never falls to main chat).

If no anchor → log a warning, then fall back to plain chat_id create honoring the feishu_user_id: → user_id and ou_ → open_id mapping.

This fixes the problem at the source (create-with-thread_id is invalid for all message types), so captioned/video/file/audio all route correctly — no per-type retry guards needed. The thread-id fallback branch is in _send_raw_message around line 4786 in current main.

Diff (against 40e0e7a, the commit we were on)
@@ -4786,34 +4786,44 @@ class FeishuAdapter(BasePlatformAdapter):
request = self._build_reply_message_request(effective_reply_to, body)
return await self._run_blocking(self._client.im.v1.message.reply, request)

  •    # For topic/thread messages that fell back from reply→create, use
    
  •    # thread_id as receive_id so the message lands in the topic instead of
    
  •    # the main chat.
    
  •    # For topic/thread messages that fell back from reply→create, try to
    
  •    # anchor the reply on the last message in the thread so the message
    
  •    # lands in the topic instead of the main chat. Feishu's create API
    
  •    # does not accept thread_id as receive_id_type, so use the reply API.
       _thread_id = (metadata or {}).get("thread_id")
       if _thread_id:
    
  •        body = self._build_create_message_body(
    
  •            receive_id=_thread_id,
    
  •            msg_type=msg_type,
    
  •            content=payload,
    
  •            uuid_value=str(uuid.uuid4()),
    
  •        )
    
  •        request = self._build_create_message_request("thread_id", body)
    
  •    else:
    
  •        receive_id = chat_id
    
  •        receive_id_type = "chat_id"
    
  •        if chat_id.startswith("feishu_user_id:"):
    
  •            receive_id = chat_id.split(":", 1)[1]
    
  •            receive_id_type = "user_id"
    
  •        elif chat_id.startswith("ou_"):
    
  •            receive_id_type = "open_id"
    
  •        body = self._build_create_message_body(
    
  •            receive_id=receive_id,
    
  •            msg_type=msg_type,
    
  •            content=payload,
    
  •            uuid_value=str(uuid.uuid4()),
    
  •        _thread_msg_id = await self._fetch_last_message_in_thread(_thread_id)
    
  •        if _thread_msg_id:
    
  •            body = self._build_reply_message_body(
    
  •                content=payload,
    
  •                msg_type=msg_type,
    
  •                reply_in_thread=True,
    
  •                uuid_value=str(uuid.uuid4()),
    
  •            )
    
  •            request = self._build_reply_message_request(_thread_msg_id, body)
    
  •            return await self._run_blocking(self._client.im.v1.message.reply, request)
    
  •        logger.warning(
    
  •            "[Feishu] No last message found in thread %s, "
    
  •            "falling back to chat_id create",
    
  •            _thread_id,
           )
    
  •        request = self._build_create_message_request(receive_id_type, body)
    
  •        _thread_id = None
    
  •    receive_id = chat_id
    
  •    receive_id_type = "chat_id"
    
  •    if chat_id.startswith("feishu_user_id:"):
    
  •        receive_id = chat_id.split(":", 1)[1]
    
  •        receive_id_type = "user_id"
    
  •    elif chat_id.startswith("ou_"):
    
  •        receive_id_type = "open_id"
    
  •    body = self._build_create_message_body(
    
  •        receive_id=receive_id,
    
  •        msg_type=msg_type,
    
  •        content=payload,
    
  •        uuid_value=str(uuid.uuid4()),
    
  •    )
    
  •    request = self._build_create_message_request(receive_id_type, body)
       return await self._run_blocking(self._client.im.v1.message.create, request)
    

Trade-off vs. this PR

Our approach fixes the routing for all message types at the source (one branch, no per-type guards), but it adds a list API call on every thread message without a reply anchor (1 extra round-trip in the fallback path only).

This PR's retry approach is cheaper for the happy path (no extra call unless 99992402), at the cost of per-type guard maintenance.

Happy to adjust either direction — just wanted to make sure the captioned/file branch coverage question had a concrete option. Pardon me if the reply is not as professional as others.

@Cassius0924
Cassius0924 force-pushed the fix/feishu-thread-file-send-99992402 branch from a355cc9 to 043bd98 Compare August 2, 2026 11:16
@Cassius0924 Cassius0924 changed the title fix: retry feishu file/media sends via reply API when thread routing fails with 99992402 fix: route feishu thread sends via reply API instead of invalid thread_id receive_id Aug 2, 2026
@Cassius0924

Copy link
Copy Markdown
Author

Thanks for this suggestion — it's the right call. I applied your source-level approach and reworked the PR: _send_raw_message() now lists the thread and replies to the last message (falling back to a plain chat_id create when the thread is empty, keeping the user_id/open_id mapping). No more per-type retry guards; verified with 2 regression tests (78 passed) and a live send into a thread (no errors in logs).

Updated PR: #75940

Cassius0924 added 2 commits August 6, 2026 20:40
…d_id receive_id

The Feishu message.create API does not accept thread_id as receive_id, so
sends into a thread without a reply anchor (media/file delivery metadata
carries only thread_id) failed with [99992402] field validation failed.

Fix _send_raw_message to never build that invalid request: when a thread_id
is present but no reply anchor exists, list the thread via ListMessageRequest
and reply to the last message (reply_in_thread=true); if the thread is empty,
fall back to a plain chat_id create (keeping the feishu_user_id:/ou_
receive_id mapping).

This fixes all message types (file/media/audio/captioned post payloads) at
the source instead of per-type retry guards.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/feishu Feishu / Lark adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Feishu file/media sends into a thread fail with [99992402] field validation failed

4 participants