Skip to content

feat(feishu): default to threaded replies in group chats - #22539

Open
ccccaqqqq wants to merge 4 commits into
NousResearch:mainfrom
ccccaqqqq:feature/feishu-auto-thread-groups
Open

feat(feishu): default to threaded replies in group chats#22539
ccccaqqqq wants to merge 4 commits into
NousResearch:mainfrom
ccccaqqqq:feature/feishu-auto-thread-groups

Conversation

@ccccaqqqq

Copy link
Copy Markdown

In Feishu group chats the bot currently posts every response into the main message stream, which floods the chat. Default to in-thread replies for regular group chats so each conversation collapses into its own topic.

Behavior:

  • Group chats (chat_mode="group"): replies are auto-threaded.
  • Direct messages (chat_mode="p2p"): unchanged.
  • Topic groups (chat_mode="topic"): unchanged — already topic-scoped.
  • Replies that already carry a thread_id: unchanged.
  • Sends without a parent message_id: unchanged (Feishu's API can't create a topic from a top-level send).

Opt out via:

feishu:
  reply_in_thread: false

The reply_in_thread key is already bridged to platform.extra by gateway/config.py for parity with Slack.

Implementation notes:

  • Adds a small _get_chat_mode helper that calls im.v1.chat.get and caches the chat_mode field per-chat in a dedicated dict.
  • Doesn't reuse _chat_info_cache["type"] because _map_chat_type maps the wrong API field (chat_type — group visibility, "private"/"public") as the chat-mode classifier and silently falls back to "dm" for groups. That pre-existing inconsistency is intentionally left alone in this patch and should be fixed separately.

What does this PR do?

Related Issue

Fixes #

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

How to Test

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform:

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

For New Skills

  • This skill is broadly useful to most users (if bundled) — see Contributing Guide
  • SKILL.md follows the standard format (frontmatter, trigger conditions, steps, pitfalls)
  • No external dependencies that aren't already available (prefer stdlib, curl, existing Hermes tools)
  • I've tested the skill end-to-end: hermes --toolsets skills -q "Use the X skill to do Y"

Screenshots / Logs

ccccaqqqq and others added 4 commits May 9, 2026 19:55
In Feishu group chats the bot currently posts every response into the
main message stream, which floods the chat. Default to in-thread replies
for regular group chats so each conversation collapses into its own
topic.

Behavior:
- Group chats (chat_mode="group"): replies are auto-threaded.
- Direct messages (chat_mode="p2p"): unchanged.
- Topic groups (chat_mode="topic"): unchanged — already topic-scoped.
- Replies that already carry a thread_id: unchanged.
- Sends without a parent message_id: unchanged (Feishu's API can't
  create a topic from a top-level send).

Opt out via:

    feishu:
      reply_in_thread: false

The reply_in_thread key is already bridged to platform.extra by
gateway/config.py for parity with Slack.

Implementation notes:
- Adds a small _get_chat_mode helper that calls im.v1.chat.get and
  caches the chat_mode field per-chat in a dedicated dict.
- Doesn't reuse _chat_info_cache["type"] because _map_chat_type maps
  the wrong API field (chat_type — group visibility, "private"/"public")
  as the chat-mode classifier and silently falls back to "dm" for
  groups. That pre-existing inconsistency is intentionally left alone
  in this patch and should be fixed separately.

Co-Authored-By: Claude <noreply@anthropic.com>
The previous commit only auto-threaded sends that already carried a
parent message id (effective_reply_to). In a fresh group chat the final
assistant reply went through the stream-consumer's reply path and was
threaded, but interim sends — tool-progress bubbles ("🐍 execute_code:
...", "💻 terminal: ..."), status callbacks, and approval cards — were
constructed in gateway/run.py with metadata that only included a reply
anchor when source.thread_id was already set. With no thread_id (the
common case for the first message in a new group), those sends arrived
at _send_raw_message with reply_to=None, bypassed the auto-thread
branch, and flooded the main feed alongside the threaded final reply.

Two-part fix:

1. FeishuAdapter remembers the most recent inbound user message id per
   chat (_last_user_message_id, OrderedDict capped at 2048 entries,
   skips bot-originated messages so the bot never anchors topics to
   its own posts). The auto-thread branch in _send_raw_message now
   falls back to this implicit anchor when no explicit reply_to was
   passed, so any future send path that forgets to plumb metadata
   still gets threaded in group chats. Also broadens the existing
   metadata.reply_to_message_id fallback to fire regardless of
   thread_id presence, so explicit anchors propagate too.

2. gateway/run.py: drop the `source.thread_id` precondition when
   building _status_thread_metadata for Feishu. The anchor (event
   message id) is now carried unconditionally so status callbacks and
   approval cards have an explicit anchor in addition to the implicit
   one above. Topic groups and DMs are unaffected — auto-threading
   only fires for chat_mode == "group".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_reply_anchor_for_event had a Feishu special case that, whenever
source.thread_id was set and the user's inbound message carried a
reply_to_message_id (i.e. the user quoted some earlier message), would
return the *quoted* message id as the bot's reply anchor instead of the
user's own message id. The anchor flowed downstream as event_message_id
(see gateway/run.py:9487) into the stream consumer's reply_to and the
status/approval metadata, so every bot send ended up visually attached
to the message the user quoted — not to the user's own request.

Symptom: in a group with topic-style replies enabled, user quotes an
unrelated meego link and @-mentions the bot to act on it; the bot's
threaded reply appears anchored under the meego link rather than under
the user's @-mention, which reads as the bot responding to the link.

Removing the special case lets _reply_anchor_for_event fall through to
the default (event.message_id, the user's own). The user's message is
in the same topic if the user is in one, so reply_in_thread=true still
keeps the bot inside the topic — the only thing that changes is the
visual anchor.

The original Feishu branch was added in passing by b323957
(fix(telegram): preserve DM topic routing via reply fallback) without a
stated rationale or Feishu-specific test coverage, so removing it is
safe; existing tests (only the Telegram branch is exercised) remain
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous patch (0148f0c) added a _last_user_message_id fallback in
_send_raw_message's auto-thread branch, but gated it on
``not reply_in_thread`` — assuming sends that already carry a thread_id
in metadata also carry an anchor. They don't always.

Specifically, _deliver_media_from_response builds metadata via
_thread_metadata_for_source which only emits ``{"thread_id": ...}`` for
Feishu (it never adds reply_to_message_id; that branch is Telegram-only).
A QR image sent through that path arrives at _send_raw_message with
reply_to=None and metadata={thread_id: omt_xxx}. The old code computed
``reply_in_thread = True`` from the metadata, then skipped the entire
auto-thread block (precondition was ``not reply_in_thread``), so the
implicit anchor was never read. effective_reply_to stayed None, and the
send fell through to the top-level branch where reply_in_thread is
silently ignored — image landed flat in the main feed.

Symptom: bytecli meego authorization QR (and any other media delivered
via _deliver_media_from_response) appeared top-level even though the
text/approval/tool-progress messages around it were correctly threaded.

Fix: re-gate the fallback on ``not effective_reply_to`` instead of
``not reply_in_thread``. The two cases now collapse:

  (a) no thread_id, no reply_to → anchor + set reply_in_thread (classic
      auto-thread to start a topic).
  (b) thread_id present, no reply_to → anchor + reply_in_thread already
      True (drop into the existing topic via the reply API, which is the
      only way Feishu lets a send land inside a topic).

Both paths now use the cached last user message id as the anchor and
go through _build_reply_message_body, so the bot send always reaches
the right topic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery platform/feishu Feishu / Lark adapter P3 Low — cosmetic, nice to have labels May 11, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the detailed Feishu threading work. The underlying regular-group behavior is still absent on current main, but this patch needs a targeted rework before it can be salvaged.

Problems

  • gateway/platforms/feishu.py is no longer the live adapter: commit 5600105478ffde29d7566b45421b100eaa29c4ef moved it to plugins/platforms/feishu/adapter.py. The PR's adapter changes would not land on the active implementation.
  • The new fallback is gated by not effective_reply_to. Normal final responses already pass an anchor in gateway/platforms/base.py:5005-5010, so the fallback is skipped. The live adapter sets reply_in_thread only from metadata["thread_id"] at plugins/platforms/feishu/adapter.py:4609-4621; a fresh regular-group final response therefore remains non-threaded.
  • No regression tests were added for fresh regular-group threading or the opt-out; current Feishu coverage at tests/gateway/test_feishu.py:2039-2077 covers only pre-existing thread metadata.

Suggested changes

  • Port the implementation to the bundled adapter and make group-mode resolution cover regular anchored replies as well as metadata-only sends.
  • Add cases for group, p2p, topic, explicit-thread, and opt-out behavior.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery 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-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants