Files
multica/server/internal/integrations/lark/http_client.go
Bohan Jiang 5eba94ee25 feat(lark): inbound context enrichment — post / merge_forward / quoted-reply (MUL-2951) (#3724)
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>
2026-06-04 11:58:16 +08:00

742 lines
26 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lark
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Real Lark/飞书 Open Platform HTTP APIClient.
//
// Scope: tenant_access_token acquisition + caching, IM v1 interactive-
// card send / patch, the dedicated binding-prompt outbound, AND the
// install-time Bot identity lookup (/open-apis/bot/v3/info) consumed
// by RegistrationService right after a successful device-flow grant.
// The PersonalAgent registration protocol itself is a separate client
// (RegistrationClient) because it speaks to a different host
// (accounts.feishu.cn) with a different auth model (no
// tenant_access_token — the response IS the credentials).
//
// Per-installation credentials flow in on each call via
// InstallationCredentials; the client never reads lark_installation
// directly. tenant_access_token is cached in-process keyed by app_id,
// honoring Lark's `expire` field minus a safety margin so callers
// never present a token that's about to lapse mid-flight.
const (
// defaultLarkBaseURL is the production 飞书 (mainland) open-platform
// host. Operators on the Lark international tenant set
// MULTICA_LARK_HTTP_BASE_URL to https://open.larksuite.com; tests
// substitute an httptest.Server URL.
defaultLarkBaseURL = "https://open.feishu.cn"
// tokenSafetyMargin is subtracted from Lark's `expire` so we
// refresh before a token actually lapses. 60s comfortably exceeds
// any in-flight HTTP timeout we set below.
tokenSafetyMargin = 60 * time.Second
// defaultRequestTimeout is the per-call HTTP timeout. Lark's API
// is normally well under 1s; we leave headroom for cross-region
// latency from a self-hosted Multica deployment to feishu.cn.
defaultRequestTimeout = 10 * time.Second
// Lark's "invalid tenant_access_token" / "tenant_access_token
// expired" error codes. When we see either, drop the cached token
// so the next call refreshes from /tenant_access_token/internal.
// 99991663 = expired, 99991664 = invalid. Documented at:
// open.feishu.cn/document/server-docs/api-call-guide/server-error-codes.
codeTokenExpired = 99991663
codeTokenInvalid = 99991664
)
// HTTPClientConfig configures the production Lark HTTP APIClient.
type HTTPClientConfig struct {
// BaseURL is the Lark open-platform root, e.g.
// "https://open.feishu.cn" or "https://open.larksuite.com". Empty
// defaults to defaultLarkBaseURL. Trailing "/" is stripped.
BaseURL string
// HTTPClient is the transport used for every outbound call. Tests
// substitute an *http.Client whose Transport routes to an
// httptest.Server. Empty defaults to a fresh http.Client with
// defaultRequestTimeout.
HTTPClient *http.Client
// Now is overridable for deterministic token-expiry tests.
Now func() time.Time
// Logger receives warnings about Lark error codes. Nil uses
// slog.Default().
Logger *slog.Logger
}
func (c HTTPClientConfig) withDefaults() HTTPClientConfig {
if c.BaseURL == "" {
c.BaseURL = defaultLarkBaseURL
}
c.BaseURL = strings.TrimRight(c.BaseURL, "/")
if c.HTTPClient == nil {
c.HTTPClient = &http.Client{Timeout: defaultRequestTimeout}
}
if c.Now == nil {
c.Now = time.Now
}
if c.Logger == nil {
c.Logger = slog.Default()
}
return c
}
// NewHTTPAPIClient constructs the real APIClient that speaks to Lark's
// open platform over HTTPS. Per-installation credentials flow in via
// each call's InstallationCredentials parameter; tokens are cached
// keyed by app_id so a single Multica server reuses Lark's
// tenant_access_token across calls to the same app.
func NewHTTPAPIClient(cfg HTTPClientConfig) APIClient {
cfg = cfg.withDefaults()
return &httpAPIClient{cfg: cfg, tokens: make(map[string]*cachedToken)}
}
type httpAPIClient struct {
cfg HTTPClientConfig
mu sync.Mutex
tokens map[string]*cachedToken
}
type cachedToken struct {
value string
expiresAt time.Time
}
// IsConfigured reports true: once this client exists at all, the
// outbound transport path (send / patch / binding prompt / bot info)
// is wired. The stub returns false because every call there errors
// with ErrAPIClientNotConfigured; the real client is the inverse
// contract.
func (c *httpAPIClient) IsConfigured() bool { return true }
// tenantAccessToken returns a usable tenant_access_token for the
// given installation, reusing a cached token while it is alive (minus
// safety margin) and otherwise fetching a fresh one from Lark.
//
// Concurrent callers serialize on the per-client mutex during the
// uncached path; the cached path takes the mutex only for the lookup
// and releases before doing any I/O. Steady-state contention is
// therefore one map-read under the lock, not a per-call HTTP round
// trip.
func (c *httpAPIClient) tenantAccessToken(ctx context.Context, creds InstallationCredentials) (string, error) {
if creds.AppID == "" {
return "", errors.New("lark http client: missing app_id")
}
if creds.AppSecret == "" {
return "", errors.New("lark http client: missing app_secret")
}
now := c.cfg.Now()
c.mu.Lock()
if t, ok := c.tokens[creds.AppID]; ok && t.expiresAt.After(now) {
val := t.value
c.mu.Unlock()
return val, nil
}
c.mu.Unlock()
// Self-built (internal) app endpoint. Marketplace / multi-tenant
// apps would use /tenant_access_token/v3 with a different body
// shape; PersonalAgent in this MVP is per-workspace self-built so
// we stay on /internal.
body := map[string]string{
"app_id": creds.AppID,
"app_secret": creds.AppSecret,
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Expire int64 `json:"expire"`
}
if err := c.doJSON(ctx, http.MethodPost, "/open-apis/auth/v3/tenant_access_token/internal", "", body, &resp); err != nil {
return "", fmt.Errorf("lark http client: tenant_access_token: %w", err)
}
if resp.Code != 0 || resp.TenantAccessToken == "" {
return "", fmt.Errorf("lark http client: tenant_access_token: code=%d msg=%q", resp.Code, resp.Msg)
}
expire := time.Duration(resp.Expire) * time.Second
// Clamp to >= 2× safety margin so a misbehaving upstream that
// returns a sub-minute expire never makes us cache a token that
// is already past its safe window.
if expire < tokenSafetyMargin*2 {
expire = tokenSafetyMargin * 2
}
expiresAt := c.cfg.Now().Add(expire - tokenSafetyMargin)
c.mu.Lock()
c.tokens[creds.AppID] = &cachedToken{value: resp.TenantAccessToken, expiresAt: expiresAt}
c.mu.Unlock()
return resp.TenantAccessToken, nil
}
// invalidateToken drops the cached token for an app_id. Called when
// Lark surfaces an expired / invalid token error code so the next
// call refreshes instead of looping on a stale entry.
func (c *httpAPIClient) invalidateToken(appID string) {
c.mu.Lock()
delete(c.tokens, appID)
c.mu.Unlock()
}
// SendInteractiveCard posts a fresh interactive card into a chat and
// returns Lark's message_id so the Patcher can target subsequent
// patches at the same card.
func (c *httpAPIClient) SendInteractiveCard(ctx context.Context, p SendCardParams) (string, error) {
if p.ChatID == "" {
return "", errors.New("lark http client: missing chat_id")
}
if p.CardJSON == "" {
return "", errors.New("lark http client: missing card json")
}
token, err := c.tenantAccessToken(ctx, p.InstallationID)
if err != nil {
return "", err
}
q := url.Values{}
q.Set("receive_id_type", "chat_id")
body := map[string]string{
"receive_id": string(p.ChatID),
"msg_type": "interactive",
"content": p.CardJSON,
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
MessageID string `json:"message_id"`
} `json:"data"`
}
path := "/open-apis/im/v1/messages?" + q.Encode()
if err := c.doJSON(ctx, http.MethodPost, path, token, body, &resp); err != nil {
return "", fmt.Errorf("lark http client: send interactive card: %w", err)
}
if resp.Code != 0 || resp.Data.MessageID == "" {
if isTokenError(resp.Code) {
c.invalidateToken(p.InstallationID.AppID)
}
return "", fmt.Errorf("lark http client: send interactive card: code=%d msg=%q", resp.Code, resp.Msg)
}
return resp.Data.MessageID, nil
}
// SendTextMessage posts a plain text IM message into a Lark chat.
// This is the Patcher's primary outbound for agent chat replies —
// using a normal text bubble instead of an interactive card makes
// free-form replies feel like a native Lark conversation. The
// content envelope Lark expects is a JSON-encoded `{"text": "..."}`
// blob; we encode it here so callers pass raw text.
func (c *httpAPIClient) SendTextMessage(ctx context.Context, p SendTextParams) (string, error) {
if p.ChatID == "" {
return "", errors.New("lark http client: missing chat_id")
}
if p.Text == "" {
return "", errors.New("lark http client: missing text")
}
token, err := c.tenantAccessToken(ctx, p.InstallationID)
if err != nil {
return "", err
}
// Lark's `text` msg_type expects content = JSON-encoded {"text": "..."}.
// json.Marshal handles the escape of newlines / quotes / unicode so
// the agent's reply round-trips intact.
contentBytes, err := json.Marshal(map[string]string{"text": p.Text})
if err != nil {
return "", fmt.Errorf("lark http client: encode text content: %w", err)
}
q := url.Values{}
q.Set("receive_id_type", "chat_id")
body := map[string]string{
"receive_id": string(p.ChatID),
"msg_type": "text",
"content": string(contentBytes),
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
MessageID string `json:"message_id"`
} `json:"data"`
}
path := "/open-apis/im/v1/messages?" + q.Encode()
if err := c.doJSON(ctx, http.MethodPost, path, token, body, &resp); err != nil {
return "", fmt.Errorf("lark http client: send text message: %w", err)
}
if resp.Code != 0 || resp.Data.MessageID == "" {
if isTokenError(resp.Code) {
c.invalidateToken(p.InstallationID.AppID)
}
return "", fmt.Errorf("lark http client: send text message: code=%d msg=%q", resp.Code, resp.Msg)
}
return resp.Data.MessageID, nil
}
// SendMarkdownCard posts the agent's reply as an interactive card
// using Lark's schema-2.0 envelope with a single `tag: "markdown"`
// body element. Lark's client renders the markdown into formatted
// text (bold, italics, lists, links, fenced code blocks, tables, …)
// rather than showing raw markdown characters as it does for
// `msg_type=text`. We deliberately keep `SendTextMessage` as a
// separate path for plain-prose replies — a card around a one-line
// "Hello!" adds visual chrome that the user doesn't want; the
// routing decision (markdown vs text) lives at the Patcher layer.
//
// Why schema 2.0 rather than the legacy schema with a `div` +
// `lark_md` text element: the legacy `lark_md` tag's markdown
// dialect is much narrower — no fenced code blocks (syntax
// highlighting), no tables, no heading sizes. Schema-2.0's
// `markdown` tag is closer to GFM.
func (c *httpAPIClient) SendMarkdownCard(ctx context.Context, p SendMarkdownCardParams) (string, error) {
if p.ChatID == "" {
return "", errors.New("lark http client: missing chat_id")
}
if p.Markdown == "" {
return "", errors.New("lark http client: missing markdown body")
}
token, err := c.tenantAccessToken(ctx, p.InstallationID)
if err != nil {
return "", err
}
card := map[string]any{
"schema": "2.0",
"body": map[string]any{
"elements": []any{
map[string]any{"tag": "markdown", "content": p.Markdown},
},
},
}
if p.Summary != "" {
card["config"] = map[string]any{
"summary": map[string]any{"content": p.Summary},
}
}
cardBytes, err := json.Marshal(card)
if err != nil {
return "", fmt.Errorf("lark http client: encode markdown card: %w", err)
}
q := url.Values{}
q.Set("receive_id_type", "chat_id")
body := map[string]string{
"receive_id": string(p.ChatID),
"msg_type": "interactive",
"content": string(cardBytes),
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
MessageID string `json:"message_id"`
} `json:"data"`
}
path := "/open-apis/im/v1/messages?" + q.Encode()
if err := c.doJSON(ctx, http.MethodPost, path, token, body, &resp); err != nil {
return "", fmt.Errorf("lark http client: send markdown card: %w", err)
}
if resp.Code != 0 || resp.Data.MessageID == "" {
if isTokenError(resp.Code) {
c.invalidateToken(p.InstallationID.AppID)
}
return "", fmt.Errorf("lark http client: send markdown card: code=%d msg=%q", resp.Code, resp.Msg)
}
return resp.Data.MessageID, nil
}
// PatchInteractiveCard updates an existing card's body. Lark's
// message-patch endpoint replaces the whole card payload; callers
// (i.e. the Patcher) render the full updated card each time.
func (c *httpAPIClient) PatchInteractiveCard(ctx context.Context, p PatchCardParams) error {
if p.LarkCardMessageID == "" {
return errors.New("lark http client: missing card message id")
}
if p.CardJSON == "" {
return errors.New("lark http client: missing card json")
}
token, err := c.tenantAccessToken(ctx, p.InstallationID)
if err != nil {
return err
}
body := map[string]string{"content": p.CardJSON}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
path := "/open-apis/im/v1/messages/" + url.PathEscape(p.LarkCardMessageID)
if err := c.doJSON(ctx, http.MethodPatch, path, token, body, &resp); err != nil {
return fmt.Errorf("lark http client: patch interactive card: %w", err)
}
if resp.Code != 0 {
if isTokenError(resp.Code) {
c.invalidateToken(p.InstallationID.AppID)
}
return fmt.Errorf("lark http client: patch interactive card: code=%d msg=%q", resp.Code, resp.Msg)
}
return nil
}
// SendBindingPromptCard renders the member-binding card and posts it
// directly to the unbound user's open_id (not the chat). Keeping the
// card template inside this client — rather than the dispatcher —
// means the dispatcher never has to know about Lark's card schema.
func (c *httpAPIClient) SendBindingPromptCard(ctx context.Context, p BindingPromptParams) error {
if p.OpenID == "" {
return errors.New("lark http client: missing open_id")
}
if p.BindURL == "" {
return errors.New("lark http client: missing bind url")
}
cardJSON, err := bindingPromptTemplate(p.BindURL)
if err != nil {
return fmt.Errorf("lark http client: render binding prompt: %w", err)
}
token, err := c.tenantAccessToken(ctx, p.InstallationID)
if err != nil {
return err
}
q := url.Values{}
q.Set("receive_id_type", "open_id")
body := map[string]string{
"receive_id": string(p.OpenID),
"msg_type": "interactive",
"content": cardJSON,
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
path := "/open-apis/im/v1/messages?" + q.Encode()
if err := c.doJSON(ctx, http.MethodPost, path, token, body, &resp); err != nil {
return fmt.Errorf("lark http client: send binding prompt: %w", err)
}
if resp.Code != 0 {
if isTokenError(resp.Code) {
c.invalidateToken(p.InstallationID.AppID)
}
return fmt.Errorf("lark http client: send binding prompt: code=%d msg=%q", resp.Code, resp.Msg)
}
return nil
}
// GetBotInfo calls /open-apis/bot/v3/info to learn the Bot's
// per-installation `open_id` and then /open-apis/contact/v3/users/
// {open_id}?user_id_type=open_id to resolve its stable `union_id`.
// RegistrationService is the only caller — right after the device-
// flow registration returns fresh `client_id` / `client_secret`, the
// service mints a tenant_access_token with those creds and calls
// this method so the installation row can be frozen with both Bot
// identifiers in the same transaction as the installer-bind.
//
// Why two API calls instead of one: /bot/v3/info does not return
// union_id in the public schema. The WS inbound decoder needs
// union_id to disambiguate which bot was @-mentioned in a multi-bot
// group chat (the per-app open_id field on mentions is structurally
// inverse across WS perspectives — see MUL-2671 triage), so we
// invest one extra HTTP round-trip at install time to capture it
// and avoid running the wrong supervisor for every event going
// forward.
//
// A missing union_id (contact lookup denied by app scope, or Lark
// returns an empty field) is NOT a hard failure here — the
// installation is still usable for p2p chats and the decoder can
// fall back to the (broken) open_id match path until the operator
// fixes scopes. We log a warning so the gap is visible.
//
// Other fields the upstream APIs return (display name, avatar, IP
// whitelist) are deliberately dropped; downstream reads can fetch
// them on demand from the bot_open_id, and freezing them into our
// schema would create a drift surface every time the operator edits
// the Bot on Lark's side.
func (c *httpAPIClient) GetBotInfo(ctx context.Context, creds InstallationCredentials) (BotInfo, error) {
if creds.AppID == "" || creds.AppSecret == "" {
return BotInfo{}, errors.New("lark http client: missing app credentials for GetBotInfo")
}
token, err := c.tenantAccessToken(ctx, creds)
if err != nil {
return BotInfo{}, err
}
var botResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Bot struct {
OpenID string `json:"open_id"`
} `json:"bot"`
}
if err := c.doJSON(ctx, http.MethodGet, "/open-apis/bot/v3/info", token, nil, &botResp); err != nil {
return BotInfo{}, fmt.Errorf("lark http client: bot info: %w", err)
}
if botResp.Code != 0 {
if isTokenError(botResp.Code) {
c.invalidateToken(creds.AppID)
}
return BotInfo{}, fmt.Errorf("lark http client: bot info: code=%d msg=%q", botResp.Code, botResp.Msg)
}
if botResp.Bot.OpenID == "" {
return BotInfo{}, errors.New("lark http client: bot info: response missing open_id")
}
// Resolve union_id via the contact endpoint. Soft-fail: log and
// return the BotInfo with empty UnionID. Callers (Registration-
// Service.finishSuccess) accept the gap and persist what they
// have.
unionID, lookupErr := c.fetchBotUnionID(ctx, creds.AppID, token, botResp.Bot.OpenID)
if lookupErr != nil {
c.cfg.Logger.Warn("lark http client: bot union_id lookup failed; continuing without it",
"app_id", creds.AppID,
"bot_open_id", botResp.Bot.OpenID,
"err", lookupErr)
}
return BotInfo{OpenID: OpenID(botResp.Bot.OpenID), UnionID: unionID}, nil
}
// GetMessage retrieves a message by id via
// GET /open-apis/im/v1/messages/{message_id}. The endpoint always wraps
// the result in data.items[] — one element for a normal message, and a
// forward sentinel followed by the bundled child messages for a
// `merge_forward`. We pass user_id_type=open_id so sender.id and
// mentions[].id come back as open_ids, matching the identifiers the
// rest of the package keys on.
//
// body.content is forwarded verbatim (the raw, JSON-encoded, msg_type-
// specific string Lark double-encodes); the enricher's flattener owns
// interpreting it. A deleted / out-of-scope message surfaces as a Lark
// error code, which we turn into a normal Go error so the enricher can
// degrade to its "[unable to fetch]" placeholder without aborting the
// inbound pipeline.
func (c *httpAPIClient) GetMessage(ctx context.Context, creds InstallationCredentials, messageID string) ([]LarkMessage, error) {
if messageID == "" {
return nil, errors.New("lark http client: missing message_id")
}
token, err := c.tenantAccessToken(ctx, creds)
if err != nil {
return nil, err
}
q := url.Values{}
q.Set("user_id_type", "open_id")
path := "/open-apis/im/v1/messages/" + url.PathEscape(messageID) + "?" + q.Encode()
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Items []larkRESTMessageItem `json:"items"`
} `json:"data"`
}
if err := c.doJSON(ctx, http.MethodGet, path, token, nil, &resp); err != nil {
return nil, fmt.Errorf("lark http client: get message: %w", err)
}
if resp.Code != 0 {
if isTokenError(resp.Code) {
c.invalidateToken(creds.AppID)
}
return nil, fmt.Errorf("lark http client: get message: code=%d msg=%q", resp.Code, resp.Msg)
}
out := make([]LarkMessage, 0, len(resp.Data.Items))
for _, it := range resp.Data.Items {
out = append(out, it.normalize())
}
return out, nil
}
// larkRESTMessageItem is the IM v1 message item shape returned by the
// get / list endpoints. It differs from the WS receive event in two
// ways the enricher cares about: msg_type (not message_type), and a
// flat `sender.id` / `mentions[].id` string (not a nested id object).
type larkRESTMessageItem struct {
MessageID string `json:"message_id"`
RootID string `json:"root_id"`
ParentID string `json:"parent_id"`
UpperMessageID string `json:"upper_message_id"`
MsgType string `json:"msg_type"`
CreateTime string `json:"create_time"`
Deleted bool `json:"deleted"`
Sender struct {
ID string `json:"id"`
IDType string `json:"id_type"`
SenderType string `json:"sender_type"`
} `json:"sender"`
Body struct {
Content string `json:"content"`
} `json:"body"`
Mentions []struct {
Key string `json:"key"`
ID string `json:"id"`
Name string `json:"name"`
} `json:"mentions"`
}
func (it larkRESTMessageItem) normalize() LarkMessage {
m := LarkMessage{
MessageID: it.MessageID,
MessageType: it.MsgType,
Content: it.Body.Content,
SenderID: it.Sender.ID,
SenderType: it.Sender.SenderType,
CreateTime: it.CreateTime,
ParentID: it.ParentID,
RootID: it.RootID,
UpperMessageID: it.UpperMessageID,
Deleted: it.Deleted,
}
for _, mn := range it.Mentions {
m.Mentions = append(m.Mentions, LarkMessageMention{Key: mn.Key, ID: mn.ID, Name: mn.Name})
}
return m
}
// fetchBotUnionID resolves a Bot's `union_id` from its `open_id` via
// /open-apis/contact/v3/users/{open_id}?user_id_type=open_id. Split
// out from GetBotInfo so the failure mode is explicit and the call
// sites that only need open_id don't pay for the second round-trip.
//
// Empty string + nil error is a valid outcome: Lark's user endpoint
// can return code=0 with no union_id field when the app's contact
// scope is restricted. Caller logs and continues; the decoder still
// works in single-bot deployments where open_id-based matching is
// unambiguous.
func (c *httpAPIClient) fetchBotUnionID(ctx context.Context, appID, token, openID string) (string, error) {
if openID == "" {
return "", errors.New("empty open_id")
}
q := url.Values{}
q.Set("user_id_type", "open_id")
path := "/open-apis/contact/v3/users/" + url.PathEscape(openID) + "?" + q.Encode()
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
User struct {
UnionID string `json:"union_id"`
} `json:"user"`
} `json:"data"`
}
if err := c.doJSON(ctx, http.MethodGet, path, token, nil, &resp); err != nil {
return "", fmt.Errorf("contact users: %w", err)
}
if resp.Code != 0 {
// invalidateToken is keyed by app_id (the cache key on
// httpAPIClient.tokens), NOT by the bearer string. Passing
// the bearer would do nothing and a stale token would keep
// being reused on every retry until natural TTL expiry.
if isTokenError(resp.Code) {
c.invalidateToken(appID)
}
return "", fmt.Errorf("contact users: code=%d msg=%q", resp.Code, resp.Msg)
}
return resp.Data.User.UnionID, nil
}
// doJSON encapsulates the verb + URL + auth-header + JSON
// encode/decode dance so each public method stays a thin shape-only
// adapter. token == "" skips the Authorization header (only the
// tenant_access_token endpoint takes that path).
func (c *httpAPIClient) doJSON(ctx context.Context, method, path, token string, body, out any) error {
var rdr io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
rdr = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.cfg.BaseURL+path, rdr)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.cfg.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("http do: %w", err)
}
defer resp.Body.Close()
rawBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http %d: %s", resp.StatusCode, truncate(string(rawBody), 512))
}
if out != nil && len(rawBody) > 0 {
if err := json.Unmarshal(rawBody, out); err != nil {
return fmt.Errorf("decode body: %w (raw=%s)", err, truncate(string(rawBody), 256))
}
}
return nil
}
func isTokenError(code int) bool {
return code == codeTokenExpired || code == codeTokenInvalid
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// bindingPromptTemplate renders the "you need to bind" interactive
// card. Single primary CTA pointing at the redemption URL; the rest
// of the body is plain-text Chinese copy matching the in-app voice.
//
// Kept here (not in defaultRenderer) so the binding card template can
// evolve independently of the streaming-status cards the Patcher
// renders — they have different lifecycles (binding card is one-shot,
// status cards are patched in place).
func bindingPromptTemplate(bindURL string) (string, error) {
doc := map[string]any{
"config": map[string]any{"wide_screen_mode": true},
"header": map[string]any{
"template": "blue",
"title": map[string]any{"tag": "plain_text", "content": "Multica"},
},
"elements": []any{
map[string]any{
"tag": "div",
"text": map[string]any{
"tag": "lark_md",
"content": "你还没有绑定 Multica 账户。点击下方按钮完成绑定后即可使用此 Agent。",
},
},
map[string]any{
"tag": "action",
"actions": []any{
map[string]any{
"tag": "button",
"text": map[string]any{"tag": "plain_text", "content": "去绑定"},
"type": "primary",
"url": bindURL,
},
},
},
},
}
raw, err := json.Marshal(doc)
if err != nil {
return "", err
}
return string(raw), nil
}