mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 04:25:46 +02:00
* 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>
136 lines
5.6 KiB
Go
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
|
|
}
|