mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 02:00:19 +08:00
271 lines
8.3 KiB
Go
271 lines
8.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package client
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
|
|
"github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/util"
|
|
)
|
|
|
|
// RawApiRequest describes a raw API request.
|
|
type RawApiRequest struct {
|
|
Method string
|
|
URL string
|
|
Params map[string]interface{}
|
|
Data interface{}
|
|
As core.Identity
|
|
ExtraOpts []larkcore.RequestOptionFunc // additional SDK request options (e.g. security headers)
|
|
}
|
|
|
|
// APIClient wraps lark.Client for all Lark Open API calls.
|
|
type APIClient struct {
|
|
Config *core.CliConfig
|
|
SDK *lark.Client // All Lark API calls go through SDK
|
|
HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.)
|
|
ErrOut io.Writer // debug/progress output
|
|
}
|
|
|
|
// buildApiReq converts a RawApiRequest into SDK types and collects
|
|
// request-specific options (ExtraOpts, URL-based headers).
|
|
// Auth is handled separately by DoSDKRequest.
|
|
func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []larkcore.RequestOptionFunc) {
|
|
queryParams := make(larkcore.QueryParams)
|
|
for k, v := range request.Params {
|
|
switch val := v.(type) {
|
|
case []string:
|
|
queryParams[k] = val
|
|
case []interface{}:
|
|
for _, item := range val {
|
|
queryParams.Add(k, fmt.Sprintf("%v", item))
|
|
}
|
|
default:
|
|
queryParams.Set(k, fmt.Sprintf("%v", v))
|
|
}
|
|
}
|
|
|
|
apiReq := &larkcore.ApiReq{
|
|
HttpMethod: strings.ToUpper(request.Method),
|
|
ApiPath: request.URL,
|
|
Body: request.Data,
|
|
QueryParams: queryParams,
|
|
}
|
|
|
|
var opts []larkcore.RequestOptionFunc
|
|
opts = append(opts, request.ExtraOpts...)
|
|
return apiReq, opts
|
|
}
|
|
|
|
// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request.
|
|
// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest)
|
|
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
|
|
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
|
var opts []larkcore.RequestOptionFunc
|
|
|
|
if as.IsBot() {
|
|
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
|
|
} else {
|
|
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}
|
|
if c.Config.UserOpenId == "" {
|
|
return nil, fmt.Errorf("login required: lark-cli auth login (or use --as bot)")
|
|
}
|
|
token, err := auth.GetValidAccessToken(c.HTTP, auth.NewUATCallOptions(c.Config, c.ErrOut))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
opts = append(opts, larkcore.WithUserAccessToken(token))
|
|
}
|
|
|
|
opts = append(opts, extraOpts...)
|
|
return c.SDK.Do(ctx, req, opts...)
|
|
}
|
|
|
|
// DoAPI executes a raw Lark SDK request and returns the raw *larkcore.ApiResp.
|
|
// Unlike CallAPI which always JSON-decodes, DoAPI returns the raw response — suitable
|
|
// for file downloads (pass larkcore.WithFileDownload() via request.ExtraOpts) and
|
|
// any endpoint whose Content-Type may not be JSON.
|
|
func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore.ApiResp, error) {
|
|
apiReq, extraOpts := c.buildApiReq(request)
|
|
return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...)
|
|
}
|
|
|
|
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse.
|
|
// Use DoAPI directly when the response may not be JSON (e.g. file downloads).
|
|
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
|
|
resp, err := c.DoAPI(ctx, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseJSONResponse(resp)
|
|
}
|
|
|
|
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
|
|
// it calls onResult if non-nil. It always accumulates and returns all raw page results.
|
|
func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{})) ([]interface{}, error) {
|
|
var allResults []interface{}
|
|
var pageToken string
|
|
page := 0
|
|
pageDelay := opts.PageDelay
|
|
if pageDelay == 0 {
|
|
pageDelay = 200
|
|
}
|
|
|
|
for {
|
|
page++
|
|
params := make(map[string]interface{})
|
|
for k, v := range request.Params {
|
|
params[k] = v
|
|
}
|
|
if pageToken != "" {
|
|
params["page_token"] = pageToken
|
|
}
|
|
|
|
fmt.Fprintf(c.ErrOut, "[page %d] fetching...\n", page)
|
|
result, err := c.CallAPI(ctx, RawApiRequest{
|
|
Method: request.Method,
|
|
URL: request.URL,
|
|
Params: params,
|
|
Data: request.Data,
|
|
As: request.As,
|
|
ExtraOpts: request.ExtraOpts,
|
|
})
|
|
if err != nil {
|
|
if page == 1 {
|
|
return nil, err
|
|
}
|
|
fmt.Fprintf(c.ErrOut, "[page %d] error, stopping pagination\n", page)
|
|
break
|
|
}
|
|
|
|
if resultMap, ok := result.(map[string]interface{}); ok {
|
|
code, _ := util.ToFloat64(resultMap["code"])
|
|
if code != 0 {
|
|
allResults = append(allResults, result)
|
|
if page == 1 {
|
|
return allResults, nil
|
|
}
|
|
fmt.Fprintf(c.ErrOut, "[page %d] API error (code=%.0f), stopping pagination\n", page, code)
|
|
break
|
|
}
|
|
}
|
|
|
|
if onResult != nil {
|
|
onResult(result)
|
|
}
|
|
allResults = append(allResults, result)
|
|
|
|
pageToken = ""
|
|
if resultMap, ok := result.(map[string]interface{}); ok {
|
|
if data, ok := resultMap["data"].(map[string]interface{}); ok {
|
|
hasMore, _ := data["has_more"].(bool)
|
|
if hasMore {
|
|
if pt, ok := data["page_token"].(string); ok && pt != "" {
|
|
pageToken = pt
|
|
} else if pt, ok := data["next_page_token"].(string); ok && pt != "" {
|
|
pageToken = pt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if pageToken == "" {
|
|
break
|
|
}
|
|
|
|
if opts.PageLimit > 0 && page >= opts.PageLimit {
|
|
fmt.Fprintf(c.ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", opts.PageLimit)
|
|
break
|
|
}
|
|
|
|
if pageDelay > 0 {
|
|
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
|
|
}
|
|
}
|
|
return allResults, nil
|
|
}
|
|
|
|
// PaginateAll fetches all pages and returns a single merged result.
|
|
// Use this for formats that need the complete dataset (e.g. JSON).
|
|
func (c *APIClient) PaginateAll(ctx context.Context, request RawApiRequest, opts PaginationOptions) (interface{}, error) {
|
|
results, err := c.paginateLoop(ctx, request, opts, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(results) == 0 {
|
|
return map[string]interface{}{}, nil
|
|
}
|
|
if len(results) == 1 {
|
|
return results[0], nil
|
|
}
|
|
return mergePagedResults(c.ErrOut, results), nil
|
|
}
|
|
|
|
// StreamPages fetches all pages and streams each page's list items via onItems.
|
|
// Returns the last page result (for error checking), whether any list items were found,
|
|
// and any network error. Use this for streaming formats (ndjson, table, csv).
|
|
func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}), opts PaginationOptions) (result interface{}, hasItems bool, err error) {
|
|
totalItems := 0
|
|
results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) {
|
|
resultMap, ok := r.(map[string]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
data, ok := resultMap["data"].(map[string]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
arrayField := output.FindArrayField(data)
|
|
if arrayField == "" {
|
|
return
|
|
}
|
|
items, ok := data[arrayField].([]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
totalItems += len(items)
|
|
onItems(items)
|
|
hasItems = true
|
|
})
|
|
if loopErr != nil {
|
|
return nil, false, loopErr
|
|
}
|
|
|
|
if hasItems {
|
|
fmt.Fprintf(c.ErrOut, "[pagination] streamed %d pages, %d total items\n", len(results), totalItems)
|
|
}
|
|
|
|
if len(results) > 0 {
|
|
return results[len(results)-1], hasItems, nil
|
|
}
|
|
return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil
|
|
}
|
|
|
|
// CheckLarkResponse inspects a Lark API response for business-level errors (non-zero code).
|
|
// Uses type assertion instead of interface{} == nil to satisfy interface_nil_check lint.
|
|
// Returns nil if result is not a map, map is nil, or code is 0.
|
|
func CheckLarkResponse(result interface{}) error {
|
|
resultMap, ok := result.(map[string]interface{})
|
|
if !ok || resultMap == nil {
|
|
return nil
|
|
}
|
|
code, _ := util.ToFloat64(resultMap["code"])
|
|
if code == 0 {
|
|
return nil
|
|
}
|
|
larkCode := int(code)
|
|
msg, _ := resultMap["msg"].(string)
|
|
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
|
|
}
|