Skip to content

feat(channel): add bot-at-bot support#7

Merged
mazhe-nerd merged 3 commits into
mainfrom
feature/channel-bot-at-bot-support
Jul 14, 2026
Merged

feat(channel): add bot-at-bot support#7
mazhe-nerd merged 3 commits into
mainfrom
feature/channel-bot-at-bot-support

Conversation

@mazhe-nerd

Copy link
Copy Markdown
Collaborator

What & why

Support multiple bots collaborating in one chat — agents @-ing each other to
hand off work. The channel layer sits between the low-level OpenAPI SDK and the
app, so it's where the extra signals and guards a bot-to-bot handoff needs
belong. Everything here is opt-in and additive; default behavior is
unchanged.

Ports the bot-at-bot support from the Node channel SDK
(larksuite/channel-sdk-node#10) to the Python SDK.

Changes (SDK-facing)

Context — know who sent a message

  • InboundMessage.sender_type ('user' | 'bot' | 'system' | 'anonymous' | 'app')
    and the convenience sender_is_bot, passed through from the raw event
    (previously dropped). Identity.is_bot semantics are unchanged.
  • channel.get_bot_identity() → the bot's own BotIdentity (raises
    FeishuChannelError(code="not_connected") before connect()), e.g. to inline
    into a system prompt.
  • Opt-in resolve_sender_names fills InboundMessage.sender_name from the chat
    roster.

Roster & outbound

  • channel.get_chat_members(chat_id) — a chat's users (paginated, cached).
  • channel.get_chat_bots(chat_id) — a chat's bots (users and bots are served
    by separate endpoints; this covers the bots).
  • channel.reply(msg, ...) — replies to the triggering message and keeps the
    reply in-thread when that message was in a topic thread.
  • @name → open_id resolution: name-only entries in OutboundText /
    OutboundPost mentions, and — with opt-in SendOpts.resolve_mentions_in_text
    @name tokens in a text/markdown body, resolved against the chat roster. A
    name that is unknown or shared by more than one member is left as plain text
    and never mis-mentioned.
  • resolve_chat_members hook to source the roster from your own directory
    instead of the API.
  • <at> output is hardened: attacker-influenced display names are escaped and
    open_ids validated at every sink (the @all sentinel is preserved).

Policy & safety

  • PolicyConfig docs clarify allow_from (sender ids ou_…) vs
    group_allowlist (chat ids oc_…); a misplaced app id (cli_…) now logs a
    warning instead of silently matching nothing.
  • Opt-in policy.bot_loop_guard breaks bot ↔ bot @-mention ping-pong loops
    (sliding window over "another bot @-ed me" messages; on trip either drops or
    emits a reject with reason="bot_loop"; a human message resets it). New
    ChatMember / BotLoopGuardConfig are exported.
  • An @-only mention (no body) is delivered as a valid wake — mentioned_bot
    is True with empty content_text, and the bot's own mention is stripped from
    the rendered content so mentioned_bot and not content_text.strip() detects a
    bare poke.

Compatibility

New fields are optional, new methods/options are additive, and every new
behavior is opt-in (off by default). Without opting in, receive/send/connect/
normalization behavior is identical to the previous release, except that a
message which @-mentions the bot no longer renders the bot's own @name in
content_text (it is stripped). New ChannelConfig fields are appended at the
end, preserving positional construction. Version 1.1.01.2.0.

Testing

  • Unit tests for each new surface: sender-type normalization, the roster cache
    (name-collision → ambiguous, TTL, capacity), @name resolution (including
    linear-time / ReDoS-safe scanning), the <at> escaping/validation, the loop
    guard, reply, get_chat_members/get_chat_bots (pagination, caching,
    chat_id URL-encoding), the allowlist warning, and the empty-@ wake.
  • An end-to-end double-bot scenario against a real chat exercises sender_type/
    sender_is_bot, get_bot_identity, get_chat_members/get_chat_bots, reply,
    the @-name handoff, the empty-@ wake, and the loop-guard trip.

Support multiple bots collaborating in one chat (@-ing each other to hand off
work). All additive and opt-in; default receive/send/normalize behavior is
unchanged.

- InboundMessage.sender_type / sender_is_bot: pass through the raw
  sender.sender_type (previously dropped); Identity.is_bot semantics unchanged.
- get_bot_identity(): the bot's own identity; raises not_connected before connect.
- get_chat_members() / get_chat_bots(): chat roster (users / bots), paginated
  and cached; resolve_chat_members hook to source the roster externally.
- reply(msg, ...): replies to the trigger and follows its thread shape.
- @name -> open_id: name-only structured mentions and, with
  resolve_mentions_in_text, @name tokens in text/markdown are resolved against
  the chat roster; unknown/ambiguous names are left as plain text.
- <at> hardening: escape attacker-controlled display names and validate open_ids
  at every <at> sink (the @ALL sentinel is preserved).
- policy.bot_loop_guard: opt-in sliding-window guard against bot ping-pong,
  drop or reject (reason bot_loop).
- allow_from / group_allowlist: warn on a misplaced cli_ app id.

Version 1.1.0 -> 1.2.0.
…y P2)

Resolves the blocking deep-review of PR #7:

- content_text default restored (keeps the bot's own rendered @-mention);
  new InboundMessage.body_text carries the bot-mention-stripped view.
- reply(): long thread replies keep every chunk in-thread (not just the first).
- chunker never splits an <at>...</at> tag across message boundaries.
- chat member cache: keyed by (chat_id, id_type); name->open_id index only from
  open_id members; a full API refresh rebuilds the index (drops departed members,
  re-evaluates name collisions); pagination tracks completeness + warns on
  truncation; repeated page_token guard.
- roster collection / sender-name resolution moved after safety admission, so a
  rejected message can't write the roster or trigger the members API.
- members request: validates HTTP status + JSON shape (no empty-success on
  errors, no leaked AttributeError); tenant-token verify runs off the event loop
  with a total timeout and unified error wrapping.
- SendOpts.resolve_mentions_in_text accepted from dict opts.
- runtime update_policy() atomically reconfigures the bot loop guard;
  loop-guard config validation + monotonic-clock out-of-order handling.
- docs: correct the include_bot permission name; document body_text and the
  resolve_chat_members empty-list semantics.
@mazhe-nerd

Copy link
Copy Markdown
Collaborator Author

Addressed a round of review feedback (a2545a7). Highlights, all backward-compatible:

Behavior / correctness

  • content_text default is now unchanged again — it keeps the rendered mention (including the bot's own). A new InboundMessage.body_text carries the bot-mention-stripped view for command parsing / bare-@ wake detection (mentioned_bot and not body_text.strip()).
  • reply() keeps every chunk of a long reply in the thread (previously only the first chunk stayed in-thread; the rest fell to the main timeline).
  • The plain-text chunker never splits an <at>…</at> tag across message boundaries.

Roster / cache

  • Member cache is keyed by (chat_id, id_type) and the name→open_id index is built only from open_id members, so a user_id query can't leak the wrong id type.
  • A full (force=True) refresh rebuilds the index from the fresh snapshot — departed members drop out and resolved name collisions become resolvable again. Pagination tracks completeness and warns on truncation.
  • Mention collection and sender_name resolution now run only after the safety pipeline admits a message, so a rejected message can't seed the roster or trigger the members API.

Requests / safety

  • The members request validates HTTP status and JSON shape (a non-2xx or non-object body is a typed error, not an empty-success cache entry); tenant-token verification runs off the event loop with a total timeout.
  • policy.bot_loop_guard is reconfigured atomically on update_policy() (runtime enable/disable and threshold changes now take effect); config values are validated and out-of-order timestamps handled.
  • resolve_mentions_in_text is honored when passed via a dict opts (not just SendOpts).

Docs: corrected the im:message.group_at_msg.include_bot:readonly permission name; documented body_text and the resolve_chat_members empty-list semantics.

A few lower-risk items (extra roster hardening, singleflight, thread-safety, name-scan boundary refinements) are tracked as follow-ups. Full test suite green across Python 3.8–3.12, plus an end-to-end double-bot run against a real chat.

- body_text: recombined across default batching (merge_batch) and derived from
  the AST for post content (drops only the bot's own <at>, keeps title/format).
- roster completeness now enforced: a truncated page result never feeds the
  name index, overwrites a complete snapshot, or is served from cache;
  max_pages<=0 is rejected; repeated page_token guarded.
- API refresh reconciles mention observations (departed / renamed / de-duplicated
  members are no longer resurrected by a stale observation).
- member cache: per-id_type snapshots (a user_id query no longer clobbers the
  open_id name index); resolve_chat_members hook takes (chat_id, id_type),
  validates the returned id_type, runs sync hooks off the loop, bounds async
  hooks with a timeout; empty-list semantics reconciled across code/docs.
- concurrency: LocalCache (global token store) is now lock-guarded and evicts
  with pop() (fixes a KeyError race exposed by off-loop token verify); roster
  fetches are singleflighted; ChatMemberCache is lock-guarded and returns
  defensive copies.
- bot-at-bot: resolve_sender_names warms get_chat_bots for bot senders; a human
  message resets the loop guard before the policy gate (so a no-mention human
  message still breaks a ping-pong); LoopGuard is lock-guarded so runtime
  reconfigure is atomic vs record.
@mazhe-nerd

Copy link
Copy Markdown
Collaborator Author

Addressed a second round of review feedback (9186f7b). All backward-compatible; every new capability stays opt-in.

Message views

  • body_text is now correct on the two paths it was missing: it's recombined across the default batch-aggregation window (batched messages no longer blank content_text / body_text), and for post content it's derived at the AST level — dropping only the current bot's own <at> node while keeping the title, formatting, links and everyone else's mentions. content_text is unchanged.

Roster correctness

  • A truncated member list (paging hit max_pages while more remained, or a repeated page token) is never cached, never feeds the name→open_id index, and never overwrites a complete snapshot — so a partial roster can't make a shared name look unique and mis-mention. max_pages < 1 is rejected.
  • A full refresh reconciles cached mention observations: departed / renamed / de-duplicated members are no longer resurrected by a stale observation, and the API's own mapping wins over an observed alias.
  • Member snapshots are keyed by id_type, so a user_id query no longer clobbers the open_id name index. The resolve_chat_members hook can take (chat_id, id_type), its returned id_type is validated, sync hooks run off the event loop, and async hooks are bounded by a timeout.

Concurrency

  • The process-global token cache is now lock-guarded and evicts with pop() (fixes a KeyError race that off-loop token verification could hit under concurrent roster requests).
  • Roster fetches for the same (chat, id_type) are single-flighted; the member cache is lock-guarded and returns defensive copies so callers can't mutate the cached fact source. The chunker's <at> scan is linear-time.

Bot-at-bot

  • resolve_sender_names now resolves a bot sender's name (it warms the bots roster, which the users list omits).
  • A human message resets the bot loop guard before the mention gate, so a plain (no-mention) human message can still break an ongoing ping-pong; the guard is lock-guarded so a runtime update_policy reconfigure is atomic.

Full suite green across Python 3.8–3.12; end-to-end double-bot run against a real chat passes. A few efficiency / clean-up items remain as tracked follow-ups.

@mazhe-nerd
mazhe-nerd merged commit 731d459 into main Jul 14, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants