mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
feat(integrations/lark): real Lark HTTP APIClient for IM v1 send/patch (MUL-2671)
Lands the production Lark Open Platform HTTP APIClient that replaces
the stub for outbound transport. The patcher's "thinking → streaming
→ final | error" card lifecycle and the dispatcher's binding-prompt
card both now reach Lark for real once MULTICA_LARK_HTTP_ENABLED=true.
Scope of this stage:
- tenant_access_token retrieval via /open-apis/auth/v3/
tenant_access_token/internal, cached in-process per app_id with a
60s safety margin against Lark's `expire` value. Sub-2-minute
expires are clamped to 120s so we never cache an entry that's
already past its safe window.
- SendInteractiveCard: POST /open-apis/im/v1/messages?receive_id_type=chat_id
returning the Lark message_id the Patcher persists in
lark_outbound_card_message for later patches.
- PatchInteractiveCard: PATCH /open-apis/im/v1/messages/:id with
the full re-rendered card body (Lark's update endpoint replaces,
not deep-merges).
- SendBindingPromptCard: open_id-targeted interactive card with a
primary "去绑定" CTA pointing at the redemption URL. Template is
co-located with the transport so the dispatcher never has to know
about Lark's card schema.
- Token-error invalidation: Lark codes 99991663 (expired) /
99991664 (invalid) drop the cached token so the next call
refreshes from /tenant_access_token/internal instead of looping
on a stale entry.
Out of scope (deferred to follow-up stages):
- ExchangeOAuthCode stays unimplemented behind
ErrAPIClientNotConfigured. The PersonalAgent install handshake's
response shape (returning per-installation app credentials in a
single call) is not yet verified against the production endpoint,
and a silent mis-fill of OAuthExchangeResult would corrupt
lark_installation rows past validateExchangeResult. Operators
continue to use the manual-paste InstallationService path until
the OAuth stage lands.
- Inbound WS EventConnector — Hub's ConnectorFactory still needs a
real wire-protocol implementation.
Wiring:
- MULTICA_LARK_HTTP_ENABLED=true switches router.go from the stub
to the real client. MULTICA_LARK_HTTP_BASE_URL overrides the
default open.feishu.cn host (set to open.larksuite.com for the
Lark international tenant, or to an httptest URL for integration
tests).
- The OAuth handler now also receives the real client (its
ExchangeOAuthCode still surfaces ErrAPIClientNotConfigured, so
callback behavior is unchanged until that stage lands).
Tests (19 new cases against an httptest.Server fake):
- happy path send/patch/binding-prompt round trips, asserting URL
query params, body shape, Authorization header
- token cache: 3 sends share one /tenant_access_token/internal hit
- token refresh after clock-driven expiry
- sub-margin expire clamping (10s expire → cached for >= safety
margin of wall-clock)
- Lark error code surfacing (230001 send, 230002 patch, 10003 auth)
- token-expired (99991663) invalidates the cache; caller's retry
re-fetches and succeeds
- non-2xx HTTP status surfaces "http 500: …"
- input validation: missing chat_id short-circuits BEFORE auth
round-trip, missing card json / open_id / bind url all fail
pre-flight without hitting Lark
- ExchangeOAuthCode still returns ErrAPIClientNotConfigured
- binding-prompt template carries the BindURL and the localized
"去绑定" CTA in valid JSON
go build ./..., go vet ./..., and go test ./internal/integrations/lark/...
pass. Pre-existing handler/router integration tests that require a
real Postgres connection are unaffected by this change.
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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 {
|
||||
|
||||
426
server/internal/integrations/lark/http_client.go
Normal file
426
server/internal/integrations/lark/http_client.go
Normal file
@@ -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
|
||||
}
|
||||
603
server/internal/integrations/lark/http_client_test.go
Normal file
603
server/internal/integrations/lark/http_client_test.go
Normal file
@@ -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/<id>; 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) }
|
||||
Reference in New Issue
Block a user