mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 05:39:08 +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>
400 lines
18 KiB
Go
400 lines
18 KiB
Go
package lark
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
)
|
|
|
|
// APIClient is the narrow surface this package needs from the Lark Open
|
|
// Platform HTTP API. It is intentionally defined here (rather than
|
|
// taken from a vendor SDK) so the rest of the package can be built and
|
|
// unit-tested without dragging Lark's transport into every test, and
|
|
// so we can swap implementations (real SDK, stub, fake) without
|
|
// touching call sites.
|
|
//
|
|
// All methods are scoped to a single installation — the caller has
|
|
// already authenticated the installation row and decrypted its
|
|
// app_secret. The client never reads `lark_installation` itself.
|
|
type APIClient interface {
|
|
// IsConfigured reports whether this APIClient can reach Lark over
|
|
// the network. It is the "HTTP outbound is wired" signal: the stub
|
|
// returns false; the real Lark HTTP client returns true once
|
|
// instantiated. Handlers consult this when deciding whether to
|
|
// surface install / management UI that needs to talk to Lark.
|
|
IsConfigured() bool
|
|
|
|
// SendInteractiveCard posts an interactive card into a Lark chat
|
|
// and returns Lark's message_id for the card. The patcher persists
|
|
// this id in lark_outbound_card_message so subsequent patches can
|
|
// target the same card.
|
|
SendInteractiveCard(ctx context.Context, p SendCardParams) (string, error)
|
|
|
|
// PatchInteractiveCard replaces the body of a previously-sent card.
|
|
// The throttling decision belongs to the caller; this method just
|
|
// performs the network call.
|
|
PatchInteractiveCard(ctx context.Context, p PatchCardParams) error
|
|
|
|
// SendTextMessage posts a plain text message into a Lark chat.
|
|
// Used for the agent's chat reply when the body has no markdown
|
|
// syntax — short prose / acknowledgments / pings. A plain text
|
|
// bubble feels like a normal IM message; we deliberately keep
|
|
// this path even after adding the markdown card variant because
|
|
// wrapping a one-liner "Hello!" inside a card just adds visual
|
|
// chrome the user doesn't want.
|
|
SendTextMessage(ctx context.Context, p SendTextParams) (string, error)
|
|
|
|
// SendMarkdownCard posts the agent's reply as a Lark interactive
|
|
// card (schema 2.0) with a single `tag: "markdown"` body element.
|
|
// This is the path the chat-reply router takes when the body
|
|
// contains markdown syntax (fenced code blocks, headings, lists,
|
|
// tables, etc.) — Lark renders the markdown into formatted text
|
|
// rather than leaving raw `**bold**` / `# heading` characters in
|
|
// the user's transcript. Returns the card's message_id.
|
|
SendMarkdownCard(ctx context.Context, p SendMarkdownCardParams) (string, error)
|
|
|
|
// SendBindingPromptCard is the dedicated "you need to bind"
|
|
// outbound. Kept separate from SendInteractiveCard so the
|
|
// abstraction stays stable when the production card template
|
|
// changes — call sites in identity check don't have to know about
|
|
// Lark's card schema.
|
|
SendBindingPromptCard(ctx context.Context, p BindingPromptParams) error
|
|
|
|
// GetBotInfo returns the Bot's per-installation `open_id` (the
|
|
// `bot_open_id` we persist on lark_installation). RegistrationService
|
|
// is the only caller — after the device-flow registration returns
|
|
// fresh `client_id` / `client_secret`, the service mints a
|
|
// tenant_access_token with those creds and calls
|
|
// /open-apis/bot/v3/info to learn the Bot's identity. The result
|
|
// is then frozen into lark_installation alongside the app_id /
|
|
// app_secret in the same transaction as the installer-bind.
|
|
GetBotInfo(ctx context.Context, creds InstallationCredentials) (BotInfo, error)
|
|
|
|
// GetMessage fetches a message by id via
|
|
// GET /open-apis/im/v1/messages/{message_id}. Lark always returns an
|
|
// ARRAY (data.items[]): for a normal message exactly one element;
|
|
// for a `merge_forward` message the first element is the forward
|
|
// sentinel and the remaining elements are the bundled child messages
|
|
// (each a normal typed message linked back by upper_message_id). The
|
|
// inbound enricher relies on both shapes: items[0] for a quoted-reply
|
|
// parent, items[1:] for a forwarded transcript. Returning the raw
|
|
// slice keeps this method a thin transport adapter — flattening and
|
|
// block assembly are the enricher's job.
|
|
GetMessage(ctx context.Context, creds InstallationCredentials, messageID string) ([]LarkMessage, error)
|
|
|
|
// ListChatMessages fetches the most recent messages in a single chat
|
|
// via GET /open-apis/im/v1/messages?container_id_type=chat. It powers
|
|
// the group-context prefetch: when a user @-mentions the Bot in a busy
|
|
// group, the enricher pulls a bounded window of surrounding messages
|
|
// so the agent sees the conversation, not just the one @-ed line.
|
|
//
|
|
// Results come back newest-first (sort_type=ByCreateTimeDesc), capped
|
|
// at p.PageSize (Lark hard-caps a page at 50); the caller orders and
|
|
// trims for rendering. Only a single page is fetched — pagination is
|
|
// deliberately not exposed so the inbound ACK path's HTTP fan-out
|
|
// stays a single round-trip. Like GetMessage, this is a thin transport
|
|
// adapter: flattening and block assembly are the enricher's job.
|
|
ListChatMessages(ctx context.Context, creds InstallationCredentials, p ListMessagesParams) ([]LarkMessage, error)
|
|
|
|
// BatchGetUsers resolves a set of user open_ids to their display names
|
|
// via GET /open-apis/contact/v3/users/batch. The enricher uses it to
|
|
// label recent-context / quoted / forwarded speakers (and the sender
|
|
// who @-mentioned the Bot) with real names instead of positional
|
|
// "User 1 / User 2". Returns an open_id -> name map; ids the API does
|
|
// not return (restricted contact scope, deactivated user, …) are
|
|
// simply absent from the map, and the caller falls back to a
|
|
// positional label. openIDs beyond Lark's 50-per-call cap are dropped
|
|
// by the client.
|
|
BatchGetUsers(ctx context.Context, creds InstallationCredentials, openIDs []string) (map[string]string, error)
|
|
|
|
// AddMessageReaction adds an emoji reaction to an existing message.
|
|
// The standard use-case is the "Typing" indicator that signals the
|
|
// Bot is processing the user's message. Returns the reaction_id Lark
|
|
// assigns so it can be removed later.
|
|
AddMessageReaction(ctx context.Context, p AddReactionParams) (string, error)
|
|
|
|
// DeleteMessageReaction removes a previously-added reaction from a
|
|
// message. This is the cleanup half of the typing-indicator lifecycle.
|
|
DeleteMessageReaction(ctx context.Context, p DeleteReactionParams) error
|
|
}
|
|
|
|
// ListMessagesParams selects a bounded, recent window of messages in a
|
|
// single Lark chat for the group-context prefetch. Only the fields the
|
|
// enricher needs today are exposed (ChatID, PageSize, EndTime);
|
|
// start_time and page_token are intentionally omitted until a caller
|
|
// needs them.
|
|
type ListMessagesParams struct {
|
|
ChatID ChatID
|
|
// PageSize is how many of the most-recent messages to fetch. The
|
|
// client clamps it into Lark's valid 1..50 range.
|
|
PageSize int
|
|
// EndTime, when > 0, caps the window to messages created at or before
|
|
// this Unix timestamp in SECONDS (Lark's end_time is second-, not
|
|
// millisecond-, granularity). The enricher sets it to the trigger
|
|
// message's time so the prefetch is anchored to the @-mention moment
|
|
// rather than whatever is newest by the time the fetch runs.
|
|
EndTime int64
|
|
}
|
|
|
|
// LarkMessage is the normalized slice of an IM v1 message item the
|
|
// enricher needs. Body.content is passed through raw (still the
|
|
// JSON-encoded, msg_type-specific string Lark double-encodes) so the
|
|
// flattener — not the transport client — owns content interpretation.
|
|
type LarkMessage struct {
|
|
MessageID string
|
|
MessageType string // Lark `msg_type`: text / post / image / merge_forward / …
|
|
Content string // raw body.content (a JSON-encoded string)
|
|
SenderID string // sender.id (open_id for users, app_id for apps)
|
|
SenderType string // sender.sender_type: user / app / anonymous / …
|
|
CreateTime string // epoch milliseconds, as Lark returns it (a string)
|
|
ParentID string
|
|
RootID string
|
|
UpperMessageID string // the merge_forward parent a child hangs under
|
|
Deleted bool
|
|
Mentions []LarkMessageMention
|
|
}
|
|
|
|
// LarkMessageMention mirrors a mentions[] entry on the IM REST item
|
|
// shape. Note this differs from the WS receive event's mention shape:
|
|
// here `id` is a bare open_id string, not a nested {open_id, union_id,
|
|
// user_id} object.
|
|
type LarkMessageMention struct {
|
|
Key string // e.g. "@_user_1"
|
|
ID string // open_id
|
|
Name string // display name (may be empty)
|
|
}
|
|
|
|
// BotInfo is the slice of /open-apis/bot/v3/info (+ a follow-up
|
|
// /open-apis/contact/v3/users lookup for the union_id) we care about:
|
|
// the Bot's per-installation `open_id` and its stable `union_id`.
|
|
//
|
|
// Both identifiers are persisted on lark_installation:
|
|
//
|
|
// - `open_id` is the per-app Lark identifier; it is what /bot/v3/info
|
|
// returns and what the OUTBOUND send paths use to address a user.
|
|
//
|
|
// - `union_id` is the cross-app stable identifier scoped to the Lark
|
|
// tenant. It is the only field that is consistent across the two
|
|
// WS perspectives in a multi-bot group chat — see MUL-2671 group-
|
|
// @-mention triage. The decoder matches inbound `mentions[].id`
|
|
// against `union_id` so the right bot's supervisor handles the
|
|
// event when several bots are bound to the same group.
|
|
//
|
|
// Everything else /bot/v3/info returns (display name, avatar,
|
|
// activate_status, ip_white_list) is intentionally dropped — those
|
|
// can be re-fetched downstream from the bot_open_id if a UI needs
|
|
// them, and freezing them in our schema would create a drift surface
|
|
// every time the operator edits the Bot on Lark's side.
|
|
type BotInfo struct {
|
|
OpenID OpenID
|
|
UnionID string
|
|
}
|
|
|
|
// SendCardParams is the input shape for posting a fresh card.
|
|
type SendCardParams struct {
|
|
InstallationID InstallationCredentials
|
|
ChatID ChatID
|
|
// CardJSON is the raw Lark interactive card JSON body. We pass it
|
|
// through opaque so the card-template package can evolve without
|
|
// dragging this transport interface along.
|
|
CardJSON string
|
|
// ReplyTarget, when set, routes the send through Lark's reply
|
|
// endpoint (POST /im/v1/messages/{id}/reply) instead of the
|
|
// chat-level send endpoint, so the card lands inside the originating
|
|
// 话题 (thread). Empty ReplyTarget keeps the legacy chat-level send.
|
|
ReplyTarget ReplyTarget
|
|
}
|
|
|
|
// ReplyTarget describes how an outbound message should be threaded back
|
|
// to an inbound message. When MessageID is non-empty the transport uses
|
|
// Lark's reply endpoint targeting that message; InThread maps to the
|
|
// reply_in_thread flag so the reply stays inside the message's topic.
|
|
// The zero value (empty MessageID) means "send at the chat level" — the
|
|
// historical behavior — so callers that don't care about threading just
|
|
// leave it unset.
|
|
type ReplyTarget struct {
|
|
MessageID string
|
|
InThread bool
|
|
}
|
|
|
|
// IsSet reports whether this target should route through the reply
|
|
// endpoint. A reply needs a parent message_id; without one there is
|
|
// nothing to reply to and the caller falls back to a chat-level send.
|
|
func (r ReplyTarget) IsSet() bool { return r.MessageID != "" }
|
|
|
|
// PatchCardParams is the input shape for updating an existing card.
|
|
type PatchCardParams struct {
|
|
InstallationID InstallationCredentials
|
|
LarkCardMessageID string
|
|
CardJSON string
|
|
}
|
|
|
|
// SendTextParams is the input shape for posting a plain text message.
|
|
// Text is sent verbatim to Lark; the client handles JSON encoding of
|
|
// the `{"text": "..."}` content envelope Lark requires.
|
|
type SendTextParams struct {
|
|
InstallationID InstallationCredentials
|
|
ChatID ChatID
|
|
Text string
|
|
// ReplyTarget threads the text reply back into a Lark topic; see
|
|
// ReplyTarget. Empty keeps the chat-level send.
|
|
ReplyTarget ReplyTarget
|
|
}
|
|
|
|
// SendMarkdownCardParams is the input shape for posting an agent
|
|
// reply as a Lark interactive card with a markdown body element.
|
|
// Markdown is forwarded to Lark verbatim; the client builds the
|
|
// schema-2.0 card envelope around it.
|
|
type SendMarkdownCardParams struct {
|
|
InstallationID InstallationCredentials
|
|
ChatID ChatID
|
|
// Markdown is the body. Lark schema-2.0 markdown supports GFM-ish:
|
|
// **bold**, *italic*, `inline code`, fenced code blocks, headings,
|
|
// ordered + unordered lists, links, tables, blockquotes, separators.
|
|
Markdown string
|
|
// Summary, when non-empty, is rendered as the single-line preview
|
|
// Lark shows in the chat list / desktop notification. Empty falls
|
|
// back to whatever Lark derives from the body.
|
|
Summary string
|
|
// ReplyTarget threads the card reply back into a Lark topic; see
|
|
// ReplyTarget. Empty keeps the chat-level send.
|
|
ReplyTarget ReplyTarget
|
|
}
|
|
|
|
// BindingPromptParams carries the data needed to render and send the
|
|
// member-binding prompt card (single CTA: open the binding URL).
|
|
type BindingPromptParams struct {
|
|
InstallationID InstallationCredentials
|
|
OpenID OpenID
|
|
// BindURL is the absolute URL the user clicks. The token is
|
|
// embedded in the URL by the caller; the client never sees it.
|
|
BindURL string
|
|
}
|
|
|
|
// AddReactionParams is the input shape for adding an emoji reaction to
|
|
// a message.
|
|
type AddReactionParams struct {
|
|
InstallationID InstallationCredentials
|
|
MessageID string
|
|
EmojiType string
|
|
}
|
|
|
|
// DeleteReactionParams is the input shape for removing a previously-added
|
|
// reaction from a message.
|
|
type DeleteReactionParams struct {
|
|
InstallationID InstallationCredentials
|
|
MessageID string
|
|
ReactionID string
|
|
}
|
|
|
|
// InstallationCredentials is the per-installation transport context the
|
|
// client needs to authenticate against Lark on behalf of a workspace's
|
|
// bot. Passing these explicitly to each call (rather than constructing
|
|
// per-installation clients) keeps lifecycle simple: the hub decrypts
|
|
// app_secret once and reuses the struct for every outbound call.
|
|
//
|
|
// The plaintext app_secret lives inside this struct exactly while a
|
|
// call is in flight; callers MUST NOT log or persist it.
|
|
type InstallationCredentials struct {
|
|
AppID string
|
|
AppSecret string
|
|
TenantKey string
|
|
// Region selects the Lark open-platform host (Feishu mainland vs
|
|
// Lark international) for every call made with these credentials.
|
|
// Empty defaults to Feishu. Credential-build sites copy it from
|
|
// lark_installation.region; the device-flow installer sets it from
|
|
// the auto-detected tenant. This is what lets one deployment serve
|
|
// both clouds — see http_client.go resolveBaseURL and
|
|
// ws_endpoint.go Endpoint.
|
|
Region Region
|
|
}
|
|
|
|
// ErrAPIClientNotConfigured is returned by the stub client to signal
|
|
// that a real Lark client has not been wired in yet. Call sites SHOULD
|
|
// treat this as an expected condition on self-host deployments without
|
|
// a Lark app — log a warning, fall back to "Lark integration not
|
|
// configured", and continue serving other workspace functionality.
|
|
var ErrAPIClientNotConfigured = errors.New("lark: API client not configured")
|
|
|
|
// stubAPIClient is the default APIClient used when no production client
|
|
// has been registered. It refuses every transport call with
|
|
// ErrAPIClientNotConfigured so a misconfigured deployment fails loudly
|
|
// instead of silently dropping cards or device-flow registration
|
|
// responses.
|
|
//
|
|
// We deliberately do NOT silently succeed: a stub that returned ""
|
|
// message IDs would let the inbound dispatcher record bogus
|
|
// lark_outbound_card_message rows pointing at nothing.
|
|
type stubAPIClient struct {
|
|
log *slog.Logger
|
|
}
|
|
|
|
// NewStubAPIClient returns the default no-op APIClient. The hub
|
|
// constructs one of these when no real implementation has been
|
|
// supplied, so subsystems that depend on APIClient (outbound patcher,
|
|
// device-flow registration) can still wire up; their first call
|
|
// surfaces a clear error.
|
|
func NewStubAPIClient(log *slog.Logger) APIClient {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return &stubAPIClient{log: log}
|
|
}
|
|
|
|
func (s *stubAPIClient) IsConfigured() bool { return false }
|
|
|
|
func (s *stubAPIClient) SendInteractiveCard(ctx context.Context, p SendCardParams) (string, error) {
|
|
s.log.Warn("lark stub client: SendInteractiveCard called", "chat_id", string(p.ChatID))
|
|
return "", ErrAPIClientNotConfigured
|
|
}
|
|
|
|
func (s *stubAPIClient) PatchInteractiveCard(ctx context.Context, p PatchCardParams) error {
|
|
s.log.Warn("lark stub client: PatchInteractiveCard called", "card_message_id", p.LarkCardMessageID)
|
|
return ErrAPIClientNotConfigured
|
|
}
|
|
|
|
func (s *stubAPIClient) SendTextMessage(ctx context.Context, p SendTextParams) (string, error) {
|
|
s.log.Warn("lark stub client: SendTextMessage called", "chat_id", string(p.ChatID))
|
|
return "", ErrAPIClientNotConfigured
|
|
}
|
|
|
|
func (s *stubAPIClient) SendMarkdownCard(ctx context.Context, p SendMarkdownCardParams) (string, error) {
|
|
s.log.Warn("lark stub client: SendMarkdownCard called", "chat_id", string(p.ChatID))
|
|
return "", ErrAPIClientNotConfigured
|
|
}
|
|
|
|
func (s *stubAPIClient) SendBindingPromptCard(ctx context.Context, p BindingPromptParams) error {
|
|
s.log.Warn("lark stub client: SendBindingPromptCard called", "open_id", string(p.OpenID))
|
|
return ErrAPIClientNotConfigured
|
|
}
|
|
|
|
func (s *stubAPIClient) GetBotInfo(ctx context.Context, creds InstallationCredentials) (BotInfo, error) {
|
|
s.log.Warn("lark stub client: GetBotInfo called", "app_id", creds.AppID)
|
|
return BotInfo{}, ErrAPIClientNotConfigured
|
|
}
|
|
|
|
func (s *stubAPIClient) GetMessage(ctx context.Context, creds InstallationCredentials, messageID string) ([]LarkMessage, error) {
|
|
s.log.Warn("lark stub client: GetMessage called", "message_id", messageID)
|
|
return nil, ErrAPIClientNotConfigured
|
|
}
|
|
|
|
func (s *stubAPIClient) ListChatMessages(ctx context.Context, creds InstallationCredentials, p ListMessagesParams) ([]LarkMessage, error) {
|
|
s.log.Warn("lark stub client: ListChatMessages called", "chat_id", string(p.ChatID))
|
|
return nil, ErrAPIClientNotConfigured
|
|
}
|
|
|
|
func (s *stubAPIClient) BatchGetUsers(ctx context.Context, creds InstallationCredentials, openIDs []string) (map[string]string, error) {
|
|
s.log.Warn("lark stub client: BatchGetUsers called", "count", len(openIDs))
|
|
return nil, ErrAPIClientNotConfigured
|
|
}
|
|
|
|
func (s *stubAPIClient) AddMessageReaction(ctx context.Context, p AddReactionParams) (string, error) {
|
|
s.log.Warn("lark stub client: AddMessageReaction called", "message_id", p.MessageID, "emoji_type", p.EmojiType)
|
|
return "", ErrAPIClientNotConfigured
|
|
}
|
|
|
|
func (s *stubAPIClient) DeleteMessageReaction(ctx context.Context, p DeleteReactionParams) error {
|
|
s.log.Warn("lark stub client: DeleteMessageReaction called", "message_id", p.MessageID, "reaction_id", p.ReactionID)
|
|
return ErrAPIClientNotConfigured
|
|
}
|