mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
4 Commits
feat/impor
...
fix/ppe-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21b81f7ae4 | ||
|
|
5eaeab2bb3 | ||
|
|
8f5504c51c | ||
|
|
d0a896ce91 |
@@ -153,9 +153,79 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
|
||||
if err != nil {
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
c.logAPIResponse(req, resp)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *APIClient) logAPIResponse(req *larkcore.ApiReq, resp *larkcore.ApiResp) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
logID := strings.TrimSpace(resp.LogId())
|
||||
if logID == "" {
|
||||
return
|
||||
}
|
||||
method, path := apiReqLogFields(req, "")
|
||||
fmt.Fprintf(c.errOut(), "[lark-cli] api-response: method=%s path=%s status=%d log_id=%s\n", method, path, resp.StatusCode, logID)
|
||||
}
|
||||
|
||||
func (c *APIClient) logStreamResponse(req *larkcore.ApiReq, requestURL string, resp *http.Response) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
logID := streamLogID(resp.Header)
|
||||
if logID == "" {
|
||||
return
|
||||
}
|
||||
method, path := apiReqLogFields(req, requestURL)
|
||||
fmt.Fprintf(c.errOut(), "[lark-cli] api-response: method=%s path=%s status=%d log_id=%s\n", method, path, resp.StatusCode, logID)
|
||||
}
|
||||
|
||||
func (c *APIClient) errOut() io.Writer {
|
||||
if c != nil && c.ErrOut != nil {
|
||||
return c.ErrOut
|
||||
}
|
||||
return io.Discard
|
||||
}
|
||||
|
||||
func apiReqLogFields(req *larkcore.ApiReq, fallbackURL string) (string, string) {
|
||||
method := ""
|
||||
path := ""
|
||||
if req != nil {
|
||||
method = req.HttpMethod
|
||||
path = req.ApiPath
|
||||
}
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
if method == "" {
|
||||
method = "UNKNOWN"
|
||||
}
|
||||
path = requestLogPath(path)
|
||||
if path == "missing" {
|
||||
path = requestLogPath(fallbackURL)
|
||||
}
|
||||
return method, path
|
||||
}
|
||||
|
||||
func requestLogPath(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "missing"
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil && u.IsAbs() {
|
||||
if u.EscapedPath() != "" {
|
||||
return u.EscapedPath()
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
if i := strings.Index(raw, "?"); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
if raw == "" {
|
||||
return "missing"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
|
||||
// Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns
|
||||
// a live *http.Response whose Body is an io.Reader for streaming consumption.
|
||||
@@ -224,6 +294,7 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
|
||||
}
|
||||
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
||||
c.logStreamResponse(req, requestURL, resp)
|
||||
|
||||
// Handle HTTP errors internally
|
||||
if resp.StatusCode >= 400 {
|
||||
|
||||
@@ -464,6 +464,48 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoStream_LogsLogIDToErrOut(t *testing.T) {
|
||||
errBuf := &bytes.Buffer{}
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
larkcore.HttpHeaderKeyLogId: []string{"stream-log-123"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader("ok")),
|
||||
}, nil
|
||||
})
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Transport: rt},
|
||||
ErrOut: errBuf,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
resp, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
t.Fatalf("DoStream() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := errBuf.String()
|
||||
for _, want := range []string{
|
||||
"[lark-cli] api-response:",
|
||||
"method=GET",
|
||||
"path=/open-apis/drive/v1/medias/file_token/download",
|
||||
"status=200",
|
||||
"log_id=stream-log-123",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("log missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// failingTokenResolver always returns TokenUnavailableError, exercising the
|
||||
// auth/credential failure path through resolveAccessToken.
|
||||
type failingTokenResolver struct{}
|
||||
@@ -618,6 +660,41 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSDKRequest_LogsLogIDToErrOut(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
larkcore.HttpHeaderKeyLogId: []string{"sdk-log-123"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":0,"msg":"ok","data":{}}`)),
|
||||
}, nil
|
||||
})
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/contact/v3/users/me",
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
t.Fatalf("DoSDKRequest() error = %v", err)
|
||||
}
|
||||
|
||||
got := errBuf.String()
|
||||
for _, want := range []string{
|
||||
"[lark-cli] api-response:",
|
||||
"method=GET",
|
||||
"path=/open-apis/contact/v3/users/me",
|
||||
"status=200",
|
||||
"log_id=sdk-log-123",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("log missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for
|
||||
// malformed JSON response bodies: WrapJSONResponseParseError emits
|
||||
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint
|
||||
|
||||
@@ -28,8 +28,14 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderTTEnv = "X-Tt-Env"
|
||||
HeaderUsePPE = "X-Use-Ppe"
|
||||
HeaderRPCAppID = "Rpc-Persist-Cli-Req-App-Id"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
TTEnvValue = "ppe_doubao_office_local"
|
||||
UsePPEValue = "1"
|
||||
RPCAppID = "497858"
|
||||
|
||||
HeaderUserAgent = "User-Agent"
|
||||
|
||||
@@ -75,6 +81,9 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
h.Set(HeaderTTEnv, TTEnvValue)
|
||||
h.Set(HeaderUsePPE, UsePPEValue)
|
||||
h.Set(HeaderRPCAppID, RPCAppID)
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
|
||||
@@ -256,13 +256,26 @@ func TestBaseSecurityHeaders_IncludesBuildHeader(t *testing.T) {
|
||||
|
||||
func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
h := BaseSecurityHeaders()
|
||||
for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent} {
|
||||
for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent, HeaderTTEnv, HeaderUsePPE, HeaderRPCAppID} {
|
||||
if h.Get(key) == "" {
|
||||
t.Errorf("BaseSecurityHeaders missing %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesPersistentRequestHeaders(t *testing.T) {
|
||||
h := BaseSecurityHeaders()
|
||||
if got := h.Get(HeaderTTEnv); got != TTEnvValue {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderTTEnv, got, TTEnvValue)
|
||||
}
|
||||
if got := h.Get(HeaderUsePPE); got != UsePPEValue {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderUsePPE, got, UsePPEValue)
|
||||
}
|
||||
if got := h.Get(HeaderRPCAppID); got != RPCAppID {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderRPCAppID, got, RPCAppID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentTraceValue / HeaderAgentTrace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。默认使用 DocxXML,也支持 Markdown。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
description: "飞书云文档(Docx / Wiki 文档,v2 API):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -10,7 +10,9 @@ metadata:
|
||||
|
||||
# docs (v2)
|
||||
|
||||
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create --api-version v2`、`docs +fetch --api-version v2`、`docs +update --api-version v2` 命令必须携带 `--api-version v2`。**
|
||||
**身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。**
|
||||
|
||||
> **CRITICAL — API 版本:本 skill 使用 v2 API。执行 `docs +create`、`docs +fetch`、`docs +update` 时必须显式传入 `--api-version v2`。**
|
||||
|
||||
```bash
|
||||
# 常用示例
|
||||
@@ -69,3 +71,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
|
||||
|
||||
## 不在本 Skill 范围
|
||||
|
||||
- 文档评论管理 → [`lark-drive`](../lark-drive/SKILL.md)
|
||||
- 电子表格或 Base 的数据操作 → [`lark-sheets`](../lark-sheets/SKILL.md) / [`lark-base`](../lark-base/SKILL.md)
|
||||
- 云空间文件上传、下载、权限管理 → [`lark-drive`](../lark-drive/SKILL.md)
|
||||
|
||||
@@ -32,6 +32,8 @@ metadata:
|
||||
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
- 用户给的是 wiki URL / token,且后续还没明确底层资源类型时,先用 `lark-cli drive +inspect` 解包;`+inspect` 失败后不要自动切到别的写接口继续尝试,先按错误提示处理权限、scope 或链接问题。
|
||||
- `drive +inspect` / `drive +upload` 遇到 `not found`、`permission denied`、`missing scope` 时,默认停止重试;只有 `rate limit` 或临时网络错误才适合有限重试。
|
||||
|
||||
## 修改标题
|
||||
- 使用 `drive files patch` 命令,通过new_title字段可以修改标题,支持 docx、sheet、bitable、file、wiki、folder 类型
|
||||
|
||||
@@ -15,6 +15,7 @@ metadata:
|
||||
## 快速决策
|
||||
|
||||
- 身份:Markdown 文件通常属于用户云空间资源,优先使用 `--as user`。如为自动化场景,或应用已创建并持有目标文件权限,可按场景使用 `--as bot`。首次以 `user` 身份访问前执行 `lark-cli auth login`
|
||||
- `markdown +create` / `+overwrite` 失败时,先判断是不是身份和权限问题:`bot` 更常见的是 app scope 或目标目录 ACL,`user` 更常见的是用户授权或用户 ACL;不要不加判断地来回切身份重试。
|
||||
|
||||
- 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create`
|
||||
- 用户要**比较原生 `.md` 文件的历史版本差异**,或比较远端 Markdown 与本地草稿,使用 `lark-cli markdown +diff`
|
||||
@@ -24,6 +25,7 @@ metadata:
|
||||
- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history`
|
||||
- 用户要把本地 Markdown **导入成在线新版文档(docx)**,不要用本 skill,改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
|
||||
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间(云盘/云存储)操作,不要留在本 skill,切到 [`lark-drive`](../lark-drive/SKILL.md)
|
||||
- `markdown +create` / `+overwrite` 命中 `missing scope`、`permission denied`、`not found`、`version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate limit` 或临时网络错误才做有限重试。
|
||||
|
||||
## 核心边界
|
||||
|
||||
|
||||
Reference in New Issue
Block a user