Files
multica/server/internal/integrations/lark/ws_endpoint.go
Bohan Jiang 6ac8314711 feat(lark): support both Feishu and Lark from one deployment (MUL-3083) (#3815)
* feat(lark): serve Feishu and Lark from one deployment, per installation

The Lark integration was locked to a single open-platform host chosen
deployment-wide (MULTICA_LARK_HTTP_BASE_URL / _CALLBACK_BASE_URL,
defaulting to open.feishu.cn), so one deployment could talk to only the
mainland Feishu cloud OR Lark international — never both. Teams on the
other tenant could not use the integration at all.

Make the host per-installation. The device-flow installer already
auto-detects the tenant (Lark emits tenant_brand="lark" mid-poll); we now
persist that as lark_installation.region, carry it on
InstallationCredentials.Region, and resolve the open-platform host per
call (REST + WS bootstrap) from the region. An explicit cfg.BaseURL
(env / httptest) still overrides every region, so existing tests and
staging/proxy setups keep working.

- migration 116: lark_installation.region TEXT NOT NULL DEFAULT 'feishu'
  CHECK (region IN ('feishu','lark')) — existing rows are all mainland.
- lark.Region enum + OpenPlatformBaseURL/RegionOrDefault helpers.
- registration: thread the detected region into finishSuccess so the
  install-time GetBotInfo hits the right cloud AND the row records it.
- every credential-build site (patcher, replier, WS provider, union_id
  backfill) copies region off the installation row.
- region is part of the WS supervisor fingerprint so a re-install that
  switches cloud restarts the connection.
- API: surface region on the installation listing DTO.

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>

* feat(lark): surface installation region in settings UI

Read the per-installation region off the listings response: build the
"Manage in Lark" dev-console host from it (open.feishu.cn vs
open.larksuite.com instead of a hardcoded mainland host) and render a
Feishu / Lark badge on each connected bot. The field is optional and
defaults to Feishu when an older server omits it (API-compat). Adds the
region_feishu / region_lark labels to all four locales.

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>

* docs(lark): document simultaneous Feishu + Lark support

The cloud each bot belongs to is now auto-detected at install and stored
per installation, so one deployment serves both. Replace the old
"point MULTICA_LARK_HTTP_BASE_URL at larksuite for international tenants"
guidance (now just an optional override) in all four locales.

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>

* fix(lark): repair legacy Lark-international installs on upgrade

Review follow-up (MUL-3083). Migration 116 backfilled every existing
lark_installation to region='feishu', assuming all historical rows were
mainland. But self-host deployments could already run Lark international
via the deployment-wide MULTICA_LARK_HTTP_BASE_URL override, so those
rows are really Lark — clearing the override after upgrade (which the new
docs invite) would route them to open.feishu.cn and break them.

Add a one-shot startup repair, BackfillRegionFromLegacyOverride, fired
off the hot path like BackfillBotUnionIDs: when the deployment's global
base-URL override targets open.larksuite.com, relabel the still-default
'feishu' rows to 'lark'. Gating on the deployment-wide override is what
makes it safe — every pre-existing install on such a deployment was Lark.
Idempotent; no-op on mainland / fresh deployments. Verified end-to-end
against a scratch DB (flip then 0-row idempotent re-run).

Also document that a Lark/飞书 app_id is globally unique across both
clouds, which is what makes the app_id-keyed token cache and the
UNIQUE(app_id) constraint safe across regions (review nit).

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>

* docs(lark): fix ops guidance to match auto per-installation region

Review follow-up (MUL-3083). .env.example and docker-compose.selfhost.yml
still told operators that international Lark requires pointing both base
URLs at open.larksuite.com — now wrong, and it would push a fresh
deployment back into a single-cloud override. Rewrite them: the base
URLs are optional deployment-wide overrides; normal dual-cloud operation
keeps them empty. Document the first-boot auto-relabel for deployments
migrating off the old single-cloud override, across the integration docs
(en/zh/ja/ko).

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 16:03:13 +08:00

203 lines
8.0 KiB
Go

package lark
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"strconv"
"time"
)
// The bootstrap host for the long-conn `/callback/ws/endpoint` request
// is the installation's open-platform host — open.feishu.cn for Feishu
// (mainland), open.larksuite.com for Lark (international) — resolved per
// call from InstallationCredentials.Region via Region.OpenPlatformBaseURL
// (Lark returns the actual wss URL in the response body, so only the
// bootstrap POST host has to be region-aware). A deployment-wide
// MULTICA_LARK_CALLBACK_BASE_URL still overrides every installation when
// set (staging / mock).
// HTTPConnectionTokenFetcher is the production EndpointFetcher. It
// exchanges per-installation app credentials for a short-lived
// WebSocket URL + ClientConfig by calling
// `POST /callback/ws/endpoint` on Lark's open-platform host — the
// same bootstrap path the official `larksuite/oapi-sdk-go/v3/ws`
// client uses. The request body carries `{AppID, AppSecret}` plain
// (no tenant_access_token bearer); the response carries the wss URL
// (single-use, embedded device_id/service_id auth) and a ClientConfig
// with PingInterval / ReconnectInterval / ReconnectNonce /
// ReconnectCount in seconds.
//
// We do NOT cache the response. The wss URL is single-use by design
// (the embedded `device_id` is rotated on every bootstrap call), so
// re-using it on a reconnect would yield an auth rejection that looks
// like a Lark outage. The connector calls Endpoint() once per Run.
//
// PersonalAgent compatibility — OPEN RISK (MUL-2671 review thread):
// the official Feishu docs describe long-conn mode as "supports
// 企业自建应用 only". The PersonalAgent device-flow archetype is not
// listed as supported; live confirmation is pending. If the bootstrap
// call returns a structured "app type not supported" error, this code
// surfaces the code+msg directly so the Hub's backoff loop logs the
// real reason instead of looping silently. The smoke test path is
// `multica` -> register a PersonalAgent -> enable WS -> watch logs.
type HTTPConnectionTokenFetcher struct {
cfg HTTPConnectionTokenConfig
}
// HTTPConnectionTokenConfig wires the fetcher's dependencies. BaseURL is
// an optional deployment-wide override; when empty (the production
// default) Endpoint() resolves the bootstrap host per installation from
// the region. Tests substitute an httptest.Server URL to force all
// regions to the fake server.
type HTTPConnectionTokenConfig struct {
BaseURL string
HTTPClient *http.Client
Now func() time.Time
Logger *slog.Logger
}
func (c HTTPConnectionTokenConfig) withDefaults() HTTPConnectionTokenConfig {
// BaseURL is intentionally NOT defaulted here. Empty means "no
// deployment-wide override" — Endpoint() then resolves the bootstrap
// host per installation from InstallationCredentials.Region, so one
// fetcher serves both Feishu and Lark. A non-empty BaseURL
// (MULTICA_LARK_CALLBACK_BASE_URL, or an httptest URL in tests)
// forces every installation to that host.
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
}
// NewHTTPConnectionTokenFetcher returns the production EndpointFetcher
// bound to the supplied configuration.
func NewHTTPConnectionTokenFetcher(cfg HTTPConnectionTokenConfig) (*HTTPConnectionTokenFetcher, error) {
return &HTTPConnectionTokenFetcher{cfg: cfg.withDefaults()}, nil
}
// bootstrapRequest mirrors the SDK's BootstrapRequest. Field names use
// PascalCase exactly because the server-side JSON tags are PascalCase
// (`AppID`, not `app_id`); the SDK's pbbp2 schema dictates the format
// and lower-snake_case would not match.
type bootstrapRequest struct {
AppID string `json:"AppID"`
AppSecret string `json:"AppSecret"`
}
// endpointResponse mirrors the SDK's EndpointResp + Endpoint +
// ClientConfig. Field naming is PascalCase to match Lark's wire shape.
type endpointResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
URL string `json:"URL"`
ClientConfig struct {
ReconnectCount int `json:"ReconnectCount"`
ReconnectInterval int `json:"ReconnectInterval"`
ReconnectNonce int `json:"ReconnectNonce"`
PingInterval int `json:"PingInterval"`
} `json:"ClientConfig"`
} `json:"data"`
}
// Endpoint implements EndpointFetcher.
func (f *HTTPConnectionTokenFetcher) Endpoint(ctx context.Context, creds InstallationCredentials) (WSEndpoint, error) {
if creds.AppID == "" || creds.AppSecret == "" {
return WSEndpoint{}, errors.New("lark ws endpoint: missing app_id / app_secret")
}
body := bootstrapRequest{AppID: creds.AppID, AppSecret: creds.AppSecret}
raw, err := json.Marshal(body)
if err != nil {
return WSEndpoint{}, fmt.Errorf("marshal body: %w", err)
}
// Resolve the bootstrap host per call: an explicit cfg.BaseURL
// override wins (env / httptest), otherwise the installation's region
// picks Feishu vs Lark so one fetcher serves both clouds.
base := f.cfg.BaseURL
if base == "" {
base = creds.Region.OpenPlatformBaseURL()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/callback/ws/endpoint", bytes.NewReader(raw))
if err != nil {
return WSEndpoint{}, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
// Locale header is sent verbatim by the SDK — Lark uses it for the
// error `msg` field (Chinese vs English). We pick zh because that's
// the audience Multica server logs are read by today; if i18n
// matters later this becomes an env or a per-installation knob.
req.Header.Set("locale", "zh")
resp, err := f.cfg.HTTPClient.Do(req)
if err != nil {
return WSEndpoint{}, fmt.Errorf("http do: %w", err)
}
defer resp.Body.Close()
rawResp, err := io.ReadAll(resp.Body)
if err != nil {
return WSEndpoint{}, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return WSEndpoint{}, fmt.Errorf("http %d: %s", resp.StatusCode, truncate(string(rawResp), 512))
}
var decoded endpointResponse
if err := json.Unmarshal(rawResp, &decoded); err != nil {
return WSEndpoint{}, fmt.Errorf("decode response: %w (raw=%s)", err, truncate(string(rawResp), 256))
}
if decoded.Code != 0 || decoded.Data.URL == "" {
// Surface the structured Lark error verbatim — that's what
// operators need to disambiguate "app type not supported"
// (PersonalAgent risk) from "credentials wrong" from "Lark
// outage". The downstream Hub backoff logs this on each
// reconnect attempt.
return WSEndpoint{}, fmt.Errorf("lark ws endpoint: code=%d msg=%q", decoded.Code, decoded.Msg)
}
serviceID, err := parseServiceIDFromURL(decoded.Data.URL)
if err != nil {
return WSEndpoint{}, fmt.Errorf("parse service_id from wss url: %w", err)
}
return WSEndpoint{
URL: decoded.Data.URL,
Headers: http.Header{},
ServiceID: serviceID,
PingInterval: time.Duration(decoded.Data.ClientConfig.PingInterval) * time.Second,
ReconnectInterval: time.Duration(decoded.Data.ClientConfig.ReconnectInterval) * time.Second,
ReconnectNonce: time.Duration(decoded.Data.ClientConfig.ReconnectNonce) * time.Second,
ReconnectCount: decoded.Data.ClientConfig.ReconnectCount,
}, nil
}
// parseServiceIDFromURL extracts the `service_id` query parameter Lark
// embeds in the wss URL. The connector needs this value to address
// outbound Frame.Service for ping/pong and ACK frames; the SDK does
// the same.
func parseServiceIDFromURL(rawURL string) (int32, error) {
u, err := url.Parse(rawURL)
if err != nil {
return 0, err
}
sid := u.Query().Get("service_id")
if sid == "" {
return 0, errors.New("missing service_id query parameter")
}
n, err := strconv.ParseInt(sid, 10, 32)
if err != nil {
return 0, fmt.Errorf("service_id %q is not an int: %w", sid, err)
}
return int32(n), nil
}