Files
multica/server/internal/integrations/lark/outcome_replier.go
苗大 93ed3dc131 fix(lark): use app URL for web links (MUL-3679)
Point Lark binding and issue-created links at the web app URL (MULTICA_APP_URL)
instead of the backend public URL, so users on split-domain / self-host
deployments get working links. appURLFromEnv falls back to FRONTEND_ORIGIN,
matching the backend's existing app-URL resolution.
2026-06-25 15:14:09 +08:00

383 lines
14 KiB
Go

package lark
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// OutcomeReplier reacts to the Dispatcher's verdict by posting the
// appropriate Lark-side reply card. This is the outbound half of the
// `EventEmitter` contract in hub.go: NeedsBinding sends the binding
// prompt to the sender's open_id, AgentOffline / AgentArchived send
// a status notice into the chat. OutcomeIngested is owned by the
// Patcher (task lifecycle); OutcomeDropped is silent.
//
// Reply is best-effort by design: a transient Lark outage MUST NOT
// fail the inbound pipeline (the message is already durable in
// chat_session by the time we get here for OutcomeIngested, and for
// the other outcomes there is no durable side effect to undo). Errors
// are logged and swallowed; the next inbound message for the same
// user retries the reply on its own.
type OutcomeReplier interface {
Reply(ctx context.Context, inst Installation, msg InboundMessage, res DispatchResult)
}
// OutcomeReplierQueries is the narrow subset of *db.Queries the
// replier needs. Pinned via an interface so tests substitute a fake.
type OutcomeReplierQueries interface {
GetAgent(ctx context.Context, id pgtype.UUID) (db.Agent, error)
}
// BindingTokenMinter is the narrow dependency the outcome replier needs from
// BindingTokenService. Keeping this as an interface lets tests pin the Lark
// binding URL without constructing a database-backed token service.
type BindingTokenMinter interface {
Mint(ctx context.Context, workspaceID, installationID pgtype.UUID, openID OpenID) (BindingToken, error)
}
// noopReplier is the safe default when Lark is wired without an
// outbound APIClient (stub) or without a BindingTokenService. It
// logs each outcome that would have produced a reply so an operator
// can see the gap in production logs.
type noopReplier struct {
log *slog.Logger
}
func (n *noopReplier) Reply(ctx context.Context, inst Installation, msg InboundMessage, res DispatchResult) {
switch res.Outcome {
case OutcomeNeedsBinding, OutcomeAgentOffline, OutcomeAgentArchived:
n.log.Warn("lark outcome replier: outbound reply skipped (replier not wired)",
"outcome", string(res.Outcome),
"installation_id", uuidString(inst.ID),
"chat_id", string(msg.ChatID),
"open_id", string(msg.SenderOpenID),
)
}
}
// NewNoopOutcomeReplier returns the no-op replier. Used as the
// fallback when the production wiring is incomplete (e.g. stub
// APIClient, no binding token service).
func NewNoopOutcomeReplier(log *slog.Logger) OutcomeReplier {
if log == nil {
log = slog.Default()
}
return &noopReplier{log: log}
}
// LarkOutcomeReplier is the production OutcomeReplier. It composes:
//
// - APIClient — to send the binding prompt card (open_id-targeted)
// and the offline/archived notice cards (chat_id-targeted).
// - BindingTokenService — to mint a one-shot binding token for the
// NeedsBinding flow.
// - CredentialsResolver — to decrypt app_secret per call (the
// plaintext secret never lives on the in-memory installation row).
// - OutcomeReplierQueries — for the agent name shown on cards.
//
// The replier is constructed once at boot and shared across the Hub's
// supervisor goroutines; all dependencies must be goroutine-safe
// (the standard implementations are).
type LarkOutcomeReplier struct {
client APIClient
bindingSvc BindingTokenMinter
credentials CredentialsResolver
queries OutcomeReplierQueries
appURL string // e.g. https://multica.example, trailing slash trimmed
bindingPath string // path component of the binding URL, default "/lark/bind"
noticeHeader string // header text used by the offline/archived cards
log *slog.Logger
}
// OutcomeReplierConfig wires the production replier. AppURL is the Multica web
// app host the user clicks into to redeem the binding token or open an issue
// (e.g. https://multica.example). It comes from MULTICA_APP_URL and is
// intentionally separate from MULTICA_PUBLIC_URL, which is the backend/API
// public URL used for webhook and daemon-facing endpoints. Empty means the
// binding flow can only log the open_id, not produce a clickable card. The
// other fields default at construction.
type OutcomeReplierConfig struct {
APIClient APIClient
BindingSvc BindingTokenMinter
Credentials CredentialsResolver
Queries OutcomeReplierQueries
AppURL string
BindingPath string
Logger *slog.Logger
}
// NewLarkOutcomeReplier validates the configuration and returns the
// production replier. Missing dependencies fall back to noop so the
// boot path stays robust on partially-configured deployments.
func NewLarkOutcomeReplier(cfg OutcomeReplierConfig) OutcomeReplier {
log := cfg.Logger
if log == nil {
log = slog.Default()
}
if cfg.APIClient == nil || cfg.BindingSvc == nil || cfg.Credentials == nil || cfg.Queries == nil {
return NewNoopOutcomeReplier(log)
}
if !cfg.APIClient.IsConfigured() {
log.Warn("lark outcome replier: APIClient.IsConfigured()=false; downgrading to noop replier")
return NewNoopOutcomeReplier(log)
}
if cfg.AppURL == "" {
log.Warn("lark outcome replier: MULTICA_APP_URL not set; binding prompt CTA will not work")
}
bindingPath := cfg.BindingPath
if bindingPath == "" {
bindingPath = "/lark/bind"
}
if !strings.HasPrefix(bindingPath, "/") {
bindingPath = "/" + bindingPath
}
return &LarkOutcomeReplier{
client: cfg.APIClient,
bindingSvc: cfg.BindingSvc,
credentials: cfg.Credentials,
queries: cfg.Queries,
appURL: strings.TrimRight(cfg.AppURL, "/"),
bindingPath: bindingPath,
noticeHeader: "Multica",
log: log,
}
}
// Reply implements OutcomeReplier. Reads carefully — the switch is
// the SOURCE OF TRUTH for which outcomes generate a reply, and a
// missing branch silently drops the user-visible side effect.
func (r *LarkOutcomeReplier) Reply(ctx context.Context, inst Installation, msg InboundMessage, res DispatchResult) {
switch res.Outcome {
case OutcomeNeedsBinding:
if err := r.sendBindingPrompt(ctx, inst, res); err != nil {
r.log.Warn("lark outcome replier: binding prompt failed",
"installation_id", uuidString(inst.ID),
"open_id", string(res.SenderOpenID),
"err", err.Error(),
)
}
case OutcomeAgentOffline:
if err := r.sendChatNotice(ctx, inst, msg, agentOfflineCopy); err != nil {
r.log.Warn("lark outcome replier: offline notice failed",
"installation_id", uuidString(inst.ID),
"chat_id", string(msg.ChatID),
"err", err.Error(),
)
}
case OutcomeAgentArchived:
if err := r.sendChatNotice(ctx, inst, msg, agentArchivedCopy); err != nil {
r.log.Warn("lark outcome replier: archived notice failed",
"installation_id", uuidString(inst.ID),
"chat_id", string(msg.ChatID),
"err", err.Error(),
)
}
case OutcomeIngested:
// The agent's chat reply itself goes through the Patcher (text
// message on ChatDone). But /issue does NOT block on the
// agent — the user expects an immediate "Created [MUL-42]"
// confirmation as soon as the issue row commits, separate
// from whatever the agent eventually replies. Without this,
// the user types `/issue fix login bug` and just sees the
// agent's eventual response, with no clear signal that the
// command itself was understood. Gate on IssueID.Valid so a
// plain chat message (no /issue) stays silent here.
if res.IssueID.Valid {
if err := r.sendIssueCreated(ctx, inst, msg, res); err != nil {
r.log.Warn("lark outcome replier: issue-created confirmation failed",
"installation_id", uuidString(inst.ID),
"chat_id", string(msg.ChatID),
"issue_id", uuidString(res.IssueID),
"err", err.Error(),
)
}
}
case OutcomeDropped:
// OutcomeDropped is informational; no user-visible reply.
}
}
func (r *LarkOutcomeReplier) sendBindingPrompt(ctx context.Context, inst Installation, res DispatchResult) error {
if res.SenderOpenID == "" {
return errors.New("missing sender open_id")
}
if r.appURL == "" {
return errors.New("app_url not configured")
}
token, err := r.bindingSvc.Mint(ctx, inst.WorkspaceID, inst.ID, res.SenderOpenID)
if err != nil {
return fmt.Errorf("mint binding token: %w", err)
}
bindURL := r.appURL + r.bindingPath + "?token=" + url.QueryEscape(token.Raw)
creds, err := r.installationCredentials(inst)
if err != nil {
return err
}
return r.client.SendBindingPromptCard(ctx, BindingPromptParams{
InstallationID: creds,
OpenID: res.SenderOpenID,
BindURL: bindURL,
})
}
// sendIssueCreated posts the "Created [MUL-42] <title>" confirmation
// as a plain text message. We deliberately send text rather than an
// interactive card so the confirmation flows inline with the rest of
// the Lark conversation — consistent with how chat replies render
// after MUL-2671's plain-text refactor. The link to Multica is
// included on its own line so Lark's auto-linker turns it into a
// tappable URL.
func (r *LarkOutcomeReplier) sendIssueCreated(ctx context.Context, inst Installation, msg InboundMessage, res DispatchResult) error {
if msg.ChatID == "" {
return errors.New("missing chat_id")
}
creds, err := r.installationCredentials(inst)
if err != nil {
return err
}
text := issueCreatedText(res, r.appURL)
// Share the Patcher's classified fallback: a thread reply that
// fails because the topic cannot receive it (recalled trigger,
// topics disabled, aggregated message) falls back to a chat-level
// send so the confirmation is not lost; transport/5xx/rate-limit
// failures stay failures rather than leaking into the group chat.
return sendWithThreadFallback(r.log, "send issue-created text", inboundReplyTarget(msg), func(t ReplyTarget) error {
_, err := r.client.SendTextMessage(ctx, SendTextParams{
InstallationID: creds,
ChatID: msg.ChatID,
Text: text,
ReplyTarget: t,
})
return err
})
}
// inboundReplyTarget threads an outbound reply off the inbound trigger
// message when that message lived inside a Lark topic (话题). It mirrors
// threadReplyTarget (used by the event-driven Patcher) but reads the
// live InboundMessage the replier already holds, so it needs no DB
// round-trip. An empty thread_id yields the zero ReplyTarget — a
// chat-level send, i.e. the unchanged behavior for non-thread messages.
func inboundReplyTarget(msg InboundMessage) ReplyTarget {
if msg.ThreadID != "" && msg.MessageID != "" {
return ReplyTarget{MessageID: msg.MessageID, InThread: true}
}
return ReplyTarget{}
}
// issueCreatedText composes the user-facing confirmation. Identifier
// always wins over a bare number — DispatchResult.IssueIdentifier
// already encodes the workspace prefix when available. AppURL is optional:
// when empty (self-host operators who haven't configured MULTICA_APP_URL) the
// message still confirms the issue, just without a deep link the user can tap.
func issueCreatedText(res DispatchResult, appURL string) string {
identifier := res.IssueIdentifier
if identifier == "" {
identifier = fmt.Sprintf("#%d", res.IssueNumber)
}
title := strings.TrimSpace(res.IssueTitle)
var line string
if title == "" {
line = fmt.Sprintf("Created %s", identifier)
} else {
line = fmt.Sprintf("Created %s — %s", identifier, title)
}
if appURL == "" {
return line
}
return line + "\n" + strings.TrimRight(appURL, "/") + "/issues/" + identifier
}
func (r *LarkOutcomeReplier) sendChatNotice(ctx context.Context, inst Installation, msg InboundMessage, body string) error {
if msg.ChatID == "" {
return errors.New("missing chat_id")
}
creds, err := r.installationCredentials(inst)
if err != nil {
return err
}
header := r.noticeHeader
if agent, aerr := r.queries.GetAgent(ctx, inst.AgentID); aerr == nil && agent.Name != "" {
header = agent.Name
}
cardJSON, err := renderNoticeCard(header, body)
if err != nil {
return fmt.Errorf("render notice card: %w", err)
}
// Same classified fallback as sendIssueCreated: only thread-reply
// failures that mean the topic cannot receive the message fall back
// to a chat-level send; ambiguous/transport failures stay failures.
return sendWithThreadFallback(r.log, "send notice card", inboundReplyTarget(msg), func(t ReplyTarget) error {
_, err := r.client.SendInteractiveCard(ctx, SendCardParams{
InstallationID: creds,
ChatID: msg.ChatID,
CardJSON: cardJSON,
ReplyTarget: t,
})
return err
})
}
func (r *LarkOutcomeReplier) installationCredentials(inst Installation) (InstallationCredentials, error) {
secret, err := r.credentials.DecryptAppSecret(inst)
if err != nil {
return InstallationCredentials{}, fmt.Errorf("decrypt app_secret: %w", err)
}
creds := InstallationCredentials{
AppID: inst.AppID,
AppSecret: secret,
Region: RegionOrDefault(inst.Region),
}
if inst.TenantKey.Valid {
creds.TenantKey = inst.TenantKey.String
}
return creds, nil
}
// renderNoticeCard produces a minimal text-only interactive card for
// the offline / archived dispatch outcomes. Lark requires
// update_multi=true on every card we may patch later; these notice
// cards are one-shot, so update_multi is left false (the card stays
// as-is). Header / body match the Chinese voice used elsewhere in
// the integration.
func renderNoticeCard(header, body string) (string, error) {
doc := map[string]any{
"config": map[string]any{"wide_screen_mode": true},
"header": map[string]any{
"template": "grey",
"title": map[string]any{"tag": "plain_text", "content": header},
},
"elements": []any{
map[string]any{
"tag": "div",
"text": map[string]any{
"tag": "plain_text",
"content": body,
},
},
},
}
raw, err := json.Marshal(doc)
if err != nil {
return "", err
}
return string(raw), nil
}
// agentOfflineCopy and agentArchivedCopy are the user-visible Chinese
// strings for the two daemon/agent unavailability outcomes. They
// match the §4.6 design: an offline agent will run when the daemon
// comes back; an archived agent needs operator action.
const (
agentOfflineCopy = "Agent 当前离线,消息已记录。下次 daemon 上线后会自动继续处理。"
agentArchivedCopy = "这个 Agent 已被归档,无法继续处理消息。请联系工作区管理员恢复或重新绑定。"
)