diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 8ea813928..60be04861 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -178,17 +178,35 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus // Outbound card patcher: subscribe to task / chat-done // events on the existing bus so any task born from a // Lark-bound chat_session gets its card kept in sync - // (thinking → streaming → final | error). The stub - // APIClient surfaces ErrAPIClientNotConfigured on - // transport — once the real Lark client lands, swap - // it in here without touching the subscription. - larkClient := lark.NewStubAPIClient(slog.Default()) + // (thinking → streaming → final | error). + // + // APIClient selection: when MULTICA_LARK_HTTP_ENABLED is + // "true" the real Lark Open Platform HTTP client is wired + // (IM v1 send/patch + binding-prompt). Otherwise the stub + // stays in place and every outbound call surfaces + // ErrAPIClientNotConfigured — useful for deployments that + // want the inbound dispatcher / database surface online + // without committing to a Lark app yet. + // + // MULTICA_LARK_HTTP_BASE_URL overrides the default + // open.feishu.cn host (set to https://open.larksuite.com + // for the Lark international tenant, or to a mock for + // integration tests). + var larkClient lark.APIClient + if strings.EqualFold(strings.TrimSpace(os.Getenv("MULTICA_LARK_HTTP_ENABLED")), "true") { + larkClient = lark.NewHTTPAPIClient(lark.HTTPClientConfig{ + BaseURL: strings.TrimSpace(os.Getenv("MULTICA_LARK_HTTP_BASE_URL")), + Logger: slog.Default(), + }) + slog.Info("lark http api client enabled") + } else { + larkClient = lark.NewStubAPIClient(slog.Default()) + } // Expose the APIClient to handlers so the install // surface can short-circuit when no real transport is - // wired (IsConfigured() == false). Replace this with - // the real Lark HTTP client once it lands; the - // install_supported flag will flip automatically and - // the UI will reveal the install entry points. + // wired (IsConfigured() == false). With the real HTTP + // client wired the install_supported flag flips and the + // UI reveals the install entry points. h.LarkAPIClient = larkClient patcher := lark.NewPatcher(queries, installSvc, larkClient, lark.PatcherConfig{}) patcher.Register(bus) @@ -207,11 +225,13 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus FrontendSuccessURL: strings.TrimSpace(os.Getenv("MULTICA_LARK_OAUTH_SUCCESS_URL")), } if oauthCfg.Enabled() { - // The real APIClient lands in a follow-up; until - // then OAuth callbacks surface ErrAPIClientNotConfigured - // loudly instead of silently failing. - stub := lark.NewStubAPIClient(slog.Default()) - oauthSvc, oerr := lark.NewOAuthService(oauthCfg, stub, installSvc, h.LarkBindingTokens) + // OAuth callback delegates the code→credentials + // exchange to APIClient.ExchangeOAuthCode. The HTTP + // client returns ErrAPIClientNotConfigured for that + // path until the PersonalAgent install-time response + // shape lands, so operators still rely on the manual- + // paste InstallationService until then. + oauthSvc, oerr := lark.NewOAuthService(oauthCfg, larkClient, installSvc, h.LarkBindingTokens) if oerr != nil { slog.Error("lark: OAuthService init failed; oauth disabled", "error", oerr) } else { diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go new file mode 100644 index 000000000..dabee1254 --- /dev/null +++ b/server/internal/integrations/lark/http_client.go @@ -0,0 +1,426 @@ +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 (this stage): tenant_access_token acquisition + caching, IM v1 +// interactive-card send / patch, and the dedicated binding-prompt +// outbound. ExchangeOAuthCode stays unimplemented behind +// ErrAPIClientNotConfigured — the PersonalAgent install-time response +// shape is not yet verified against the production endpoint, and a +// silent mis-fill of OAuthExchangeResult would corrupt +// lark_installation. The manual-paste InstallationService path is +// unaffected and continues to be the recommended install method for +// self-host operators until that OAuth stage lands. +// +// 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 and the + // "OAuth not implemented" surface. 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 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 +} + +// 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 +} + +// ExchangeOAuthCode is deliberately not yet implemented. The +// PersonalAgent install flow's "scan to bind, you're done" handshake +// returns per-installation app credentials (app_id, app_secret) plus +// the installer's open_id in a single response shape we have not yet +// pinned down against the production endpoint. A naive +// implementation against /authen/v1/access_token here would silently +// mis-fill OAuthExchangeResult — the standard user_info OAuth flow +// only returns user identity, not the app credentials +// validateExchangeResult requires. Until the PersonalAgent install +// stage lands, OAuth callbacks short-circuit on +// ErrAPIClientNotConfigured and self-host operators stay on the +// manual-paste InstallationService path. +func (c *httpAPIClient) ExchangeOAuthCode(ctx context.Context, code, redirectURI string) (OAuthExchangeResult, error) { + c.cfg.Logger.Warn("lark http client: ExchangeOAuthCode not yet implemented; manual-paste install only") + return OAuthExchangeResult{}, ErrAPIClientNotConfigured +} + +// 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) + } + 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 +} diff --git a/server/internal/integrations/lark/http_client_test.go b/server/internal/integrations/lark/http_client_test.go new file mode 100644 index 000000000..58c7e0825 --- /dev/null +++ b/server/internal/integrations/lark/http_client_test.go @@ -0,0 +1,603 @@ +package lark + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// larkFakeServer is a tiny in-memory stand-in for the Lark Open +// Platform. Tests register handlers per path; the server panics if a +// path is hit without a registration (a missed assertion is louder +// than a 404). +// +// The handler shape mirrors http.HandlerFunc so each test can encode +// its own response without inheriting boilerplate. +type larkFakeServer struct { + t *testing.T + mux *http.ServeMux + srv *httptest.Server + tokenN atomic.Int32 + sendN atomic.Int32 + patchN atomic.Int32 + bindN atomic.Int32 + authObs atomic.Value // last Authorization header seen across all paths +} + +func newLarkFake(t *testing.T) *larkFakeServer { + t.Helper() + f := &larkFakeServer{t: t, mux: http.NewServeMux()} + f.srv = httptest.NewServer(f) + t.Cleanup(f.srv.Close) + return f +} + +func (f *larkFakeServer) URL() string { return f.srv.URL } + +func (f *larkFakeServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if a := r.Header.Get("Authorization"); a != "" { + f.authObs.Store(a) + } + f.mux.ServeHTTP(w, r) +} + +func (f *larkFakeServer) lastAuth() string { + v, _ := f.authObs.Load().(string) + return v +} + +// stubToken installs a token endpoint that returns the supplied token +// with the supplied expire (seconds) and counts hits. +func (f *larkFakeServer) stubToken(token string, expireSec int64) { + f.mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) { + f.tokenN.Add(1) + if r.Method != http.MethodPost { + f.t.Errorf("token: want POST, got %s", r.Method) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + f.t.Errorf("token: decode body: %v", err) + } + if body["app_id"] == "" || body["app_secret"] == "" { + f.t.Errorf("token: missing app credentials: %v", body) + } + writeJSON(w, map[string]any{ + "code": 0, + "msg": "ok", + "tenant_access_token": token, + "expire": expireSec, + }) + }) +} + +// stubTokenError installs a token endpoint returning a Lark-style +// error code (non-zero `code` with HTTP 200). +func (f *larkFakeServer) stubTokenError(code int, msg string) { + f.mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) { + f.tokenN.Add(1) + writeJSON(w, map[string]any{"code": code, "msg": msg}) + }) +} + +// stubSend installs the IM-send endpoint. resp is the response body +// (typically the standard {code, msg, data:{message_id}} shape). +func (f *larkFakeServer) stubSend(resp map[string]any, verify func(r *http.Request, body map[string]string)) { + f.mux.HandleFunc("/open-apis/im/v1/messages", func(w http.ResponseWriter, r *http.Request) { + f.sendN.Add(1) + if r.Method != http.MethodPost { + f.t.Errorf("send: want POST, got %s", r.Method) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + f.t.Errorf("send: decode body: %v", err) + } + if verify != nil { + verify(r, body) + } + writeJSON(w, resp) + }) +} + +// stubPatch installs the IM-patch endpoint. The Lark route is +// /open-apis/im/v1/messages/; ServeMux uses prefix matching when +// we register the parent path explicitly. We register the parent +// SEND path above already, so the patch path needs the full prefix. +func (f *larkFakeServer) stubPatch(resp map[string]any, verify func(r *http.Request, id string, body map[string]string)) { + const prefix = "/open-apis/im/v1/messages/" + f.mux.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + f.t.Errorf("patch: want PATCH, got %s", r.Method) + } + id := strings.TrimPrefix(r.URL.Path, prefix) + if id == "" { + f.t.Errorf("patch: missing message id") + } + f.patchN.Add(1) + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + f.t.Errorf("patch: decode body: %v", err) + } + if verify != nil { + verify(r, id, body) + } + writeJSON(w, resp) + }) +} + +func writeJSON(w http.ResponseWriter, body any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) +} + +// newTestClient returns an httpAPIClient pointed at the fake server, +// using the supplied clock so token expiry can be controlled +// deterministically. +func newTestClient(fake *larkFakeServer, now func() time.Time) *httpAPIClient { + c := NewHTTPAPIClient(HTTPClientConfig{ + BaseURL: fake.URL(), + Now: now, + }).(*httpAPIClient) + return c +} + +func testCreds() InstallationCredentials { + return InstallationCredentials{AppID: "cli_app_xx", AppSecret: "secret_xx"} +} + +func TestHTTPClient_IsConfigured(t *testing.T) { + c := NewHTTPAPIClient(HTTPClientConfig{}) + if !c.IsConfigured() { + t.Fatalf("real client must report IsConfigured()=true") + } +} + +func TestHTTPClient_SendInteractiveCard_HappyPath(t *testing.T) { + fake := newLarkFake(t) + fake.stubToken("tok_1", 7200) + fake.stubSend( + map[string]any{ + "code": 0, + "msg": "ok", + "data": map[string]string{"message_id": "om_msg_42"}, + }, + func(r *http.Request, body map[string]string) { + if got := r.URL.Query().Get("receive_id_type"); got != "chat_id" { + t.Errorf("receive_id_type: got %q want chat_id", got) + } + if body["receive_id"] != "oc_chat_1" { + t.Errorf("receive_id: got %q", body["receive_id"]) + } + if body["msg_type"] != "interactive" { + t.Errorf("msg_type: got %q want interactive", body["msg_type"]) + } + if !strings.Contains(body["content"], "\"tag\"") { + t.Errorf("content not a card body: %q", body["content"]) + } + }, + ) + + c := newTestClient(fake, time.Now) + msgID, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc_chat_1"), + CardJSON: `{"tag":"div","text":"hi"}`, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + if msgID != "om_msg_42" { + t.Errorf("message id: got %q want om_msg_42", msgID) + } + if got := fake.lastAuth(); got != "Bearer tok_1" { + t.Errorf("Authorization header: got %q want Bearer tok_1", got) + } +} + +func TestHTTPClient_SendInteractiveCard_TokenCached(t *testing.T) { + fake := newLarkFake(t) + fake.stubToken("tok_cached", 7200) + fake.stubSend( + map[string]any{ + "code": 0, + "data": map[string]string{"message_id": "om_msg_x"}, + }, + nil, + ) + c := newTestClient(fake, time.Now) + for i := 0; i < 3; i++ { + if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc_chat_1"), + CardJSON: `{}`, + }); err != nil { + t.Fatalf("iter %d: %v", i, err) + } + } + if got := fake.tokenN.Load(); got != 1 { + t.Errorf("token endpoint hits: got %d want 1 (cached after first call)", got) + } + if got := fake.sendN.Load(); got != 3 { + t.Errorf("send endpoint hits: got %d want 3", got) + } +} + +func TestHTTPClient_TokenRefreshAfterExpiry(t *testing.T) { + fake := newLarkFake(t) + fake.stubToken("tok_refresh", 120) // 120s expire → 60s usable after safety margin + fake.stubSend( + map[string]any{ + "code": 0, + "data": map[string]string{"message_id": "om"}, + }, + nil, + ) + + now := time.Unix(1_700_000_000, 0) + clock := &fakeClock{now: now} + c := NewHTTPAPIClient(HTTPClientConfig{BaseURL: fake.URL(), Now: clock.Now}).(*httpAPIClient) + + // First call — fetches token. + if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc"), + CardJSON: `{}`, + }); err != nil { + t.Fatalf("first send: %v", err) + } + if fake.tokenN.Load() != 1 { + t.Fatalf("first call should have fetched a token, got tokenN=%d", fake.tokenN.Load()) + } + + // Advance past the cached token's expiry (token expire 120s, + // safety margin 60s → cache valid for 60s of wall-clock). + clock.Advance(90 * time.Second) + + if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc"), + CardJSON: `{}`, + }); err != nil { + t.Fatalf("post-expiry send: %v", err) + } + if got := fake.tokenN.Load(); got != 2 { + t.Errorf("token endpoint hits after expiry: got %d want 2", got) + } +} + +func TestHTTPClient_SendInteractiveCard_LarkErrorCode(t *testing.T) { + fake := newLarkFake(t) + fake.stubToken("tok_e", 7200) + fake.stubSend(map[string]any{"code": 230001, "msg": "no permission"}, nil) + c := newTestClient(fake, time.Now) + _, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc"), + CardJSON: `{}`, + }) + if err == nil { + t.Fatal("want error on non-zero code") + } + if !strings.Contains(err.Error(), "code=230001") { + t.Errorf("error should surface code: %v", err) + } +} + +func TestHTTPClient_SendInteractiveCard_TokenExpired_InvalidatesCache(t *testing.T) { + fake := newLarkFake(t) + fake.stubToken("tok_first", 7200) + // First send replies with expired-token. Second send (after the + // client should have dropped its cache) reaches the token + // endpoint again. We swap the send handler mid-test to model + // this without race conditions: send fails first, second call + // from the same fake gets the token-endpoint hit + a fresh send + // reply. To keep the test small we simply assert tokenN + // increments after the failing call when the caller retries. + var sendCalls atomic.Int32 + fake.mux.HandleFunc("/open-apis/im/v1/messages", func(w http.ResponseWriter, r *http.Request) { + fake.sendN.Add(1) + n := sendCalls.Add(1) + if n == 1 { + writeJSON(w, map[string]any{"code": codeTokenExpired, "msg": "expired"}) + return + } + writeJSON(w, map[string]any{"code": 0, "data": map[string]string{"message_id": "om_ok"}}) + }) + + c := newTestClient(fake, time.Now) + _, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc"), + CardJSON: `{}`, + }) + if err == nil { + t.Fatal("first send must fail with token-expired") + } + if !strings.Contains(err.Error(), "code=99991663") { + t.Errorf("error should mention token-expired code: %v", err) + } + + // Caller's retry — should re-fetch the token, then succeed. + msgID, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc"), + CardJSON: `{}`, + }) + if err != nil { + t.Fatalf("retry send: %v", err) + } + if msgID != "om_ok" { + t.Errorf("retry message id: got %q", msgID) + } + if got := fake.tokenN.Load(); got != 2 { + t.Errorf("token endpoint hits after invalidation: got %d want 2", got) + } +} + +func TestHTTPClient_PatchInteractiveCard_HappyPath(t *testing.T) { + fake := newLarkFake(t) + fake.stubToken("tok_p", 7200) + fake.stubPatch( + map[string]any{"code": 0, "msg": "ok"}, + func(r *http.Request, id string, body map[string]string) { + if id != "om_msg_42" { + t.Errorf("patch id: got %q want om_msg_42", id) + } + if !strings.Contains(body["content"], "updated") { + t.Errorf("patch content: %q", body["content"]) + } + }, + ) + c := newTestClient(fake, time.Now) + if err := c.PatchInteractiveCard(context.Background(), PatchCardParams{ + InstallationID: testCreds(), + LarkCardMessageID: "om_msg_42", + CardJSON: `{"text":"updated"}`, + }); err != nil { + t.Fatalf("patch: %v", err) + } +} + +func TestHTTPClient_PatchInteractiveCard_LarkErrorCode(t *testing.T) { + fake := newLarkFake(t) + fake.stubToken("tok_p", 7200) + fake.stubPatch(map[string]any{"code": 230002, "msg": "card not found"}, nil) + c := newTestClient(fake, time.Now) + err := c.PatchInteractiveCard(context.Background(), PatchCardParams{ + InstallationID: testCreds(), + LarkCardMessageID: "om_msg_x", + CardJSON: `{}`, + }) + if err == nil || !strings.Contains(err.Error(), "code=230002") { + t.Errorf("want code=230002 in error, got %v", err) + } +} + +func TestHTTPClient_SendBindingPromptCard_HappyPath(t *testing.T) { + fake := newLarkFake(t) + fake.stubToken("tok_b", 7200) + + var capturedBody map[string]string + fake.mux.HandleFunc("/open-apis/im/v1/messages", func(w http.ResponseWriter, r *http.Request) { + fake.bindN.Add(1) + _ = json.NewDecoder(r.Body).Decode(&capturedBody) + if got := r.URL.Query().Get("receive_id_type"); got != "open_id" { + t.Errorf("receive_id_type: got %q want open_id", got) + } + writeJSON(w, map[string]any{"code": 0, "data": map[string]string{"message_id": "om_bind"}}) + }) + + c := newTestClient(fake, time.Now) + if err := c.SendBindingPromptCard(context.Background(), BindingPromptParams{ + InstallationID: testCreds(), + OpenID: OpenID("ou_user_1"), + BindURL: "https://multica.test/lark/bind?token=abc", + }); err != nil { + t.Fatalf("bind prompt: %v", err) + } + if capturedBody["receive_id"] != "ou_user_1" { + t.Errorf("receive_id: got %q", capturedBody["receive_id"]) + } + if !strings.Contains(capturedBody["content"], "multica.test/lark/bind") { + t.Errorf("binding card should embed BindURL: %q", capturedBody["content"]) + } + if !strings.Contains(capturedBody["content"], "去绑定") { + t.Errorf("binding card should carry the localized CTA: %q", capturedBody["content"]) + } +} + +func TestHTTPClient_TokenEndpointError(t *testing.T) { + fake := newLarkFake(t) + fake.stubTokenError(10003, "invalid app_id or app_secret") + c := newTestClient(fake, time.Now) + _, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc"), + CardJSON: `{}`, + }) + if err == nil || !strings.Contains(err.Error(), "code=10003") { + t.Errorf("want code=10003 surfaced, got %v", err) + } +} + +func TestHTTPClient_MissingAppCredentials(t *testing.T) { + c := NewHTTPAPIClient(HTTPClientConfig{}).(*httpAPIClient) + _, err := c.tenantAccessToken(context.Background(), InstallationCredentials{AppSecret: "x"}) + if err == nil || !strings.Contains(err.Error(), "app_id") { + t.Errorf("want missing app_id error, got %v", err) + } + _, err = c.tenantAccessToken(context.Background(), InstallationCredentials{AppID: "x"}) + if err == nil || !strings.Contains(err.Error(), "app_secret") { + t.Errorf("want missing app_secret error, got %v", err) + } +} + +func TestHTTPClient_MissingChatID_PreAuth(t *testing.T) { + // chat_id validation must short-circuit BEFORE any auth round-trip + // — otherwise a misuse leaks load to the token endpoint. + fake := newLarkFake(t) + c := newTestClient(fake, time.Now) + _, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + CardJSON: `{}`, + }) + if err == nil || !strings.Contains(err.Error(), "chat_id") { + t.Errorf("want missing chat_id error, got %v", err) + } + if got := fake.tokenN.Load(); got != 0 { + t.Errorf("token endpoint must not be hit on bad input: got %d", got) + } +} + +func TestHTTPClient_MissingCardJSON(t *testing.T) { + c := NewHTTPAPIClient(HTTPClientConfig{}).(*httpAPIClient) + if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc"), + }); err == nil || !strings.Contains(err.Error(), "card json") { + t.Errorf("send: want missing card json, got %v", err) + } + if err := c.PatchInteractiveCard(context.Background(), PatchCardParams{ + InstallationID: testCreds(), + LarkCardMessageID: "om", + }); err == nil || !strings.Contains(err.Error(), "card json") { + t.Errorf("patch: want missing card json, got %v", err) + } +} + +func TestHTTPClient_PatchMissingID(t *testing.T) { + c := NewHTTPAPIClient(HTTPClientConfig{}).(*httpAPIClient) + err := c.PatchInteractiveCard(context.Background(), PatchCardParams{ + InstallationID: testCreds(), + CardJSON: `{}`, + }) + if err == nil || !strings.Contains(err.Error(), "card message id") { + t.Errorf("want missing message id error, got %v", err) + } +} + +func TestHTTPClient_BindingPromptValidation(t *testing.T) { + c := NewHTTPAPIClient(HTTPClientConfig{}).(*httpAPIClient) + if err := c.SendBindingPromptCard(context.Background(), BindingPromptParams{ + InstallationID: testCreds(), + BindURL: "https://x", + }); err == nil || !strings.Contains(err.Error(), "open_id") { + t.Errorf("want missing open_id, got %v", err) + } + if err := c.SendBindingPromptCard(context.Background(), BindingPromptParams{ + InstallationID: testCreds(), + OpenID: "ou", + }); err == nil || !strings.Contains(err.Error(), "bind url") { + t.Errorf("want missing bind url, got %v", err) + } +} + +func TestHTTPClient_ExchangeOAuthCode_NotImplemented(t *testing.T) { + c := NewHTTPAPIClient(HTTPClientConfig{}) + _, err := c.ExchangeOAuthCode(context.Background(), "code_x", "https://x") + if !errors.Is(err, ErrAPIClientNotConfigured) { + t.Errorf("OAuth exchange should still surface ErrAPIClientNotConfigured: %v", err) + } +} + +func TestHTTPClient_BadHTTPStatus(t *testing.T) { + fake := newLarkFake(t) + // Token returns success. + fake.stubToken("tok", 7200) + // Send replies with 500 + body — exercise the non-2xx branch. + fake.mux.HandleFunc("/open-apis/im/v1/messages", func(w http.ResponseWriter, r *http.Request) { + fake.sendN.Add(1) + w.WriteHeader(500) + _, _ = io.WriteString(w, "boom") + }) + c := newTestClient(fake, time.Now) + _, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc"), + CardJSON: `{}`, + }) + if err == nil || !strings.Contains(err.Error(), "http 500") { + t.Errorf("want http 500 surfaced, got %v", err) + } +} + +func TestHTTPClient_TokenExpire_ClampedToSafety(t *testing.T) { + // Lark returns expire=10s — well under the safety margin. The + // client must NOT cache a token that is already past its safe + // window; instead it clamps to 2× safety margin so the cached + // entry is at least usable for one safety margin of wall-clock. + fake := newLarkFake(t) + fake.stubToken("tok_short", 10) + fake.stubSend(map[string]any{"code": 0, "data": map[string]string{"message_id": "om"}}, nil) + + now := time.Unix(1_700_000_000, 0) + clock := &fakeClock{now: now} + c := NewHTTPAPIClient(HTTPClientConfig{BaseURL: fake.URL(), Now: clock.Now}).(*httpAPIClient) + + if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc"), + CardJSON: `{}`, + }); err != nil { + t.Fatalf("send: %v", err) + } + clock.Advance(30 * time.Second) // still within clamped window + if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{ + InstallationID: testCreds(), + ChatID: ChatID("oc"), + CardJSON: `{}`, + }); err != nil { + t.Fatalf("send2: %v", err) + } + if got := fake.tokenN.Load(); got != 1 { + t.Errorf("token endpoint hits within clamped window: got %d want 1", got) + } +} + +func TestBindingPromptTemplate_Shape(t *testing.T) { + raw, err := bindingPromptTemplate("https://multica.test/bind?token=abc") + if err != nil { + t.Fatalf("template: %v", err) + } + var doc map[string]any + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + t.Fatalf("template json: %v", err) + } + // Shape check — top-level keys exist and elements is non-empty. + if _, ok := doc["config"]; !ok { + t.Errorf("missing config") + } + if _, ok := doc["header"]; !ok { + t.Errorf("missing header") + } + elements, ok := doc["elements"].([]any) + if !ok || len(elements) < 2 { + t.Fatalf("elements: want >=2, got %v", doc["elements"]) + } + // Last element should be the action button carrying the URL. + last, _ := elements[len(elements)-1].(map[string]any) + if last["tag"] != "action" { + t.Errorf("last element should be action: %v", last) + } + actions, _ := last["actions"].([]any) + if len(actions) == 0 { + t.Fatalf("no actions in card") + } + btn, _ := actions[0].(map[string]any) + if btn["url"] != "https://multica.test/bind?token=abc" { + t.Errorf("button url: got %v", btn["url"]) + } +} + +// fakeClock is a minimal monotonic clock for tests that need to drive +// the cache TTL deterministically. +type fakeClock struct{ now time.Time } + +func (c *fakeClock) Now() time.Time { return c.now } +func (c *fakeClock) Advance(d time.Duration) { c.now = c.now.Add(d) }