Files
larksuite-cli/cmd/agent/runtime.go
liuxinyang.lxy 118ab4bf1e refactor: typed Runtime.Call[T]/CallUpload[T] over raw data JSON
Runtime.CallAPI/CallMultipart now return the response "data" object as
json.RawMessage instead of map[string]any, and provider hooks decode it through
the generic Call[T] / CallUpload[T] helpers: a hook declares the response struct
it expects and the framework unmarshals + classifies errors, instead of poking
at a map. Call[map[string]any] remains available for genuinely dynamic shapes; a
response with no "data" (e.g. a pure write) yields T's zero value and a nil error.

decodeData centralizes the unmarshal + invalid_response classification. cmdRuntime
re-encodes the unwrapped "data" sub-object to raw JSON after CheckResponse. Test
doubles (cmd/agent + example fakeRuntime, card_test fakeRT) updated to the raw
signature; the runtime tests keep the raw-data assertion and add a Call[T]
decode assertion.
2026-07-13 14:08:42 +08:00

108 lines
3.9 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// cmdRuntime is the concrete iagent.Runtime: it routes provider hook calls
// through the shared client.APIClient under a pinned identity (mirrors event's
// consumeRuntime in cmd/event/runtime.go). Provider code never sees the client,
// the identity resolution, or the response-envelope unwrap — that is exactly the
// plumbing the old Deps struct leaked.
type cmdRuntime struct {
client *client.APIClient
as core.Identity
agentID string
}
func (r *cmdRuntime) AgentID() string { return r.agentID }
func (r *cmdRuntime) IsBot() bool { return r.as == core.AsBot }
func (r *cmdRuntime) CallAPI(ctx context.Context, method, path string, query map[string]string, body any) (json.RawMessage, error) {
var params map[string]interface{}
if len(query) > 0 {
params = make(map[string]interface{}, len(query))
for k, v := range query {
params[k] = v
}
}
return r.do(ctx, client.RawApiRequest{Method: method, URL: path, Params: params, Data: body, As: r.as})
}
func (r *cmdRuntime) CallMultipart(ctx context.Context, method, path string, fields map[string]string, files []iagent.FilePart) (json.RawMessage, error) {
fd := larkcore.NewFormdata()
for k, v := range fields {
fd.AddField(k, v)
}
for _, fp := range files {
// SafeInputPath is the framework-owned security check (no path traversal /
// outside CWD); a provider must never re-implement it.
resolved, err := validate.SafeInputPath(fp.Path)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).
WithParam("--file").WithCause(err)
}
f, err := vfs.Open(resolved)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: 无法打开 %s: %v", fp.Path, err).
WithParam("--file").WithCause(err)
}
// Closed when CallMultipart returns, i.e. after do()'s request has read the
// body — deferring in the loop keeps every file open for the request.
defer f.Close()
fd.AddFile(fp.Field, f)
}
return r.do(ctx, client.RawApiRequest{
Method: method, URL: path, Data: fd, As: r.as,
ExtraOpts: []larkcore.RequestOptionFunc{larkcore.WithFileUpload()},
})
}
// do is the shared DoAPI → ParseJSONResponse → CheckResponse → unwrap-"data"
// path. It returns the "data" sub-object as raw JSON (the typed Call[T]/
// CallUpload[T] helpers decode it). Identity is sealed in r.as and never handed
// out; any non-typed transport error is classified here so hooks only ever see
// typed errs.* values.
func (r *cmdRuntime) do(ctx context.Context, req client.RawApiRequest) (json.RawMessage, error) {
resp, err := r.client.DoAPI(ctx, req)
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "api %s %s: %s", req.Method, req.URL, err).WithCause(err)
}
result, err := client.ParseJSONResponse(resp)
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "api %s %s: %s", req.Method, req.URL, err).WithCause(err)
}
if apiErr := r.client.CheckResponse(result, r.as); apiErr != nil {
return nil, apiErr
}
top, _ := result.(map[string]interface{})
dataVal, ok := top["data"]
if !ok || dataVal == nil {
return nil, nil // no "data" (e.g. a pure write) — callers get the zero value
}
raw, err := json.Marshal(dataVal)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "api %s %s: re-encode data: %s", req.Method, req.URL, err).WithCause(err)
}
return raw, nil
}