mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 11:10:25 +02:00
* feat(lark): split bind CTA into Feishu and Lark entry points (MUL-3083 follow-up)
The single "Bind to Lark" button began the device flow against
accounts.feishu.cn and relied on a mid-poll tenant_brand="lark" to
auto-switch international users over to accounts.larksuite.com. Lark
users had to scan a QR served from a Feishu domain first, which
surfaced as confusing in real use.
Replace with two explicit CTAs side by side — "Bind to Feishu" and
"Bind to Lark" — and route the device-flow begin straight to the
matching accounts host based on the user's choice. The mid-poll
auto-switch is preserved as a safety net for users who pick the wrong
entry.
Backend
- RegistrationClient.Begin(ctx, namePreset, region): POSTs to
c.cfg.LarkDomain when region=lark, c.cfg.Domain otherwise. Empty /
unknown region falls back to Feishu (matches RegionOrDefault).
- BeginInstallParams.Region threads through to the registration session
and onto runPolling's initial region local. SwitchedDomain still
flips it on tenant_brand=lark.
- POST /api/workspaces/{id}/lark/install/begin accepts ?region=feishu|lark
with empty defaulting to feishu for back-compat.
Frontend
- api.beginLarkInstall(wsId, agentId, region) — region now required
so every call site is forced to pick a cloud explicitly.
- LarkAgentBindButton renders two buttons; dialog state collapsed into
a single dialogRegion useState so an "open but with no region picked"
intermediate state can't exist.
- LarkInstallDialog takes region as a required prop and renders
region-aware copy (title, description, scan hint, link fallback,
success toast).
i18n
- Add bind_button_{feishu,lark}, install_dialog_{title,description}_*,
install_scan_hint_*, install_open_link_fallback_*, and
install_success_toast_* keys across en, zh-Hans, ja, ko. Legacy
single-region keys are kept for now; nothing in the tree references
them anymore but a follow-up cleanup can remove them once the dust
settles.
Tests
- Two new lark.RegistrationClient tests pin region routing in both
directions (region=lark hits LarkDomain; region=feishu hits Domain).
- Two new lark-tab.test.tsx cases pin that clicking each CTA calls
beginLarkInstall with the matching region argument. Existing CTA
tests updated to expect both buttons in place of one.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): bidirectional tenant_brand swap + region-aware badge + link context menu
Addresses Elon's review on PR #3832 plus a separate report that the
"Or tap here to open in Lark" link in the install dialog had no
standard right-click affordances on the desktop app.
Backend (must-fix from review)
The PR's stated 'safety net for users who pick the wrong CTA' only
worked one direction: a Feishu-first begin already swapped to Lark on
tenant_brand=lark, but the new Lark-first begin (added by this same PR)
had no reverse path — a user who picked 'Bind to Lark' but actually
authorized with a Feishu account would carry RegionLark all the way
through finishSuccess and either fail at GetBotInfo or commit a
wrong-region row.
- PollResult now carries SwitchedDomain AND SwitchedRegion in
lockstep, so the caller never has to re-derive region from the
domain string.
- Poll() detects tenant_brand=feishu while polling against a non-Feishu
host symmetrically with the existing tenant_brand=lark check, gated
on the current host so we don't loop on a brand we already match.
- runPolling reads region from res.SwitchedRegion instead of the
hardcoded RegionLark — the SwitchedDomain branch now flips both
feishu→lark and lark→feishu cleanly.
- Tests: updated the existing TestRegistrationClient_Poll_DomainSwitchOnLarkTenant
to assert SwitchedRegion, added TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant
for the reverse, and TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost
(table-driven, both directions) to pin that the gate doesn't loop.
Backend (nit from review)
Handler comment on /lark/install/begin claimed unknown region defaults
to Feishu downstream, but the handler already returns 400 on unknown
values. Updated the comment to match the actual behavior and document
why we 400 rather than silently normalize (so a frontend typo can't
land users on the wrong cloud without telling them).
Frontend (nit from review)
The Agent inspector's Connected badge was hardcoded 'Connected to
Lark' / 'Manage in Lark' (en) and 'Connected to Feishu' / 'Manage in
Feishu' (zh-Hans) — both wrong half the time now that the install
flow can land on either cloud per agent. Made the badge text and
Manage tooltip read from installation.region:
- agent_bot_connected_label_{feishu,lark}
- agent_bot_manage_link_{feishu,lark}
- agent_bot_manage_tooltip_{feishu,lark}
across en / zh-Hans / ja / ko. Legacy single-region keys retained for
safety. Existing badge tests updated: fixtures without 'region' now
expect the Feishu copy; the region: 'lark' test was promoted to also
assert the Lark badge text and link target. 21/21 lark-tab tests pass.
Desktop (separate report)
Right-clicking an <a> in the renderer surfaced only Copy / Cut /
Paste / Select All — no 'Open Link in Browser' or 'Copy Link Address'.
The renderer's <a target="_blank"> click path already routes through
setWindowOpenHandler → openExternalSafely, but discoverability via the
context menu was missing.
context-menu.ts now appends two link-specific items when params.linkURL
is an http(s) URL. Open Link routes through openExternalSafely (reuses
the existing scheme allowlist); Copy Link Address writes to Electron's
clipboard. Labels are localized to the OS preferred language for the
four locales the renderer ships (en / zh-Hans / ja / ko); zh-* variants
all route to zh-Hans, anything else falls back to English. New
context-menu.test.ts pins five cases: link items show for http(s),
not for javascript:/mailto:/etc., not when no link is under the cursor,
zh-CN gets Chinese, fr-FR falls back to English. 198/198 desktop tests
pass.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
491 lines
20 KiB
Go
491 lines
20 KiB
Go
package lark
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Lark PersonalAgent registration is a 1:1 implementation of RFC 8628
|
|
// (OAuth 2.0 Device Authorization Grant) against accounts.feishu.cn
|
|
// (mainland) / accounts.larksuite.com (international). The protocol
|
|
// has only two phases:
|
|
//
|
|
// 1. begin — POST <domain>/oauth/v1/app/registration with
|
|
// action=begin and (archetype=PersonalAgent / auth_method=client_secret
|
|
// / request_user_info=open_id). Lark returns a device_code, a
|
|
// verification_uri_complete (the QR target), a polling interval,
|
|
// and an expiry. Multica renders the QR, the user scans it in the
|
|
// Lark app, walks the "create a PersonalAgent for this account"
|
|
// flow, and authorizes.
|
|
//
|
|
// 2. poll — POST the same URL with action=poll and the device_code.
|
|
// The server replies with one of:
|
|
// - {error: "authorization_pending"} — keep polling.
|
|
// - {error: "slow_down"} — bump the interval +5s, then poll.
|
|
// - {user_info: {tenant_brand: "lark"}}
|
|
// — user authorized via the
|
|
// international tenant; we
|
|
// switch the polling host to
|
|
// accounts.larksuite.com and
|
|
// keep going.
|
|
// - {client_id, client_secret, user_info: {open_id}}
|
|
// — terminal success.
|
|
// - {error: "expired_token"|"access_denied"}
|
|
// — terminal failure.
|
|
//
|
|
// We deliberately inline this client (rather than depend on
|
|
// github.com/larksuite/oapi-sdk-go/scene/registration) so the registration
|
|
// surface ships with the same go.mod footprint as the rest of the lark
|
|
// package — a ~270-line single-file client is the right size to own
|
|
// when the alternative is dragging a full SDK + its transitive deps
|
|
// for one endpoint.
|
|
|
|
const (
|
|
registrationDefaultFeishuDomain = "https://accounts.feishu.cn"
|
|
registrationDefaultLarkDomain = "https://accounts.larksuite.com"
|
|
|
|
registrationEndpoint = "/oauth/v1/app/registration"
|
|
|
|
// Default polling cadence Lark uses when the server omits `interval`.
|
|
// 5s matches the Lark SDK; smaller would risk slow_down responses
|
|
// without buying any latency improvement.
|
|
registrationDefaultPollSeconds = 5
|
|
|
|
// Default registration window (10 minutes) — long enough for a user
|
|
// to scan, switch apps, walk the create-bot flow, and authorize on
|
|
// their phone, short enough that an abandoned session does not pin
|
|
// resources for hours.
|
|
registrationDefaultExpireSeconds = 600
|
|
|
|
// Internal-tenant brand label Lark uses to flag "you scanned with a
|
|
// Lark (international) account, not a Feishu (mainland) one". When
|
|
// we see this we re-aim polling at accounts.larksuite.com and
|
|
// re-issue the very next poll WITHOUT first waiting for the polling
|
|
// interval — the upstream SDK shows that Lark's server emits the
|
|
// tenant_brand hint exactly once during the polling stream and the
|
|
// subsequent poll must reach the new domain to learn the credentials.
|
|
registrationTenantBrandLark = "lark"
|
|
|
|
// Mirror brand label for the reverse direction: a user who picked
|
|
// the "Bind to Lark" CTA but actually authorized with a mainland
|
|
// Feishu account. The split-CTA UX (MUL-3083) rendered a QR against
|
|
// accounts.larksuite.com, but Lark's poll stream surfaces
|
|
// tenant_brand="feishu" once authorization completes on the wrong
|
|
// cloud, and we honor that signal symmetrically — re-aim polling
|
|
// at accounts.feishu.cn and let the next poll fetch the credentials
|
|
// from the right host. Without this, "wrong entry" was a hard
|
|
// install failure for the lark→feishu direction even though the
|
|
// feishu→lark direction recovered automatically.
|
|
registrationTenantBrandFeishu = "feishu"
|
|
)
|
|
|
|
// RegistrationConfig configures the device-flow client. All fields are
|
|
// optional; the zero value targets accounts.feishu.cn over the standard
|
|
// http.Client (with a 30s per-call timeout so a stalled poll cannot
|
|
// silently pin a session goroutine for the entire expiry window).
|
|
type RegistrationConfig struct {
|
|
// Domain is the initial polling host. Default
|
|
// "https://accounts.feishu.cn"; staging deployments can point this
|
|
// at a mock or at the Lark beta endpoint.
|
|
Domain string
|
|
|
|
// LarkDomain is the international-tenant polling host the client
|
|
// switches to when Lark's poll response surfaces
|
|
// user_info.tenant_brand="lark". Default
|
|
// "https://accounts.larksuite.com".
|
|
LarkDomain string
|
|
|
|
// HTTPClient is the transport for every request the client makes.
|
|
// Empty defaults to a fresh *http.Client with a 30s timeout — the
|
|
// device-flow endpoint is normally a sub-second call but we add
|
|
// headroom for cross-region paths.
|
|
HTTPClient *http.Client
|
|
|
|
// Source labels the QR-code URL's `source` query param so Lark's
|
|
// telemetry can attribute installs back to Multica. Empty defaults
|
|
// to "multica".
|
|
Source string
|
|
|
|
// Now is overridable for deterministic expiry-bound tests.
|
|
Now func() time.Time
|
|
}
|
|
|
|
func (c RegistrationConfig) withDefaults() RegistrationConfig {
|
|
if c.Domain == "" {
|
|
c.Domain = registrationDefaultFeishuDomain
|
|
}
|
|
if c.LarkDomain == "" {
|
|
c.LarkDomain = registrationDefaultLarkDomain
|
|
}
|
|
if c.HTTPClient == nil {
|
|
c.HTTPClient = &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
if c.Source == "" {
|
|
c.Source = "multica"
|
|
}
|
|
if c.Now == nil {
|
|
c.Now = time.Now
|
|
}
|
|
return c
|
|
}
|
|
|
|
// RegistrationClient runs the device-flow protocol but does NOT own
|
|
// session state or installation provisioning — RegistrationService
|
|
// composes the client with the session store and the DB write path.
|
|
// Splitting these lets the protocol client be deterministic and easy
|
|
// to test against an httptest fake without involving the database.
|
|
type RegistrationClient struct {
|
|
cfg RegistrationConfig
|
|
}
|
|
|
|
// NewRegistrationClient constructs the device-flow client.
|
|
func NewRegistrationClient(cfg RegistrationConfig) *RegistrationClient {
|
|
return &RegistrationClient{cfg: cfg.withDefaults()}
|
|
}
|
|
|
|
// BeginResult is what Begin returns to RegistrationService.
|
|
type BeginResult struct {
|
|
DeviceCode string
|
|
// QRCodeURL is the verification_uri_complete with Multica's `source`
|
|
// telemetry params appended; render this as a QR image client-side.
|
|
QRCodeURL string
|
|
// Domain is the polling host this session opened against. The
|
|
// service must pass it back to Poll so the international-tenant
|
|
// switch (StatusDomainSwitched) can re-aim subsequent polls.
|
|
Domain string
|
|
// Interval is Lark's suggested polling cadence. Slow_down responses
|
|
// add 5s; the service is responsible for honoring the updated
|
|
// cadence.
|
|
Interval time.Duration
|
|
// ExpiresIn is the absolute lifetime of the device_code. A poll
|
|
// after this window returns expired_token; the session goroutine
|
|
// uses this to size its context.WithTimeout.
|
|
ExpiresIn time.Duration
|
|
}
|
|
|
|
// PollResult is the discriminated union of every terminal and
|
|
// non-terminal poll outcome. The caller branches on the populated
|
|
// fields:
|
|
// - Success (ClientID + ClientSecret + OpenID) → install
|
|
// - SwitchedDomain (new domain string) → swap host, re-poll immediately
|
|
// - Status ("authorization_pending" / "slow_down") → wait, poll again
|
|
// - Err (terminal error) → abort the session
|
|
type PollResult struct {
|
|
ClientID string
|
|
ClientSecret string
|
|
OpenID OpenID
|
|
|
|
// SwitchedDomain is non-empty when Lark told us "this is the wrong
|
|
// cloud, re-poll over there." It is paired with SwitchedRegion so
|
|
// the caller can update both the polling host AND the per-install
|
|
// region in one step. Originally this only fired in the
|
|
// Feishu→Lark direction (Lark international users authorizing on
|
|
// a Feishu-first begin); after MUL-3083 follow-up it is symmetric,
|
|
// so a user who picked the "wrong" Bind CTA also recovers — the
|
|
// service must update the session's stored domain AND region and
|
|
// re-poll WITHOUT honoring the interval (the SDK does the same —
|
|
// the upstream behaviour is that the very next poll lands on the
|
|
// new domain and returns the actual credentials).
|
|
SwitchedDomain string
|
|
// SwitchedRegion is the region the new domain belongs to. Set in
|
|
// lockstep with SwitchedDomain; ignored when SwitchedDomain is
|
|
// empty. Carrying the region here keeps the caller from having to
|
|
// re-derive it from the domain string at session-update time.
|
|
SwitchedRegion Region
|
|
|
|
// Status carries non-terminal protocol signals — typically
|
|
// "authorization_pending" or "slow_down". The service uses these
|
|
// to decide whether to bump the polling interval.
|
|
Status string
|
|
|
|
// Err is the terminal error code (e.g. "access_denied",
|
|
// "expired_token", or a free-form Lark code we did not anticipate).
|
|
// On a non-terminal result this is nil.
|
|
Err *RegistrationError
|
|
}
|
|
|
|
// RegistrationError is the typed Lark protocol error. The handler
|
|
// pipeline maps `Code` to a stable user-facing reason so the UI can
|
|
// render the right copy without parsing prose.
|
|
type RegistrationError struct {
|
|
Code string
|
|
Description string
|
|
}
|
|
|
|
func (e *RegistrationError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
if e.Description == "" {
|
|
return fmt.Sprintf("registration: %s", e.Code)
|
|
}
|
|
return fmt.Sprintf("registration: %s: %s", e.Code, e.Description)
|
|
}
|
|
|
|
// Begin opens a new device-flow session against the open-platform host
|
|
// for the requested region. Region is normally chosen explicitly by the
|
|
// caller (the user picked "Feishu" or "Lark" in the UI) so the QR
|
|
// renders against the same cloud the user expects to scan from; an
|
|
// empty value falls back to Feishu (mainland) for back-compat with
|
|
// callers that pre-date region-aware install. Lark may STILL surface a
|
|
// Lark-international tenant on a subsequent poll even when the begin
|
|
// host was Feishu — the SwitchedDomain branch in RegistrationService
|
|
// keeps that auto-detect path alive as a fallback for users who pick
|
|
// the wrong entry, so explicit region selection is a routing
|
|
// optimization (saves one round-trip and renders the right cloud's QR
|
|
// up front), not a constraint on what the device flow can recover from.
|
|
//
|
|
// namePreset pre-fills the bot/app name on Lark's "create a
|
|
// PersonalAgent" form so the installed bot defaults to e.g.
|
|
// "<agent> - Multica" instead of Lark's auto-generated
|
|
// "{用户姓名}的智能助手". It is a user-editable default (the user can
|
|
// still change it on the form), and it rides on the QR URL — not the
|
|
// begin POST body, which has no name field. Empty omits the pre-fill.
|
|
func (c *RegistrationClient) Begin(ctx context.Context, namePreset string, region Region) (*BeginResult, error) {
|
|
// Pick the begin domain off the requested region. Empty / unknown
|
|
// regions degrade to Feishu (mainland) — same back-compat invariant
|
|
// as RegionOrDefault, so callers that pre-date this signature
|
|
// (passing "") keep working.
|
|
domain := c.cfg.Domain
|
|
if region == RegionLark {
|
|
domain = c.cfg.LarkDomain
|
|
}
|
|
var resp struct {
|
|
DeviceCode string `json:"device_code"`
|
|
VerificationURIComplete string `json:"verification_uri_complete"`
|
|
VerificationURI string `json:"verification_uri"`
|
|
UserCode string `json:"user_code"`
|
|
Interval int `json:"interval"`
|
|
ExpireIn int `json:"expire_in"`
|
|
Error string `json:"error"`
|
|
ErrorDescription string `json:"error_description"`
|
|
}
|
|
form := url.Values{
|
|
"action": []string{"begin"},
|
|
"archetype": []string{"PersonalAgent"},
|
|
"auth_method": []string{"client_secret"},
|
|
"request_user_info": []string{"open_id"},
|
|
}
|
|
if err := c.doForm(ctx, domain, form, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.Error != "" {
|
|
return nil, &RegistrationError{Code: resp.Error, Description: resp.ErrorDescription}
|
|
}
|
|
if resp.DeviceCode == "" {
|
|
return nil, &RegistrationError{Code: "invalid_response", Description: "device_code is empty"}
|
|
}
|
|
if resp.VerificationURIComplete == "" {
|
|
return nil, &RegistrationError{Code: "invalid_response", Description: "verification_uri_complete is empty"}
|
|
}
|
|
qr, err := decorateQRCodeURL(resp.VerificationURIComplete, c.cfg.Source, namePreset)
|
|
if err != nil {
|
|
return nil, &RegistrationError{Code: "invalid_response", Description: "verification_uri_complete is not a URL: " + err.Error()}
|
|
}
|
|
interval := registrationDefaultPollSeconds
|
|
if resp.Interval > 0 {
|
|
interval = resp.Interval
|
|
}
|
|
expireIn := registrationDefaultExpireSeconds
|
|
if resp.ExpireIn > 0 {
|
|
expireIn = resp.ExpireIn
|
|
}
|
|
return &BeginResult{
|
|
DeviceCode: resp.DeviceCode,
|
|
QRCodeURL: qr,
|
|
Domain: domain,
|
|
Interval: time.Duration(interval) * time.Second,
|
|
ExpiresIn: time.Duration(expireIn) * time.Second,
|
|
}, nil
|
|
}
|
|
|
|
// Poll runs a single poll round-trip against the supplied domain (which
|
|
// the caller may have updated mid-session via SwitchedDomain from a
|
|
// prior PollResult). Domain selection lives outside the client so the
|
|
// session state machine in RegistrationService is the single source of
|
|
// truth for which host the next call must hit.
|
|
func (c *RegistrationClient) Poll(ctx context.Context, domain, deviceCode string) (*PollResult, error) {
|
|
if deviceCode == "" {
|
|
return nil, &RegistrationError{Code: "invalid_argument", Description: "device_code is required"}
|
|
}
|
|
if domain == "" {
|
|
domain = c.cfg.Domain
|
|
}
|
|
var resp struct {
|
|
ClientID string `json:"client_id,omitempty"`
|
|
ClientSecret string `json:"client_secret,omitempty"`
|
|
UserInfo *struct {
|
|
OpenID string `json:"open_id,omitempty"`
|
|
TenantBrand string `json:"tenant_brand,omitempty"`
|
|
} `json:"user_info,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
ErrorDescription string `json:"error_description,omitempty"`
|
|
}
|
|
form := url.Values{
|
|
"action": []string{"poll"},
|
|
"device_code": []string{deviceCode},
|
|
}
|
|
if err := c.doForm(ctx, domain, form, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Tenant-brand-driven domain swap. Lark emits this exactly once on
|
|
// the transition poll when the authorized account does not match
|
|
// the cloud the begin call hit; the next poll must reach the
|
|
// matching open-platform host to learn the credentials. We surface
|
|
// the swap (domain + region) as a typed signal so the service does
|
|
// not have to know the brand string OR re-derive the region from
|
|
// the host.
|
|
//
|
|
// Both directions are honored: feishu→lark for users who scanned a
|
|
// Feishu QR with a Lark-international account, AND lark→feishu for
|
|
// users who picked the new "Bind to Lark" CTA but actually
|
|
// authorized with a mainland Feishu account. Symmetry matters
|
|
// because the split-CTA UI (MUL-3083) also begins on
|
|
// accounts.larksuite.com directly — without the reverse swap, a
|
|
// "wrong entry" install on that side would carry RegionLark all
|
|
// the way through finishSuccess and fail (or commit a wrong-region
|
|
// row) at GetBotInfo. The check is gated on the current domain so
|
|
// we do not loop on the same brand we already match.
|
|
if resp.UserInfo != nil {
|
|
switch resp.UserInfo.TenantBrand {
|
|
case registrationTenantBrandLark:
|
|
if !strings.HasPrefix(domain, c.cfg.LarkDomain) {
|
|
return &PollResult{
|
|
SwitchedDomain: c.cfg.LarkDomain,
|
|
SwitchedRegion: RegionLark,
|
|
}, nil
|
|
}
|
|
case registrationTenantBrandFeishu:
|
|
if !strings.HasPrefix(domain, c.cfg.Domain) {
|
|
return &PollResult{
|
|
SwitchedDomain: c.cfg.Domain,
|
|
SwitchedRegion: RegionFeishu,
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Success: both client_id AND client_secret AND the installer
|
|
// open_id must be present. Partial responses are treated as a
|
|
// protocol error so RegistrationService never writes a
|
|
// half-populated lark_installation row.
|
|
if resp.ClientID != "" && resp.ClientSecret != "" {
|
|
if resp.UserInfo == nil || resp.UserInfo.OpenID == "" {
|
|
return nil, &RegistrationError{
|
|
Code: "invalid_response",
|
|
Description: "success response missing installer open_id",
|
|
}
|
|
}
|
|
return &PollResult{
|
|
ClientID: resp.ClientID,
|
|
ClientSecret: resp.ClientSecret,
|
|
OpenID: OpenID(resp.UserInfo.OpenID),
|
|
}, nil
|
|
}
|
|
|
|
switch resp.Error {
|
|
case "authorization_pending", "slow_down":
|
|
return &PollResult{Status: resp.Error}, nil
|
|
case "access_denied", "expired_token":
|
|
return &PollResult{
|
|
Err: &RegistrationError{Code: resp.Error, Description: resp.ErrorDescription},
|
|
}, nil
|
|
case "":
|
|
// Empty error AND empty credentials = keep polling; this
|
|
// matches the upstream SDK's tolerant handling for the case
|
|
// where the server briefly returns an empty body during the
|
|
// authorize-redirect window.
|
|
return &PollResult{Status: "authorization_pending"}, nil
|
|
default:
|
|
return &PollResult{
|
|
Err: &RegistrationError{Code: resp.Error, Description: resp.ErrorDescription},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func (c *RegistrationClient) doForm(ctx context.Context, domain string, form url.Values, out any) error {
|
|
endpoint := strings.TrimRight(domain, "/") + registrationEndpoint
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return fmt.Errorf("registration: new request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := c.cfg.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("registration: http do: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("registration: read body: %w", err)
|
|
}
|
|
if len(body) == 0 {
|
|
return &RegistrationError{
|
|
Code: fmt.Sprintf("http_%d", resp.StatusCode),
|
|
Description: "empty body",
|
|
}
|
|
}
|
|
// RFC 8628 device-flow servers return non-2xx with a JSON body whose
|
|
// `error` field is the actual signal — `authorization_pending` and
|
|
// `slow_down` arrive as HTTP 400, NOT 2xx. Decoding the body first
|
|
// and letting the caller route on `resp.Error` is what the upstream
|
|
// Go SDK does; treating any non-2xx as a hard protocol error (the
|
|
// previous behaviour) killed every session on the first poll because
|
|
// the user hasn't scanned the QR yet at that point.
|
|
if jsonErr := json.Unmarshal(body, out); jsonErr == nil {
|
|
return nil
|
|
}
|
|
// Body didn't parse — surface the raw status + payload tail so ops
|
|
// can tell a Lark outage / proxy interception apart from a schema
|
|
// drift. Caller treats this as a terminal protocol error.
|
|
return &RegistrationError{
|
|
Code: fmt.Sprintf("http_%d", resp.StatusCode),
|
|
Description: truncate(string(body), 256),
|
|
}
|
|
}
|
|
|
|
// decorateQRCodeURL appends the SDK-style telemetry params Lark expects
|
|
// on the QR-image URL. Without `from=sdk&tp=sdk&source=<src>` the
|
|
// scanner UI on the user's phone shows a less polished prompt and Lark
|
|
// cannot attribute installs back to Multica in their analytics.
|
|
//
|
|
// namePreset, when non-empty, is appended as `name=<...>` to pre-fill
|
|
// the bot/app name on Lark's "create a PersonalAgent" form. This
|
|
// mirrors the upstream SDK's AppPreset.Name: Lark reads it from the
|
|
// verification/QR URL (the begin POST body carries no name field) and
|
|
// treats it as a user-editable default, not a locked final name.
|
|
func decorateQRCodeURL(raw, source, namePreset string) (string, error) {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
q := u.Query()
|
|
q.Set("from", "sdk")
|
|
q.Set("tp", "sdk")
|
|
q.Set("source", "go-sdk/"+source)
|
|
if namePreset != "" {
|
|
q.Set("name", namePreset)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
return u.String(), nil
|
|
}
|
|
|
|
// ErrRegistrationAccessDenied is returned by RegistrationService when
|
|
// the user explicitly denied the install in the Lark UI. Distinct from
|
|
// other terminal failures so the UI can render "you cancelled the
|
|
// install" instead of a generic error.
|
|
var ErrRegistrationAccessDenied = errors.New("lark registration: access denied by user")
|
|
|
|
// ErrRegistrationExpired is returned by RegistrationService when the
|
|
// device_code's expiry window elapsed without the user authorizing.
|
|
// Distinct so the UI can prompt "scan again — the previous QR expired".
|
|
var ErrRegistrationExpired = errors.New("lark registration: expired")
|