Skip to content

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

Open
mazhe-nerd wants to merge 2 commits into
mainfrom
feature/channel-bot-at-bot-support
Open

feat(channel): add bot-at-bot support#7
mazhe-nerd wants to merge 2 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.

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.

1 participant