feat(channel): add bot-at-bot support#7
Conversation
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.
|
Addressed a round of review feedback ( Behavior / correctness
Roster / cache
Requests / safety
Docs: corrected the 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.
|
Addressed a second round of review feedback ( Message views
Roster correctness
Concurrency
Bot-at-bot
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. |
What & why
Support multiple bots collaborating in one chat — agents
@-ing each other tohand 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_botsemantics are unchanged.channel.get_bot_identity()→ the bot's ownBotIdentity(raisesFeishuChannelError(code="not_connected")beforeconnect()), e.g. to inlineinto a system prompt.
resolve_sender_namesfillsInboundMessage.sender_namefrom the chatroster.
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 servedby separate endpoints; this covers the bots).
channel.reply(msg, ...)— replies to the triggering message and keeps thereply in-thread when that message was in a topic thread.
@name → open_idresolution: name-only entries inOutboundText/OutboundPostmentions, and — with opt-inSendOpts.resolve_mentions_in_text—
@nametokens in a text/markdown body, resolved against the chat roster. Aname that is unknown or shared by more than one member is left as plain text
and never mis-mentioned.
resolve_chat_membershook to source the roster from your own directoryinstead of the API.
<at>output is hardened: attacker-influenced display names are escaped andopen_ids validated at every sink (the
@allsentinel is preserved).Policy & safety
PolicyConfigdocs clarifyallow_from(sender idsou_…) vsgroup_allowlist(chat idsoc_…); a misplaced app id (cli_…) now logs awarning instead of silently matching nothing.
policy.bot_loop_guardbreaks bot ↔ bot@-mention ping-pong loops(sliding window over "another bot @-ed me" messages; on trip either drops or
emits a
rejectwithreason="bot_loop"; a human message resets it). NewChatMember/BotLoopGuardConfigare exported.@-only mention (no body) is delivered as a valid wake —mentioned_botis
Truewith emptycontent_text, and the bot's own mention is stripped fromthe rendered content so
mentioned_bot and not content_text.strip()detects abare 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@nameincontent_text(it is stripped). NewChannelConfigfields are appended at theend, preserving positional construction. Version
1.1.0→1.2.0.Testing
(name-collision → ambiguous, TTL, capacity),
@nameresolution (includinglinear-time / ReDoS-safe scanning), the
<at>escaping/validation, the loopguard,
reply,get_chat_members/get_chat_bots(pagination, caching,chat_idURL-encoding), the allowlist warning, and the empty-@wake.sender_is_bot,
get_bot_identity,get_chat_members/get_chat_bots,reply,the
@-name handoff, the empty-@wake, and the loop-guard trip.