mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +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>
199 lines
6.7 KiB
Go
199 lines
6.7 KiB
Go
package lark
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// capturingRoundTripper records the host of every outbound request and
|
|
// replies with a canned Lark-style JSON body that satisfies every decode
|
|
// path the client takes (token mint, bot info, contact union_id). It lets
|
|
// a test assert WHICH open-platform host a call targeted without dialing
|
|
// the real public Feishu / Lark domains.
|
|
type capturingRoundTripper struct {
|
|
hosts []string
|
|
}
|
|
|
|
func (rt *capturingRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
rt.hosts = append(rt.hosts, r.URL.Host)
|
|
const body = `{"code":0,"msg":"ok","tenant_access_token":"t","expire":7200,` +
|
|
`"bot":{"open_id":"ou_x"},"data":{"user":{"union_id":"on_x"}}}`
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}
|
|
|
|
// TestRegion_OpenPlatformBaseURL pins the region→host mapping that both
|
|
// the REST client and the WS bootstrap depend on.
|
|
func TestRegion_OpenPlatformBaseURL(t *testing.T) {
|
|
cases := []struct {
|
|
region Region
|
|
want string
|
|
}{
|
|
{RegionFeishu, "https://open.feishu.cn"},
|
|
{RegionLark, "https://open.larksuite.com"},
|
|
{Region(""), "https://open.feishu.cn"},
|
|
{Region("bogus"), "https://open.feishu.cn"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := tc.region.OpenPlatformBaseURL(); got != tc.want {
|
|
t.Errorf("Region(%q).OpenPlatformBaseURL() = %q, want %q", tc.region, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRegionOrDefault pins the normalization used at every credential-
|
|
// build site: unknown / empty strings collapse to Feishu so a malformed
|
|
// row never yields an empty host or a CHECK-violating write.
|
|
func TestRegionOrDefault(t *testing.T) {
|
|
cases := map[string]Region{
|
|
"feishu": RegionFeishu,
|
|
"lark": RegionLark,
|
|
"": RegionFeishu,
|
|
"LARK": RegionFeishu, // case-sensitive on purpose; CHECK stores lowercase
|
|
"intl": RegionFeishu,
|
|
}
|
|
for in, want := range cases {
|
|
if got := RegionOrDefault(in); got != want {
|
|
t.Errorf("RegionOrDefault(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIsLarkInternationalHost gates the upgrade-repair backfill: only a
|
|
// deployment-wide override pointing at open.larksuite.com should relabel
|
|
// legacy installs. Mainland, empty, mock/staging, and scheme-less values
|
|
// must NOT trigger it.
|
|
func TestIsLarkInternationalHost(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"https://open.larksuite.com": true,
|
|
"https://open.larksuite.com/": true,
|
|
"https://OPEN.LARKSUITE.COM": true, // host compare is case-insensitive
|
|
"https://open.feishu.cn": false,
|
|
"": false,
|
|
" ": false,
|
|
"https://mock.internal:8080": false,
|
|
"open.larksuite.com": false, // no scheme → not a usable override anyway
|
|
}
|
|
for in, want := range cases {
|
|
if got := isLarkInternationalHost(in); got != want {
|
|
t.Errorf("isLarkInternationalHost(%q) = %v, want %v", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHTTPClient_ResolvesHostFromRegion is the core dual-region guarantee:
|
|
// with NO deployment-wide BaseURL override, the open-platform host is
|
|
// chosen per call from InstallationCredentials.Region, so Feishu and Lark
|
|
// installations served by one process each reach their own cloud.
|
|
func TestHTTPClient_ResolvesHostFromRegion(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
region Region
|
|
host string
|
|
}{
|
|
{"feishu", RegionFeishu, "open.feishu.cn"},
|
|
{"lark", RegionLark, "open.larksuite.com"},
|
|
{"empty defaults to feishu", Region(""), "open.feishu.cn"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rt := &capturingRoundTripper{}
|
|
// No BaseURL → region resolution governs the host.
|
|
c := NewHTTPAPIClient(HTTPClientConfig{HTTPClient: &http.Client{Transport: rt}})
|
|
if _, err := c.GetBotInfo(context.Background(), InstallationCredentials{
|
|
AppID: "cli_x", AppSecret: "s", Region: tc.region,
|
|
}); err != nil {
|
|
t.Fatalf("GetBotInfo: %v", err)
|
|
}
|
|
if len(rt.hosts) == 0 {
|
|
t.Fatalf("no requests captured")
|
|
}
|
|
for _, h := range rt.hosts {
|
|
if h != tc.host {
|
|
t.Errorf("request targeted host %q, want %q", h, tc.host)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHTTPClient_BaseURLOverridesRegion pins the test / staging seam: an
|
|
// explicit cfg.BaseURL forces every region to that host, which is how the
|
|
// existing test suite (and MULTICA_LARK_HTTP_BASE_URL) keeps working.
|
|
func TestHTTPClient_BaseURLOverridesRegion(t *testing.T) {
|
|
rt := &capturingRoundTripper{}
|
|
c := NewHTTPAPIClient(HTTPClientConfig{
|
|
BaseURL: "https://override.example.com",
|
|
HTTPClient: &http.Client{Transport: rt},
|
|
})
|
|
if _, err := c.GetBotInfo(context.Background(), InstallationCredentials{
|
|
AppID: "cli_x", AppSecret: "s", Region: RegionLark, // would be larksuite, but override wins
|
|
}); err != nil {
|
|
t.Fatalf("GetBotInfo: %v", err)
|
|
}
|
|
for _, h := range rt.hosts {
|
|
if h != "override.example.com" {
|
|
t.Errorf("override not honored: host=%q, want override.example.com", h)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWSEndpoint_ResolvesHostFromRegion pins that the long-conn bootstrap
|
|
// POST (/callback/ws/endpoint) also targets the per-installation region
|
|
// host when no deployment-wide override is set.
|
|
func TestWSEndpoint_ResolvesHostFromRegion(t *testing.T) {
|
|
cases := []struct {
|
|
region Region
|
|
host string
|
|
}{
|
|
{RegionFeishu, "open.feishu.cn"},
|
|
{RegionLark, "open.larksuite.com"},
|
|
{Region(""), "open.feishu.cn"},
|
|
}
|
|
for _, tc := range cases {
|
|
rt := &wsEndpointRoundTripper{}
|
|
f, err := NewHTTPConnectionTokenFetcher(HTTPConnectionTokenConfig{
|
|
HTTPClient: &http.Client{Transport: rt},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPConnectionTokenFetcher: %v", err)
|
|
}
|
|
if _, err := f.Endpoint(context.Background(), InstallationCredentials{
|
|
AppID: "cli_x", AppSecret: "s", Region: tc.region,
|
|
}); err != nil {
|
|
t.Fatalf("Endpoint(region=%q): %v", tc.region, err)
|
|
}
|
|
if rt.host != tc.host {
|
|
t.Errorf("ws bootstrap targeted host %q, want %q (region=%q)", rt.host, tc.host, tc.region)
|
|
}
|
|
if rt.path != "/callback/ws/endpoint" {
|
|
t.Errorf("ws bootstrap path = %q, want /callback/ws/endpoint", rt.path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// wsEndpointRoundTripper returns a valid endpointResponse so Endpoint's
|
|
// decode succeeds, while recording the host + path it was asked to reach.
|
|
type wsEndpointRoundTripper struct {
|
|
host string
|
|
path string
|
|
}
|
|
|
|
func (rt *wsEndpointRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
rt.host = r.URL.Host
|
|
rt.path = r.URL.Path
|
|
const body = `{"code":0,"msg":"ok","data":{"URL":"wss://example/ws?service_id=1&device_id=d",` +
|
|
`"ClientConfig":{"ReconnectCount":1,"ReconnectInterval":120,"ReconnectNonce":30,"PingInterval":120}}}`
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}
|