mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* 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>
203 lines
8.0 KiB
Go
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
|
|
}
|