mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-15 22:29:04 +02:00
Expand an inbound Lark bot message's body before dispatch with the context
a user explicitly attached, so the agent sees a semantically complete
conversation instead of a bare "@bot 总结一下".
- post: flatten rich-text (title + paragraphs, links, @-mentions) to plain
text synchronously in the decoder.
- merge_forward: inline the forwarded transcript via a single GetMessage —
GET /open-apis/im/v1/messages/{id} returns the forward sentinel plus the
bundled children. (The issue's container_id_type=merge_forward query is
undocumented; this avoids it and also handles a forwarded quoted parent.)
- quoted reply: prepend the parent_id message as a <quoted_message> block;
a parent that is itself a forward nests a <forwarded_messages> block.
- new InboundEnricher runs in the WS connector between decode and emit,
bounded by EnrichTimeout and degrading to "[unable to fetch]" placeholders
so it never blocks the ~3s long-conn ACK budget.
/issue stays parseable on a quote-reply by parsing the command from the
user's own text (CommandBody) rather than the enriched body.
Short-window debounce batching (issue item #4) is tracked as a follow-up.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
275 lines
12 KiB
Go
275 lines
12 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|