fix(output): preserve non-data payloads in the api success envelope (#2601)

* fix: preserve non-data payload keys in SuccessEnvelopeData

When an API response uses a non-"data" key (e.g., /bot/v3/info returns
payload under "bot"), the previous implementation discarded the payload
and returned an empty object. Fall back to the envelope minus transport
fields (code, msg, data) so the business payload is preserved.

Fixes #2428

(cherry picked from commit a82585718c)

* fix(output): pass non-object bodies through and pin api envelope at command level

Follow-up to the cherry-picked fix for #2428: return nil bodies as {} and
non-object bodies untouched instead of collapsing them, align the new test
with its neighbours, and add cmd/api regression tests through the httpmock
path so the user-visible envelope is pinned where the bug was reported.

* test(output): pin null data with sibling payload, drop SDK-pinned array test

TestApiCmd_NonObjectBody_FailsLoudly asserted the SDK's pre-decode rejection
of non-object bodies, not the output-layer branch this PR added; reverting
that branch left it green. The unit test already covers the branch, so the
command-level copy is removed. Add a unit case for {"data": null, "bot": {..}},
which the previous code collapsed to {} and now returns as {"bot": {..}}.

* fix(output): normalize legacy bot payloads

---------

Co-authored-by: Wu Shuwen <108231307+dajiaohuang@users.noreply.github.com>
Co-authored-by: sang-neo03 <266690410+sang-neo03@users.noreply.github.com>
This commit is contained in:
sang-neo03
2026-09-03 01:14:22 +08:00
committed by GitHub
parent 515f9f5a4a
commit 59f6ad4900
5 changed files with 194 additions and 8 deletions
+44
View File
@@ -195,6 +195,50 @@ func TestApiCmd_BotMode(t *testing.T) {
}
}
func TestApiCmd_BotPayload_NormalizedInEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-nondata", AppSecret: "test-secret-nondata", Brand: core.BrandFeishu,
})
// /bot/v3/info is a legacy endpoint whose payload sits beside code/msg
// under "bot" instead of inside "data" (#2428).
reg.Register(&httpmock.Stub{
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"bot": map[string]interface{}{"open_id": "ou_123", "app_name": "TestBot"},
},
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/bot/v3/info", "--as", "bot"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v, want object", got["data"])
}
if _, ok := data["bot"]; ok {
t.Fatalf("data = %#v, want legacy bot container normalized away", data)
}
if data["open_id"] != "ou_123" || data["app_name"] != "TestBot" {
t.Fatalf("data = %#v, want normalized bot fields", data)
}
for _, k := range []string{"code", "msg"} {
if _, leaked := data[k]; leaked {
t.Fatalf("transport field %q leaked into data: %s", k, stdout.String())
}
}
}
func TestApiCmd_MissingArgs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
+25 -5
View File
@@ -18,16 +18,36 @@ type SuccessEnvelopeOptions struct {
// SuccessEnvelopeData extracts the business payload for the standard success
// envelope from a Lark API response. Outer code/msg fields are transport
// protocol details and are intentionally not exposed as business data.
//
// Most endpoints wrap the payload in "data". The legacy /open-apis/bot/v3/info
// endpoint uses "bot" as that payload container; normalize its sole business
// field to the same data shape. Other non-data responses fall back to everything
// except the transport fields. A non-object body is passed through untouched so
// this function never collapses a payload to {}.
func SuccessEnvelopeData(result interface{}) interface{} {
if result == nil {
return map[string]interface{}{}
}
m, ok := result.(map[string]interface{})
if !ok {
return map[string]interface{}{}
return result
}
data, ok := m["data"]
if !ok || data == nil {
return map[string]interface{}{}
if data, ok := m["data"]; ok && data != nil {
return data
}
return data
payload := make(map[string]interface{}, len(m))
for k, v := range m {
if k == "code" || k == "msg" || k == "data" {
continue
}
payload[k] = v
}
if len(payload) == 1 {
if bot, ok := payload["bot"].(map[string]interface{}); ok {
return bot
}
}
return payload
}
// WriteSuccessEnvelope emits the standard success envelope used by shortcuts.
+122
View File
@@ -6,6 +6,7 @@ package output
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
@@ -33,6 +34,21 @@ func TestSuccessEnvelopeData_ExtractsBusinessData(t *testing.T) {
}
}
func TestSuccessEnvelopeData_StandardDataTakesPrecedenceOverBot(t *testing.T) {
result := map[string]interface{}{
"code": float64(0),
"msg": "ok",
"data": map[string]interface{}{"id": "1"},
"bot": map[string]interface{}{"open_id": "ou_legacy"},
}
got := SuccessEnvelopeData(result)
want := map[string]interface{}{"id": "1"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("business data = %#v, want standard data payload %#v", got, want)
}
}
func TestSuccessEnvelopeData_MissingDataUsesEmptyObject(t *testing.T) {
got := SuccessEnvelopeData(map[string]interface{}{"code": float64(0), "msg": "ok"})
m, ok := got.(map[string]interface{})
@@ -55,6 +71,112 @@ func TestSuccessEnvelopeData_NilDataUsesEmptyObject(t *testing.T) {
}
}
func TestSuccessEnvelopeData_BotPayloadNormalized(t *testing.T) {
// /bot/v3/info returns payload under "bot" key, not "data"
result := map[string]interface{}{
"code": float64(0),
"msg": "ok",
"bot": map[string]interface{}{
"activate_status": float64(2),
"app_name": "TestBot",
"open_id": "ou_123",
},
}
got := SuccessEnvelopeData(result)
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("business data type = %T, want map", got)
}
if _, ok := m["code"]; ok {
t.Fatal("business data must not contain outer code")
}
if _, ok := m["msg"]; ok {
t.Fatal("business data must not contain outer msg")
}
if _, ok := m["bot"]; ok {
t.Fatalf("business data = %#v, want legacy bot container normalized away", m)
}
if m["activate_status"] != float64(2) {
t.Fatalf("activate_status = %v, want 2", m["activate_status"])
}
if m["app_name"] != "TestBot" {
t.Fatalf("app_name = %v, want TestBot", m["app_name"])
}
}
func TestSuccessEnvelopeData_UnknownNonDataPayloadPreserved(t *testing.T) {
result := map[string]interface{}{
"code": float64(0),
"msg": "ok",
"result": "success",
"request_id": "req_123",
}
got := SuccessEnvelopeData(result)
want := map[string]interface{}{"result": "success", "request_id": "req_123"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("business data = %#v, want %#v", got, want)
}
}
func TestSuccessEnvelopeData_NullDataWithBotPayloadNormalized(t *testing.T) {
// A null "data" next to a payload key used to collapse to {} and lose the
// payload; the legacy bot container is normalized like standard data.
result := map[string]interface{}{
"code": float64(0),
"msg": "ok",
"data": nil,
"bot": map[string]interface{}{"open_id": "ou_123"},
}
got := SuccessEnvelopeData(result)
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("business data type = %T, want map", got)
}
if _, ok := m["data"]; ok {
t.Fatal("business data must not contain the null data key")
}
if _, ok := m["bot"]; ok {
t.Fatalf("business data = %#v, want legacy bot container normalized away", m)
}
if m["open_id"] != "ou_123" {
t.Fatalf("business data.open_id = %#v, want ou_123", m["open_id"])
}
if len(m) != 1 {
t.Fatalf("business data = %#v, want only the normalized bot fields", m)
}
}
func TestSuccessEnvelopeData_NilResultUsesEmptyObject(t *testing.T) {
got := SuccessEnvelopeData(nil)
m, ok := got.(map[string]interface{})
if !ok || len(m) != 0 {
t.Fatalf("business data = %#v, want empty object", got)
}
}
func TestSuccessEnvelopeData_NonObjectBodyPreserved(t *testing.T) {
cases := []struct {
name string
body interface{}
}{
{"array", []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}},
{"string", "pong"},
{"number", json.Number("42")},
{"bool", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := SuccessEnvelopeData(tc.body)
if !reflect.DeepEqual(got, tc.body) {
t.Fatalf("business data = %#v, want body %#v preserved", got, tc.body)
}
})
}
}
func TestWriteSuccessEnvelope_PrintsShortcutCompatibleEnvelope(t *testing.T) {
var out strings.Builder
+2 -2
View File
@@ -204,8 +204,8 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
```bash
# 1. 获取当前应用的 open_id
lark-cli api GET /open-apis/bot/v3/info --as bot
# 从返回值中取 bot.open_id
lark-cli api GET /open-apis/bot/v3/info --as bot --jq '.data.open_id'
# 输出即当前应用的 open_id
# 2. 授权当前应用访问文档
lark-cli drive permission.members create \
@@ -39,7 +39,7 @@
需要将文档权限授予当前应用bot自身时
1. 先执行 `lark-cli api GET /open-apis/bot/v3/info --as bot`,从返回值取 `bot.open_id`
1. 先执行 `lark-cli api GET /open-apis/bot/v3/info --as bot --jq '.data.open_id'`,直接取得当前应用的 `open_id`
2. 再调用 `lark-cli drive permission.members create`,用 `member_type=openid``member_id=<bot_open_id>` 授权。
```bash