Files
multica/server/migrations/122_lark_chat_session_binding_thread_reply.up.sql
lethean-kun 0aa3b53c25 MUL-3378 feat(lark): reply inside the originating thread (话题) instead of the group (#4262)
* feat(lark): reply inside the originating thread (话题) instead of the group

When a user @-mentions the bot inside a Lark topic/thread, the bot now
replies back into that thread rather than posting a fresh message at the
chat level. Behavior is automatic and scoped: only triggers that were
themselves inside a thread get a threaded reply, so normal group/p2p
chats are unchanged.

The outbound path is event-driven and decoupled from the inbound
message, so the trigger message_id + thread_id are persisted on
lark_chat_session_binding (migration 122) at ingest time. The patcher
then routes the agent reply (text / markdown card / error card) and the
OutcomeReplier notices (/issue confirmation, offline/archived) through
Lark's reply endpoint with reply_in_thread=true when a thread is present,
falling back to a chat-level send if the threaded reply fails.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lark): classify thread-reply failures before chat-level fallback

Only retry a threaded reply at the chat level when Lark returns an
explicit "this message/topic cannot receive a threaded reply" error
(recalled trigger, topic gone, topics disabled, aggregated message,
etc.). Transport errors, 5xx, timeouts, rate limits, and ambiguous
failures are now logged and returned as failures instead of being
retried, so we never duplicate a reply or leak a thread-only reply
into the main group chat.

The three reply-capable send methods now return a structured *APIError
carrying the Lark business code, and isThreadReplyUnsupported drives
the fallback via an allowlist. sendWithThreadFallback is promoted to a
package-level function so the immediate OutcomeReplier sends (/issue
confirmation, offline/archived notices) share the same classified
fallback path instead of silently swallowing thread-reply failures.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: kun <kuen@micous.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 13:34:40 +08:00

24 lines
1.3 KiB
SQL

-- Thread-aware outbound: remember the most recent inbound trigger
-- message (and the Lark topic / thread it belongs to, if any) per chat
-- binding so the decoupled outbound patcher can thread its reply back
-- into the originating 话题 (thread) instead of always posting a fresh
-- message at the chat level.
--
-- The outbound side is event-driven and disconnected from the inbound
-- message: it only knows the chat_session → lark_chat_session_binding
-- (i.e. the lark_chat_id). Persisting the latest trigger here is what
-- lets EventChatDone resolve a reply target without plumbing the
-- message id through the whole task lifecycle.
--
-- Both columns are nullable:
-- * last_lark_message_id is the message_id the reply quotes / replies
-- to (Lark's reply endpoint keys off it).
-- * last_lark_thread_id is the message's thread_id; Lark only sends a
-- thread_id for messages that are part of a topic, so a NULL/empty
-- value means "the last trigger was a normal chat message" and the
-- outbound keeps the existing chat-level send behavior. Only when a
-- thread_id is present does the patcher switch to a thread reply.
ALTER TABLE lark_chat_session_binding
ADD COLUMN last_lark_message_id TEXT,
ADD COLUMN last_lark_thread_id TEXT;