Files
multica/server/internal/integrations/lark/chat.go
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

136 lines
5.6 KiB
Go

package lark
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
// ChatSessionService is the channel-aware chat-session entry point for
// Lark. It exists deliberately apart from the HTTP `SendChatMessage`
// handler because that handler's single-creator semantics
// (chat_session.creator_id == request user_id) make sense for the
// browser/desktop client — one human, one session — but break for
// group chat_sessions where many Lark users converse with one Bot.
//
// Concrete implementation lands in a follow-up PR (MUL-2671). The
// interface is declared here so the migration + service boundary PR
// can establish the architectural cut without dragging in OAuth, WS,
// and card-patching code.
//
// Inbound contract (enforced by the implementation):
//
// - EnsureChatSession is the ONLY way Lark code creates / looks up a
// chat_session. Identity check MUST run before this call — the
// service treats every successful return as "the sender is a
// verified, workspace-bound user".
//
// - AppendUserMessage trusts that the caller has gated the message
// through identity + group-mention filters. Unbound users and
// non-addressed group messages do NOT come through here; they go
// to AuditDrop instead.
type ChatSessionService interface {
// EnsureChatSession returns the chat_session bound to the given
// (installation, lark_chat_id) pair, creating it on first contact.
// `sender` must already be a verified lark_user_binding row — see
// the contract note above. The returned UUID is the
// chat_session.id; callers persist no other state.
EnsureChatSession(ctx context.Context, p EnsureChatSessionParams) (pgtype.UUID, error)
// AppendUserMessage appends the message to chat_session, dedups
// via lark_inbound_message_dedup, and (when the message starts
// with `/issue`) returns the parsed command so the caller can
// dispatch through service.IssueService.Create.
AppendUserMessage(ctx context.Context, p AppendUserMessageParams) (AppendResult, error)
}
// EnsureChatSessionParams carries the inputs for ChatSessionService.EnsureChatSession.
// Note `Sender` is the resolved Multica user UUID — the caller has
// already mapped lark_open_id → user via lark_user_binding.
type EnsureChatSessionParams struct {
WorkspaceID pgtype.UUID
InstallationID pgtype.UUID
AgentID pgtype.UUID
ChatID ChatID
ChatType ChatType
Sender pgtype.UUID
}
// AppendUserMessageParams carries the inputs for ChatSessionService.AppendUserMessage.
// Body is the (already-decoded) user-facing text. LarkMessageID is the
// Lark-side message id used for idempotency dedup.
//
// ClaimToken is the owner-fencing token returned by the dispatcher's
// ClaimLarkInboundDedup call. When ClaimToken.Valid is true,
// AppendUserMessage runs MarkLarkInboundDedupProcessed INSIDE its own
// chat_message+session transaction, gated on this token. A mismatched
// token (another worker re-claimed the row while we were running)
// returns ErrClaimLost and rolls back the entire transaction, so no
// second chat_message can land for the same Lark message_id. Pass an
// invalid (zero) UUID to skip the in-tx Mark — useful for tests and
// for callers that have already finalized dedup outside the
// transaction.
type AppendUserMessageParams struct {
ChatSessionID pgtype.UUID
Sender pgtype.UUID
// Body is the full text stored as the chat_message — including any
// quoted-reply / forwarded context the enricher inlined.
Body string
// CommandBody is the user's own typed text, used as the `/issue`
// command source. It is the un-enriched Body; when empty (callers
// that don't set it), `/issue` parsing falls back to Body so
// behavior is unchanged for the non-enriched path.
CommandBody string
InstallationID pgtype.UUID
LarkMessageID string
// LarkThreadID is the Lark topic (话题) id of the trigger message, or
// empty for a normal chat message. AppendUserMessage records it (with
// LarkMessageID) on the chat binding so the outbound patcher can
// thread its reply back into the originating topic.
LarkThreadID string
ClaimToken pgtype.UUID
}
// AppendResult reports what AppendUserMessage decided.
//
// Dedup is enforced by the Dispatcher's top-level dedup gate before
// AppendUserMessage runs, so a returned AppendResult always
// represents a freshly-stored message. Callers may safely act on
// IssueCommand without re-checking idempotency.
type AppendResult struct {
// IssueCommand is non-nil when the first non-empty line begins
// with `/issue`. The caller passes this to
// service.IssueService.Create.
IssueCommand *IssueCommand
// DedupMarked is true when AppendUserMessage finalized the dedup
// claim in its own transaction (i.e. ClaimToken was supplied and
// the Mark succeeded). The dispatcher uses this to skip the
// post-pipeline finalize, since the row is already in its
// terminal state.
DedupMarked bool
}
// IssueCommand is the parsed shape of a user-typed `/issue ...`
// command. Title is required; Description is the joined remainder of
// the message body (empty when only a title was given).
type IssueCommand struct {
Title string
Description string
}
// AuditLogger records dropped inbound events to lark_inbound_audit.
// The interface deliberately does not accept a message body — see the
// drop-audit policy in MUL-2671 §4.7.
type AuditLogger interface {
RecordDrop(ctx context.Context, p AuditDropParams) error
}
type AuditDropParams struct {
InstallationID pgtype.UUID // may be invalid for installation-less events
ChatID ChatID
EventType string
LarkEventID string
LarkMessageID string
Reason DropReason
}