mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a0cdc8879 | ||
|
|
67e51ec8d7 | ||
|
|
5943a20e2b | ||
|
|
cd666422ac | ||
|
|
9acd121259 | ||
|
|
1262aac480 | ||
|
|
abb02cd46c | ||
|
|
db7d3cb64d | ||
|
|
5134719da9 | ||
|
|
5a0e1d3dd9 | ||
|
|
09e60eeaf4 | ||
|
|
4f90fd3b77 | ||
|
|
6212513c43 | ||
|
|
e8df0ea63e | ||
|
|
6d0d687be2 | ||
|
|
148a04a7f8 | ||
|
|
ba19bd9f93 | ||
|
|
830fb3bbe5 | ||
|
|
1ad7cfab5b | ||
|
|
5280517d4b | ||
|
|
3ad6f2fac4 | ||
|
|
be79485fe3 | ||
|
|
94bba91224 | ||
|
|
0d50616e77 | ||
|
|
d5784eac28 | ||
|
|
663c24aadf | ||
|
|
6ad25cd452 |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -153,14 +153,14 @@ jobs:
|
||||
run: |
|
||||
# Analyze current HEAD (strip line:col for stable diff across line shifts)
|
||||
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
|
||||
|
||||
# Analyze base branch via worktree
|
||||
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
|
||||
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
|
||||
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
|
||||
@@ -209,6 +209,7 @@ jobs:
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,3 +36,5 @@ tests/mail/reports/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -2,6 +2,44 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.15] - 2026-04-20
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add float image shortcuts (#494)
|
||||
- **approval**: Document `remind` and `initiated` methods in skill (#554)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Preserve attachment metadata on base uploads (#563)
|
||||
- **base**: Fix role view and record default permission on edit (#530)
|
||||
- **sheets**: Normalize single-cell range in `+set-style` and `+batch-set-style` (#548)
|
||||
- **im**: Cap `basic_batch` user_ids at 10 per API limit (#551)
|
||||
- **install**: Refine install wizard messages (#529)
|
||||
- **whiteboard**: Deprecate old `lark-whiteboard-cli` skill (#547)
|
||||
|
||||
## [v1.0.14] - 2026-04-17
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Add email priority support for compose and read (#538)
|
||||
- **mail**: Support scheduled send (#534)
|
||||
- **drive**: Support sheet cell comments in `+add-comment` (#518)
|
||||
- **doc**: Add `--file-view` flag to `+media-insert` (#419)
|
||||
- **base**: Auto grant current user for bot create and copy (#497)
|
||||
- **base**: Add identity priority strategy and error handling (#505)
|
||||
- **auth**: Improve login scope handling and messages (#523)
|
||||
- Add OKR business domain (#522)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **wiki**: Improve wiki skill docs and add wiki domain template (#512)
|
||||
- **task**: Document `custom_fields` and `custom_field_options` API resources and permissions (#524)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **skills**: Introduce `lark-doc-whiteboard.md` and streamline whiteboard workflow (#502)
|
||||
|
||||
## [v1.0.13] - 2026-04-16
|
||||
|
||||
### Features
|
||||
@@ -382,6 +420,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
|
||||
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
|
||||
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
|
||||
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
|
||||
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
|
||||
|
||||
@@ -30,7 +30,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
@@ -38,6 +38,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type loginMsg struct {
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
AuthorizedUser string
|
||||
ScopeMismatch string
|
||||
ScopeHint string
|
||||
RequestedScopes string
|
||||
@@ -58,9 +59,10 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
|
||||
AuthSuccess: "授权已完成,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
ScopeMismatch: "授权结果异常:以下请求 scopes 未被授予: %s",
|
||||
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
RequestedScopes: " 本次请求 scopes: %s\n",
|
||||
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
|
||||
@@ -93,9 +95,10 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
|
||||
AuthSuccess: "Authorization completed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
|
||||
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
RequestedScopes: " Requested scopes: %s\n",
|
||||
NewlyGrantedScopes: " Newly granted scopes: %s\n",
|
||||
|
||||
@@ -69,6 +69,12 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
t.Errorf("%s LoginSuccess has no format verb", lang)
|
||||
}
|
||||
|
||||
// AuthorizedUser should contain two %s placeholders (userName, openId)
|
||||
got = fmt.Sprintf(msg.AuthorizedUser, "testuser", "ou_123")
|
||||
if got == msg.AuthorizedUser {
|
||||
t.Errorf("%s AuthorizedUser has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryDomains should contain %s
|
||||
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
|
||||
if got == msg.SummaryDomains {
|
||||
|
||||
@@ -190,11 +190,11 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
if loginSucceeded {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
if loginSucceeded {
|
||||
if msg.AuthorizedUser != "" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
|
||||
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
Message: "授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -376,8 +376,8 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
@@ -392,15 +392,15 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
if strings.Contains(got, "授权成功") {
|
||||
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
|
||||
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
|
||||
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
|
||||
Hint: "Granted scopes: base:app:copy. Check app scopes.",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -469,7 +469,7 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"登录成功! 用户: tester (ou_user)",
|
||||
"授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"本次未授予 scopes: (空)",
|
||||
@@ -619,8 +619,8 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
@@ -634,6 +634,9 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "OK: 授权成功") {
|
||||
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
@@ -743,7 +746,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"OK: 授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
@@ -771,7 +774,7 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"Login successful! User: tester (ou_user)",
|
||||
"Authorization successful! User: tester (ou_user)",
|
||||
"Requested scopes: im:message:send",
|
||||
"Newly granted scopes: im:message:send",
|
||||
"Not granted scopes: (none)",
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package credential
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
@@ -11,12 +14,28 @@ var (
|
||||
)
|
||||
|
||||
// Register registers a credential Provider.
|
||||
// Providers are consulted in registration order.
|
||||
// Providers are consulted in priority order (lowest value first).
|
||||
// Providers that implement Priority() int are sorted accordingly;
|
||||
// those that do not default to priority 10.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers = append(providers, p)
|
||||
sort.SliceStable(providers, func(i, j int) bool {
|
||||
return providerPriority(providers[i]) < providerPriority(providers[j])
|
||||
})
|
||||
}
|
||||
|
||||
// providerPriority returns the priority of a provider.
|
||||
// If the provider implements interface{ Priority() int }, that value is used;
|
||||
// otherwise 10 is returned as the default priority.
|
||||
// Lower values are consulted first.
|
||||
func providerPriority(p Provider) int {
|
||||
if pp, ok := p.(interface{ Priority() int }); ok {
|
||||
return pp.Priority()
|
||||
}
|
||||
return 10
|
||||
}
|
||||
|
||||
// Providers returns all registered providers (snapshot).
|
||||
|
||||
@@ -37,6 +37,32 @@ func TestRegisterAndProviders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type priorityProvider struct {
|
||||
stubProvider
|
||||
priority int
|
||||
}
|
||||
|
||||
func (p *priorityProvider) Priority() int { return p.priority }
|
||||
|
||||
func TestRegister_PriorityOrder(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "env"}) // priority 10 (default)
|
||||
Register(&priorityProvider{stubProvider: stubProvider{name: "sidecar"}, priority: 0}) // priority 0 (first)
|
||||
|
||||
got := Providers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
if got[0].Name() != "sidecar" || got[1].Name() != "env" {
|
||||
t.Errorf("expected sidecar before env, got %s, %s", got[0].Name(), got[1].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviders_ReturnsSnapshot(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
|
||||
131
extension/credential/sidecar/provider.go
Normal file
131
extension/credential/sidecar/provider.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a noop credential provider for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies
|
||||
// placeholder credentials so the CLI's auth pipeline can proceed normally.
|
||||
// Real tokens are never present in the sandbox; the sidecar transport
|
||||
// interceptor routes requests to the trusted sidecar process instead.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Provider is the noop credential provider for sidecar mode.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "sidecar" }
|
||||
func (p *Provider) Priority() int { return 0 }
|
||||
|
||||
// ResolveAccount returns a minimal Account when sidecar mode is active.
|
||||
// The account contains AppID and Brand from environment variables, a
|
||||
// placeholder secret, and SupportedIdentities derived from STRICT_MODE.
|
||||
// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set).
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return nil, nil // not in sidecar mode, skip
|
||||
}
|
||||
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err),
|
||||
}
|
||||
}
|
||||
|
||||
appID := os.Getenv(envvars.CliAppID)
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing",
|
||||
}
|
||||
}
|
||||
|
||||
if os.Getenv(envvars.CliProxyKey) == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing",
|
||||
}
|
||||
}
|
||||
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
|
||||
acct := &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: credential.NoAppSecret,
|
||||
Brand: brand,
|
||||
}
|
||||
|
||||
// Parse DefaultAs
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse SupportedIdentities from STRICT_MODE, default to SupportsAll.
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off", "":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// ResolveToken returns a sentinel token whose value encodes the token type.
|
||||
// The transport interceptor reads this sentinel to determine the identity
|
||||
// (user vs bot), strips it, and the sidecar injects the real token.
|
||||
// Returns nil, nil when sidecar mode is not active.
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
if os.Getenv(envvars.CliAuthProxy) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var sentinel string
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
sentinel = sidecar.SentinelUAT
|
||||
case credential.TokenTypeTAT:
|
||||
sentinel = sidecar.SentinelTAT
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &credential.Token{
|
||||
Value: sentinel,
|
||||
Scopes: "", // empty → scope pre-check is skipped
|
||||
Source: "sidecar",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
188
extension/credential/sidecar/provider_test.go
Normal file
188
extension/credential/sidecar/provider_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
func setEnv(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
old, hadOld := os.LookupEnv(key)
|
||||
os.Setenv(key, value)
|
||||
t.Cleanup(func() {
|
||||
if hadOld {
|
||||
os.Setenv(key, old)
|
||||
} else {
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func unsetEnv(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
old, hadOld := os.LookupEnv(key)
|
||||
os.Unsetenv(key)
|
||||
t.Cleanup(func() {
|
||||
if hadOld {
|
||||
os.Setenv(key, old)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveAccount_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatal("expected nil account when AUTH_PROXY not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_Active(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test123")
|
||||
setEnv(t, envvars.CliBrand, "lark")
|
||||
unsetEnv(t, envvars.CliDefaultAs)
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("expected non-nil account")
|
||||
}
|
||||
if acct.AppID != "cli_test123" {
|
||||
t.Errorf("AppID = %q, want %q", acct.AppID, "cli_test123")
|
||||
}
|
||||
if acct.Brand != credential.BrandLark {
|
||||
t.Errorf("Brand = %q, want %q", acct.Brand, credential.BrandLark)
|
||||
}
|
||||
if acct.AppSecret != credential.NoAppSecret {
|
||||
t.Errorf("AppSecret should be NoAppSecret, got %q", acct.AppSecret)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("SupportedIdentities = %d, want %d (SupportsAll)", acct.SupportedIdentities, credential.SupportsAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_MissingProxyKey(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
unsetEnv(t, envvars.CliProxyKey)
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
p := &Provider{}
|
||||
_, err := p.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PROXY_KEY is missing")
|
||||
}
|
||||
if _, ok := err.(*credential.BlockError); !ok {
|
||||
t.Fatalf("expected BlockError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_MissingAppID(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
unsetEnv(t, envvars.CliAppID)
|
||||
|
||||
p := &Provider{}
|
||||
_, err := p.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when APP_ID is missing")
|
||||
}
|
||||
if _, ok := err.(*credential.BlockError); !ok {
|
||||
t.Fatalf("expected BlockError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictMode(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
tests := []struct {
|
||||
mode string
|
||||
want credential.IdentitySupport
|
||||
}{
|
||||
{"bot", credential.SupportsBot},
|
||||
{"user", credential.SupportsUser},
|
||||
{"off", credential.SupportsAll},
|
||||
{"", credential.SupportsAll},
|
||||
}
|
||||
|
||||
p := &Provider{}
|
||||
for _, tt := range tests {
|
||||
t.Run("strict_"+tt.mode, func(t *testing.T) {
|
||||
if tt.mode == "" {
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
} else {
|
||||
setEnv(t, envvars.CliStrictMode, tt.mode)
|
||||
}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct.SupportedIdentities != tt.want {
|
||||
t.Errorf("SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
p := &Provider{}
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tok != nil {
|
||||
t.Fatal("expected nil token when AUTH_PROXY not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_Sentinels(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
|
||||
p := &Provider{}
|
||||
|
||||
// UAT
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("UAT: unexpected error: %v", err)
|
||||
}
|
||||
if tok.Value != sidecar.SentinelUAT {
|
||||
t.Errorf("UAT value = %q, want %q", tok.Value, sidecar.SentinelUAT)
|
||||
}
|
||||
if tok.Scopes != "" {
|
||||
t.Errorf("UAT scopes should be empty, got %q", tok.Scopes)
|
||||
}
|
||||
|
||||
// TAT
|
||||
tok, err = p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatalf("TAT: unexpected error: %v", err)
|
||||
}
|
||||
if tok.Value != sidecar.SentinelTAT {
|
||||
t.Errorf("TAT value = %q, want %q", tok.Value, sidecar.SentinelTAT)
|
||||
}
|
||||
}
|
||||
178
extension/transport/sidecar/interceptor.go
Normal file
178
extension/transport/sidecar/interceptor.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
||||
// outgoing requests are rewritten to the sidecar address. The interceptor
|
||||
// strips placeholder credentials, injects proxy headers, and signs each
|
||||
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
||||
// standard http.Transport connects to the sidecar via plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Provider implements transport.Provider for the sidecar mode.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "sidecar" }
|
||||
|
||||
// ResolveInterceptor returns a SidecarInterceptor when sidecar mode is active.
|
||||
// Returns nil when sidecar mode is disabled or the proxy address is invalid;
|
||||
// in the latter case a warning is emitted to stderr and requests fall back to
|
||||
// the non-sidecar transport path (where the credential layer will typically
|
||||
// block them for lack of a valid account).
|
||||
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return nil
|
||||
}
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err)
|
||||
return nil
|
||||
}
|
||||
key := os.Getenv(envvars.CliProxyKey)
|
||||
return &Interceptor{
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor rewrites requests for the sidecar proxy.
|
||||
type Interceptor struct {
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host:port for URL rewriting
|
||||
}
|
||||
|
||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||
// sentinel token. Requests without a sentinel token (e.g. pre-signed download
|
||||
// URLs) are passed through unmodified.
|
||||
//
|
||||
// Supports two auth patterns:
|
||||
// - Standard OpenAPI: Authorization: Bearer <sentinel>
|
||||
// - MCP protocol: X-Lark-MCP-UAT/TAT: <sentinel>
|
||||
func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, err error) {
|
||||
identity, authHeader := detectSentinel(req)
|
||||
if identity == "" {
|
||||
return nil // not a sidecar-managed request, pass through
|
||||
}
|
||||
|
||||
// 1. Buffer the body first, before mutating any request state. A partial
|
||||
// read would sign a truncated body and cause a misleading HMAC mismatch
|
||||
// on the sidecar side; bail out early and let the request fall through
|
||||
// unmodified so the credential layer can surface an actionable error.
|
||||
var bodyBytes []byte
|
||||
if req.Body != nil {
|
||||
var err error
|
||||
bodyBytes, err = io.ReadAll(req.Body)
|
||||
_ = req.Body.Close() // release original body (fd/pipe/etc.) after buffering
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: sidecar interceptor failed to read request body: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
if req.GetBody != nil {
|
||||
req.GetBody = func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Save original target (scheme://host)
|
||||
originalScheme := "https"
|
||||
if req.URL.Scheme != "" {
|
||||
originalScheme = req.URL.Scheme
|
||||
}
|
||||
originalHost := req.URL.Host
|
||||
req.Header.Set(sidecar.HeaderProxyTarget, originalScheme+"://"+originalHost)
|
||||
|
||||
// 3. Set identity and tell sidecar which header to inject real token into
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
|
||||
|
||||
// 4. Strip placeholder auth header(s)
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del(sidecar.HeaderMCPUAT)
|
||||
req.Header.Del(sidecar.HeaderMCPTAT)
|
||||
|
||||
bodySHA := sidecar.BodySHA256(bodyBytes)
|
||||
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
|
||||
|
||||
pathAndQuery := req.URL.RequestURI()
|
||||
ts := sidecar.Timestamp()
|
||||
// Cover identity and authHeader in the signature so an on-path attacker
|
||||
// within the replay window cannot flip the injected token's identity or
|
||||
// redirect the token into a different header.
|
||||
sig := sidecar.Sign(i.key, sidecar.CanonicalRequest{
|
||||
Version: sidecar.ProtocolV1,
|
||||
Method: req.Method,
|
||||
Host: originalHost,
|
||||
PathAndQuery: pathAndQuery,
|
||||
BodySHA256: bodySHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
})
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
|
||||
// 5. Rewrite URL to route through sidecar
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = i.sidecarHost
|
||||
|
||||
return nil // no post-hook needed
|
||||
}
|
||||
|
||||
// detectSentinel checks both standard Authorization and MCP auth headers for
|
||||
// sentinel tokens. Returns the identity ("user"/"bot") and the header name
|
||||
// that carried the sentinel.
|
||||
//
|
||||
// Returns ("", "") when the request carries no sentinel token — typically
|
||||
// requests that require no auth (e.g. pre-signed download URLs where the
|
||||
// token is embedded in the URL query parameters).
|
||||
func detectSentinel(req *http.Request) (identity, authHeader string) {
|
||||
// Check standard Authorization: Bearer <sentinel>
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
switch token {
|
||||
case sidecar.SentinelUAT:
|
||||
return sidecar.IdentityUser, "Authorization"
|
||||
case sidecar.SentinelTAT:
|
||||
return sidecar.IdentityBot, "Authorization"
|
||||
}
|
||||
}
|
||||
// Check MCP headers: X-Lark-MCP-UAT/TAT: <sentinel>
|
||||
if v := req.Header.Get(sidecar.HeaderMCPUAT); v == sidecar.SentinelUAT {
|
||||
return sidecar.IdentityUser, sidecar.HeaderMCPUAT
|
||||
}
|
||||
if v := req.Header.Get(sidecar.HeaderMCPTAT); v == sidecar.SentinelTAT {
|
||||
return sidecar.IdentityBot, sidecar.HeaderMCPTAT
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return
|
||||
}
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err)
|
||||
return
|
||||
}
|
||||
transport.Register(&Provider{})
|
||||
}
|
||||
265
extension/transport/sidecar/interceptor_test.go
Normal file
265
extension/transport/sidecar/interceptor_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
|
||||
type failingBody struct {
|
||||
err error
|
||||
closed bool
|
||||
readCall bool
|
||||
}
|
||||
|
||||
func (b *failingBody) Read(p []byte) (int, error) {
|
||||
b.readCall = true
|
||||
return 0, b.err
|
||||
}
|
||||
|
||||
func (b *failingBody) Close() error {
|
||||
b.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInterceptor_PreRoundTrip(t *testing.T) {
|
||||
key := []byte("test-key-for-hmac-signing-32byte!")
|
||||
interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
body := []byte(`{"msg":"hello"}`)
|
||||
req, _ := http.NewRequest("POST", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", io.NopCloser(bytes.NewReader(body)))
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
req.Header.Set("X-Cli-Source", "lark-cli")
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook")
|
||||
}
|
||||
|
||||
// URL should be rewritten to sidecar
|
||||
if req.URL.Scheme != "http" {
|
||||
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "http")
|
||||
}
|
||||
if req.URL.Host != "127.0.0.1:16384" {
|
||||
t.Errorf("host = %q, want %q", req.URL.Host, "127.0.0.1:16384")
|
||||
}
|
||||
|
||||
// Original target should be preserved
|
||||
target := req.Header.Get(sidecar.HeaderProxyTarget)
|
||||
if target != "https://open.feishu.cn" {
|
||||
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
||||
}
|
||||
|
||||
// Identity should be user (from SentinelUAT)
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
||||
}
|
||||
|
||||
// Authorization should be stripped
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
t.Errorf("Authorization header should be stripped, got %q", auth)
|
||||
}
|
||||
|
||||
// HMAC headers should be set
|
||||
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
||||
t.Error("signature header should be set")
|
||||
}
|
||||
if ts := req.Header.Get(sidecar.HeaderProxyTimestamp); ts == "" {
|
||||
t.Error("timestamp header should be set")
|
||||
}
|
||||
if sha := req.Header.Get(sidecar.HeaderBodySHA256); sha == "" {
|
||||
t.Error("body SHA256 header should be set")
|
||||
}
|
||||
if v := req.Header.Get(sidecar.HeaderProxyVersion); v != sidecar.ProtocolV1 {
|
||||
t.Errorf("version header = %q, want %q", v, sidecar.ProtocolV1)
|
||||
}
|
||||
|
||||
// Non-proxy headers should be preserved
|
||||
if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" {
|
||||
t.Errorf("X-Cli-Source should be preserved, got %q", src)
|
||||
}
|
||||
|
||||
// Body should still be readable
|
||||
readBody, _ := io.ReadAll(req.Body)
|
||||
if !bytes.Equal(readBody, body) {
|
||||
t.Errorf("body should be preserved after PreRoundTrip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/calendar/v4/events", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_NonSentinelToken_PassThrough(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
origURL := "https://some-cdn.example.com/presigned-download?token=abc"
|
||||
req, _ := http.NewRequest("GET", origURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer some-real-token")
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
// Should NOT be rewritten — no sentinel token
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook for pass-through")
|
||||
}
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged, got %q", req.URL.String())
|
||||
}
|
||||
if req.Header.Get(sidecar.HeaderProxyTarget) != "" {
|
||||
t.Error("proxy target header should not be set for pass-through")
|
||||
}
|
||||
if req.Header.Get("Authorization") != "Bearer some-real-token" {
|
||||
t.Error("Authorization should be preserved for pass-through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_NoAuth_PassThrough(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
origURL := "https://cdn.feishu.cn/download/file"
|
||||
req, _ := http.NewRequest("GET", origURL, nil)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
// No Authorization header at all — should pass through
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged for no-auth request, got %q", req.URL.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_MCP_UAT(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{"jsonrpc":"2.0"}`)))
|
||||
req.Header.Set(sidecar.HeaderMCPUAT, sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
// Should be intercepted and rewritten
|
||||
if req.URL.Host != "127.0.0.1:16384" {
|
||||
t.Errorf("host = %q, want sidecar host", req.URL.Host)
|
||||
}
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
||||
}
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPUAT {
|
||||
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPUAT)
|
||||
}
|
||||
// MCP sentinel should be stripped
|
||||
if v := req.Header.Get(sidecar.HeaderMCPUAT); v != "" {
|
||||
t.Errorf("MCP-UAT should be stripped, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_MCP_TAT(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{}`)))
|
||||
req.Header.Set(sidecar.HeaderMCPTAT, sidecar.SentinelTAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
||||
}
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPTAT {
|
||||
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPTAT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_StandardAuth_SetsAuthorizationHeader(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != "Authorization" {
|
||||
t.Errorf("auth header = %q, want %q", ah, "Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptor_BodyReadError verifies that when io.ReadAll on the request
|
||||
// body fails partway, PreRoundTrip skips the rewrite entirely rather than
|
||||
// signing a truncated body (which would produce a misleading HMAC mismatch on
|
||||
// the sidecar side) and releases the original body.
|
||||
func TestInterceptor_BodyReadError(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
const origURL = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
body := &failingBody{err: errors.New("disk gremlin")}
|
||||
|
||||
req, _ := http.NewRequest("POST", origURL, body)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook on body read failure")
|
||||
}
|
||||
|
||||
// Original body must be closed to avoid leaking fd/pipe-like resources.
|
||||
if !body.readCall {
|
||||
t.Error("expected ReadAll to have attempted reading from the body")
|
||||
}
|
||||
if !body.closed {
|
||||
t.Error("expected original body to be Close()'d after read failure")
|
||||
}
|
||||
|
||||
// URL must NOT be rewritten — request should fall through to the next
|
||||
// layer (credential) which can surface a meaningful error.
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged on read failure, got %q", req.URL.String())
|
||||
}
|
||||
|
||||
// No proxy/HMAC headers should leak onto the request.
|
||||
for _, h := range []string{
|
||||
sidecar.HeaderProxyVersion,
|
||||
sidecar.HeaderProxyTarget,
|
||||
sidecar.HeaderProxySignature,
|
||||
sidecar.HeaderProxyTimestamp,
|
||||
sidecar.HeaderBodySHA256,
|
||||
sidecar.HeaderProxyIdentity,
|
||||
sidecar.HeaderProxyAuthHeader,
|
||||
} {
|
||||
if v := req.Header.Get(h); v != "" {
|
||||
t.Errorf("%s should not be set on read failure, got %q", h, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_EmptyBody(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/path", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
sha := req.Header.Get(sidecar.HeaderBodySHA256)
|
||||
expectedEmpty := sidecar.BodySHA256(nil)
|
||||
if sha != expectedEmpty {
|
||||
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -122,9 +123,22 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
|
||||
// Add top-level JSON keys as text form fields.
|
||||
if m, ok := dataJSON.(map[string]any); ok {
|
||||
for k, v := range m {
|
||||
fd.AddField(k, fmt.Sprintf("%v", v))
|
||||
fd.AddField(k, formatFormFieldValue(v))
|
||||
}
|
||||
}
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// formatFormFieldValue renders a JSON-unmarshalled value as a multipart form
|
||||
// field string. float64 is handled specially: fmt's default %v/%g switches to
|
||||
// scientific notation for values >= ~1e6 (e.g. "1.185356e+06"), which some
|
||||
// backends reject when parsing the field as an integer. Use decimal notation
|
||||
// instead so size / block_num / offset-style numeric fields round-trip cleanly.
|
||||
// All other types fall through to %v.
|
||||
func formatFormFieldValue(v any) string {
|
||||
if n, ok := v.(float64); ok {
|
||||
return strconv.FormatFloat(n, 'f', -1, 64)
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
@@ -336,3 +336,40 @@ func TestBuildFormdata(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormatFormFieldValue locks in the fix for the float64 -> scientific
|
||||
// notation bug. JSON numbers unmarshal to float64, and fmt's default %v for
|
||||
// float64 delegates to %g which switches to scientific notation at ~1e6
|
||||
// (e.g. 1185356 -> "1.185356e+06"). Backends that parse the form field as an
|
||||
// integer reject that, surfacing as a generic "params error".
|
||||
func TestFormatFormFieldValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"float64 large integer avoids scientific", float64(1185356), "1185356"},
|
||||
{"float64 below scientific threshold", float64(358934), "358934"},
|
||||
{"float64 zero", float64(0), "0"},
|
||||
{"float64 huge", float64(20 * 1024 * 1024), "20971520"},
|
||||
{"float64 negative", float64(-42), "-42"},
|
||||
{"float64 fractional preserved", float64(3.14), "3.14"},
|
||||
{"string pass-through", "hello", "hello"},
|
||||
{"bool true", true, "true"},
|
||||
{"int via %v", 42, "42"},
|
||||
{"int64 via %v", int64(9007199254740992), "9007199254740992"},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := formatFormFieldValue(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("formatFormFieldValue(%v) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,8 @@ const (
|
||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
|
||||
// Sidecar proxy (auth proxy mode)
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||
)
|
||||
|
||||
@@ -38,6 +38,9 @@ const (
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
|
||||
LarkErrDriveCrossBrand = 1064511 // cross brand not support
|
||||
|
||||
// Sheets float image: width/height/offset out of range or invalid.
|
||||
LarkErrSheetsFloatImageInvalidDims = 1310246
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
@@ -73,6 +76,12 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
|
||||
case LarkErrDriveCrossBrand:
|
||||
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
|
||||
|
||||
// sheets-specific constraints that benefit from actionable hints
|
||||
case LarkErrSheetsFloatImageInvalidDims:
|
||||
return ExitAPI, "invalid_params",
|
||||
"check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
|
||||
@@ -40,6 +40,13 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
},
|
||||
{
|
||||
name: "sheets float image invalid dims",
|
||||
code: LarkErrSheetsFloatImageInvalidDims,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantHint: "--width / --height / --offset-x / --offset-y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -63,5 +63,9 @@
|
||||
"wiki": {
|
||||
"en": { "title": "Wiki", "description": "Wiki space and node management" },
|
||||
"zh": { "title": "知识库", "description": "知识空间、节点管理" }
|
||||
},
|
||||
"okr": {
|
||||
"en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators" },
|
||||
"zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标" }
|
||||
}
|
||||
}
|
||||
|
||||
11
main_authsidecar.go
Normal file
11
main_authsidecar.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/larksuite/cli/extension/credential/sidecar" // activate sidecar credential provider
|
||||
_ "github.com/larksuite/cli/extension/transport/sidecar" // activate sidecar transport interceptor
|
||||
)
|
||||
54
main_noauthsidecar.go
Normal file
54
main_noauthsidecar.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !authsidecar
|
||||
|
||||
// This file is the fail-closed guard for builds that do NOT include the
|
||||
// `authsidecar` tag. The sidecar credential-isolation feature is only
|
||||
// compiled in under that tag; deploying the plain build into an environment
|
||||
// that expects sidecar isolation would silently fall back to direct env
|
||||
// credential use — exactly the failure mode the feature is meant to prevent.
|
||||
//
|
||||
// When LARKSUITE_CLI_AUTH_PROXY is set, we refuse to run rather than ignore
|
||||
// the variable. The operator either rebuilt without realizing (wrong
|
||||
// artifact) or the sandbox inherited the var by accident; both cases want
|
||||
// a loud startup error, not a mysterious token leak on the first API call.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if code := checkNoAuthsidecarBuild(os.Getenv, os.Stderr); code != 0 {
|
||||
os.Exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNoAuthsidecarBuild returns a non-zero exit code (and writes a
|
||||
// human-readable reason to stderr) when the environment asks for sidecar
|
||||
// isolation that this binary cannot provide. Factored out from init() so
|
||||
// tests can exercise the decision without actually calling os.Exit.
|
||||
func checkNoAuthsidecarBuild(getenv func(string) string, stderr io.Writer) int {
|
||||
v := getenv(envvars.CliAuthProxy)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stderr,
|
||||
"ERROR: %s is set, but this lark-cli binary was built WITHOUT the "+
|
||||
"'authsidecar' build tag.\n"+
|
||||
"The sidecar credential-isolation feature is compiled out — "+
|
||||
"running would bypass isolation and\n"+
|
||||
"send any real credentials present in the environment directly "+
|
||||
"to the Lark API.\n\n"+
|
||||
"To fix, either:\n"+
|
||||
" - rebuild the CLI with: go build -tags authsidecar\n"+
|
||||
" - or unset %s if sidecar isolation is not required\n",
|
||||
envvars.CliAuthProxy, envvars.CliAuthProxy)
|
||||
return 2
|
||||
}
|
||||
52
main_noauthsidecar_test.go
Normal file
52
main_noauthsidecar_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !authsidecar
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestCheckNoAuthsidecarBuild_Unset(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
code := checkNoAuthsidecarBuild(func(string) string { return "" }, &stderr)
|
||||
if code != 0 {
|
||||
t.Errorf("exit code = %d, want 0 when AUTH_PROXY is unset", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr should be empty, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoAuthsidecarBuild_Set verifies that deploying a plain build into
|
||||
// a sandbox that expects sidecar isolation fails loudly at startup instead
|
||||
// of silently leaking credentials through the env provider path.
|
||||
func TestCheckNoAuthsidecarBuild_Set(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
env := func(k string) string {
|
||||
if k == envvars.CliAuthProxy {
|
||||
return "http://127.0.0.1:16384"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
code := checkNoAuthsidecarBuild(env, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("expected non-zero exit code when AUTH_PROXY is set")
|
||||
}
|
||||
msg := stderr.String()
|
||||
for _, want := range []string{
|
||||
envvars.CliAuthProxy,
|
||||
"authsidecar", // build-tag name must appear so operators can act on it
|
||||
"rebuild",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("stderr message missing %q; got:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.15",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -38,11 +38,11 @@ const messages = {
|
||||
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
|
||||
step4: "授权",
|
||||
step4NotFound: "未找到 lark-cli,跳过授权",
|
||||
step4Confirm: "允许 AI 访问你的飞书数据(消息、文档、日历等)?",
|
||||
step4Confirm: "是否允许 AI 访问你个人的消息、文档、日历等飞书 / Lark 数据,并以你的名义执行操作?",
|
||||
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
|
||||
step4Done: "授权完成",
|
||||
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
|
||||
done: "安装完成!\n现在可以对你的 AI 工具(Claude Code、Trae 等)说:\"Feishu/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
|
||||
done: "安装完成!\n可以和你的 AI 工具(如 Claude Code、Trae等)说:\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
|
||||
cancelled: "安装已取消",
|
||||
},
|
||||
en: {
|
||||
@@ -66,7 +66,7 @@ const messages = {
|
||||
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
|
||||
step4: "Authorization",
|
||||
step4NotFound: "lark-cli not found. Skipping authorization",
|
||||
step4Confirm: "Allow AI to access your Feishu/Lark data (messages, docs, calendar, etc.)?",
|
||||
step4Confirm: "Allow the AI to access your messages, documents, calendar, and more in Feishu/Lark, and perform actions on your behalf?",
|
||||
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
|
||||
step4Done: "Authorization complete",
|
||||
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
|
||||
|
||||
@@ -14,7 +14,8 @@ var BaseBaseCopy = common.Shortcut{
|
||||
Command: "+base-copy",
|
||||
Description: "Copy a base resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:app:copy"},
|
||||
UserScopes: []string{"base:app:copy"},
|
||||
BotScopes: []string{"base:app:copy", "docs:permission.member:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
|
||||
@@ -14,7 +14,8 @@ var BaseBaseCreate = common.Shortcut{
|
||||
Command: "+base-create",
|
||||
Description: "Create a new base resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:app:create"},
|
||||
UserScopes: []string{"base:app:create"},
|
||||
BotScopes: []string{"base:app:create", "docs:permission.member:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "base name", Required: true},
|
||||
|
||||
@@ -137,6 +137,8 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
"bitable_file",
|
||||
"PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
|
||||
"report-final.pdf",
|
||||
`"mime_type":"\u003cdetected_mime_type\u003e"`,
|
||||
`"size":"\u003cfile_size\u003e"`,
|
||||
"deprecated_set_attachment",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package base
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -19,12 +20,16 @@ import (
|
||||
)
|
||||
|
||||
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
return newExecuteFactoryWithUserOpenID(t, "ou_testuser")
|
||||
}
|
||||
|
||||
func newExecuteFactoryWithUserOpenID(t *testing.T, userOpenID string) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
UserOpenId: userOpenID,
|
||||
}
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
||||
return factory, stdout, reg
|
||||
@@ -48,7 +53,14 @@ func withBaseWorkingDir(t *testing.T, dir string) {
|
||||
|
||||
func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
shortcut.AuthTypes = []string{"bot"}
|
||||
return runShortcutWithAuthTypes(t, shortcut, []string{"bot"}, args, factory, stdout)
|
||||
}
|
||||
|
||||
func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes []string, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
if authTypes != nil {
|
||||
shortcut.AuthTypes = authTypes
|
||||
}
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
@@ -60,6 +72,14 @@ func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory
|
||||
|
||||
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
@@ -68,11 +88,32 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(permStub)
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"app_token": "app_x"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true {
|
||||
t.Fatalf("created = %#v, want true", data["created"])
|
||||
}
|
||||
base, _ := data["base"].(map[string]interface{})
|
||||
if got := common.GetString(base, "app_token"); got != "app_x" {
|
||||
t.Fatalf("base.app_token = %q, want %q", got, "app_x")
|
||||
}
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, permStub)
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +138,14 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
||||
|
||||
t.Run("copy", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
@@ -105,14 +154,243 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base", "url": "https://example.com/base/app_new"},
|
||||
},
|
||||
})
|
||||
reg.Register(permStub)
|
||||
args := []string{"+base-copy", "--base-token", "app_src", "--name", "Copied Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai", "--without-content"}
|
||||
if err := runShortcut(t, BaseBaseCopy, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"copied": true`) || !strings.Contains(got, `"app_new"`) {
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["copied"] != true {
|
||||
t.Fatalf("copied = %#v, want true", data["copied"])
|
||||
}
|
||||
base, _ := data["base"].(map[string]interface{})
|
||||
if got := common.GetString(base, "base_token"); got != "app_new" {
|
||||
t.Fatalf("base.base_token = %q, want %q", got, "app_new")
|
||||
}
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, permStub)
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if _, ok := grant["user_open_id"]; ok {
|
||||
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
||||
t.Fatalf("Base creation should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyBotAutoGrantFailureDoesNotFailCopy(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
||||
t.Fatalf("Base copy should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCopy, authTypes(), []string{"+base-copy", "--base-token", "app_src", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
t.Run("create bot", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("copy bot", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create user", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func decodeBaseEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in output envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBaseHistoryExecute(t *testing.T) {
|
||||
@@ -941,7 +1219,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
!strings.Contains(updateBody, `"image_height":480`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_1"`) ||
|
||||
!strings.Contains(updateBody, `"name":"report.txt"`) {
|
||||
!strings.Contains(updateBody, `"name":"report.txt"`) ||
|
||||
!strings.Contains(updateBody, `"size":16`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
}
|
||||
})
|
||||
@@ -1092,6 +1372,8 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
|
||||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
|
||||
!strings.Contains(updateBody, `"size":20971521`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
}
|
||||
|
||||
@@ -17,36 +17,24 @@ func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.Dr
|
||||
}
|
||||
|
||||
func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
body["name"] = name
|
||||
}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
}
|
||||
if runtime.Bool("without-content") {
|
||||
body["without_content"] = true
|
||||
}
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/copy").
|
||||
Body(body).
|
||||
Body(buildBaseCopyBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
}
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases").
|
||||
Body(body)
|
||||
Body(buildBaseCreateBody(runtime))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func executeBaseGet(runtime *common.RuntimeContext) error {
|
||||
@@ -59,6 +47,28 @@ func executeBaseGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeBaseCopy(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, buildBaseCopyBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"base": data, "copied": true}
|
||||
augmentBasePermissionGrant(runtime, out, data)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, buildBaseCreateBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"base": data, "created": true}
|
||||
augmentBasePermissionGrant(runtime, out, data)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBaseCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
body["name"] = name
|
||||
@@ -72,15 +82,10 @@ func executeBaseCopy(runtime *common.RuntimeContext) error {
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data, "copied": true}, nil)
|
||||
return nil
|
||||
return body
|
||||
}
|
||||
|
||||
func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
func buildBaseCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
@@ -88,10 +93,20 @@ func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data, "created": true}, nil)
|
||||
return nil
|
||||
return body
|
||||
}
|
||||
|
||||
func augmentBasePermissionGrant(runtime *common.RuntimeContext, out, base map[string]interface{}) {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, extractBasePermissionToken(base), "bitable"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
func extractBasePermissionToken(base map[string]interface{}) string {
|
||||
for _, key := range []string{"base_token", "app_token"} {
|
||||
if token := strings.TrimSpace(common.GetString(base, key)); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -105,6 +109,8 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
map[string]interface{}{
|
||||
"file_token": "<uploaded_file_token>",
|
||||
"name": fileName,
|
||||
"mime_type": "<detected_mime_type>",
|
||||
"size": "<file_size>",
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
},
|
||||
@@ -243,10 +249,14 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentNode := baseToken
|
||||
var (
|
||||
fileToken string
|
||||
err error
|
||||
)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
@@ -272,7 +282,78 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName,
|
||||
attachment := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"name": fileName,
|
||||
"mime_type": mimeType,
|
||||
"size": fileSize,
|
||||
"deprecated_set_attachment": true,
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) {
|
||||
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" {
|
||||
return stripMIMEParams(byExt), nil
|
||||
}
|
||||
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))); byExt != "" {
|
||||
return stripMIMEParams(byExt), nil
|
||||
}
|
||||
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, readErr := f.Read(buf)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
return "", output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
return detectAttachmentMIMEFromContent(buf[:n]), nil
|
||||
}
|
||||
|
||||
func stripMIMEParams(value string) string {
|
||||
if i := strings.IndexByte(value, ';'); i != -1 {
|
||||
value = value[:i]
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func detectAttachmentMIMEFromContent(content []byte) string {
|
||||
if len(content) == 0 {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
|
||||
return "image/png"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte{0xff, 0xd8, 0xff}) {
|
||||
return "image/jpeg"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte("GIF87a")) || bytes.HasPrefix(content, []byte("GIF89a")) {
|
||||
return "image/gif"
|
||||
}
|
||||
if len(content) >= 12 && bytes.Equal(content[:4], []byte("RIFF")) && bytes.Equal(content[8:12], []byte("WEBP")) {
|
||||
return "image/webp"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte("%PDF-")) {
|
||||
return "application/pdf"
|
||||
}
|
||||
if looksLikeText(content) {
|
||||
return "text/plain"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func looksLikeText(content []byte) bool {
|
||||
if !utf8.Valid(content) {
|
||||
return false
|
||||
}
|
||||
for _, r := range string(content) {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
continue
|
||||
}
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
136
shortcuts/base/record_upload_attachment_test.go
Normal file
136
shortcuts/base/record_upload_attachment_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
type attachmentTestFileIO struct {
|
||||
openFile fileio.File
|
||||
openErr error
|
||||
}
|
||||
|
||||
func (f attachmentTestFileIO) Open(string) (fileio.File, error) { return f.openFile, f.openErr }
|
||||
func (attachmentTestFileIO) Stat(string) (fileio.FileInfo, error) {
|
||||
return attachmentTestFileInfo{}, nil
|
||||
}
|
||||
func (attachmentTestFileIO) ResolvePath(path string) (string, error) { return path, nil }
|
||||
func (attachmentTestFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type attachmentTestFileInfo struct{}
|
||||
|
||||
func (attachmentTestFileInfo) Size() int64 { return 0 }
|
||||
func (attachmentTestFileInfo) IsDir() bool { return false }
|
||||
func (attachmentTestFileInfo) Mode() fs.FileMode { return 0 }
|
||||
|
||||
type attachmentTestFile struct {
|
||||
*bytes.Reader
|
||||
}
|
||||
|
||||
func newAttachmentTestFile(content []byte) attachmentTestFile {
|
||||
return attachmentTestFile{Reader: bytes.NewReader(content)}
|
||||
}
|
||||
|
||||
func (attachmentTestFile) Close() error { return nil }
|
||||
|
||||
type attachmentReadErrorFile struct{}
|
||||
|
||||
func (attachmentReadErrorFile) Read([]byte) (int, error) { return 0, os.ErrPermission }
|
||||
func (attachmentReadErrorFile) ReadAt([]byte, int64) (int, error) { return 0, io.EOF }
|
||||
func (attachmentReadErrorFile) Close() error { return nil }
|
||||
|
||||
func TestDetectAttachmentMIMETypeUsesExtension(t *testing.T) {
|
||||
got, err := detectAttachmentMIMEType(nil, "ignored", "note.TXT")
|
||||
if err != nil {
|
||||
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
|
||||
}
|
||||
if got != "text/plain" {
|
||||
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeFallsBackToSourcePathExtension(t *testing.T) {
|
||||
got, err := detectAttachmentMIMEType(nil, "report.docx", "report")
|
||||
if err != nil {
|
||||
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
|
||||
}
|
||||
if got != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" {
|
||||
t.Fatalf("detectAttachmentMIMEType() = %q, want docx MIME type", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openFile: newAttachmentTestFile([]byte("hello from base attachment"))}
|
||||
|
||||
got, err := detectAttachmentMIMEType(fio, "note", "note")
|
||||
if err != nil {
|
||||
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
|
||||
}
|
||||
if got != "text/plain" {
|
||||
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openErr: os.ErrNotExist}
|
||||
|
||||
_, err := detectAttachmentMIMEType(fio, "missing", "missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for open failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Fatalf("error = %v, want wrapped read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeReturnsReadError(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openFile: attachmentReadErrorFile{}}
|
||||
|
||||
_, err := detectAttachmentMIMEType(fio, "broken", "broken")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for read failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Fatalf("error = %v, want read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMEFromContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
want string
|
||||
}{
|
||||
{name: "empty", content: nil, want: "application/octet-stream"},
|
||||
{name: "png", content: []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, want: "image/png"},
|
||||
{name: "jpeg", content: []byte{0xff, 0xd8, 0xff, 0xe0}, want: "image/jpeg"},
|
||||
{name: "gif87a", content: []byte("GIF87a"), want: "image/gif"},
|
||||
{name: "gif89a", content: []byte("GIF89a"), want: "image/gif"},
|
||||
{name: "webp", content: []byte("RIFF1234WEBP"), want: "image/webp"},
|
||||
{name: "pdf", content: []byte("%PDF-1.7"), want: "application/pdf"},
|
||||
{name: "text", content: []byte("hello from base attachment"), want: "text/plain"},
|
||||
{name: "text with newline", content: []byte("hello\nworld\tok"), want: "text/plain"},
|
||||
{name: "control bytes", content: []byte{'h', 'i', 0x00}, want: "application/octet-stream"},
|
||||
{name: "binary fallback", content: []byte{0x00, 0x01, 0x02, 0x03}, want: "application/octet-stream"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectAttachmentMIMEFromContent(tt.content)
|
||||
if got != tt.want {
|
||||
t.Fatalf("detectAttachmentMIMEFromContent() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,18 @@ var alignMap = map[string]int{
|
||||
"right": 3,
|
||||
}
|
||||
|
||||
// fileViewMap maps the user-facing --file-view value to the docx File block
|
||||
// `view_type` enum. The underlying values come from the open platform spec:
|
||||
//
|
||||
// 1 = card view (default)
|
||||
// 2 = preview view (renders audio/video files as an inline player)
|
||||
// 3 = inline view
|
||||
var fileViewMap = map[string]int{
|
||||
"card": 1,
|
||||
"preview": 2,
|
||||
"inline": 3,
|
||||
}
|
||||
|
||||
var DocMediaInsert = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+media-insert",
|
||||
@@ -33,6 +45,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
{Name: "type", Default: "image", Desc: "type: image | file"},
|
||||
{Name: "align", Desc: "alignment: left | center | right"},
|
||||
{Name: "caption", Desc: "image caption text"},
|
||||
{Name: "file-view", Desc: "file block rendering: card (default) | preview | inline; only applies when --type=file. preview renders audio/video as an inline player"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
@@ -42,6 +55,14 @@ var DocMediaInsert = common.Shortcut{
|
||||
if docRef.Kind == "doc" {
|
||||
return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
|
||||
}
|
||||
if view := runtime.Str("file-view"); view != "" {
|
||||
if _, ok := fileViewMap[view]; !ok {
|
||||
return output.ErrValidation("invalid --file-view value %q, expected one of: card | preview | inline", view)
|
||||
}
|
||||
if runtime.Str("type") != "file" {
|
||||
return output.ErrValidation("--file-view only applies when --type=file")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -55,9 +76,10 @@ var DocMediaInsert = common.Shortcut{
|
||||
filePath := runtime.Str("file")
|
||||
mediaType := runtime.Str("type")
|
||||
caption := runtime.Str("caption")
|
||||
fileViewType := fileViewMap[runtime.Str("file-view")]
|
||||
|
||||
parentType := parentTypeForMediaType(mediaType)
|
||||
createBlockData := buildCreateBlockData(mediaType, 0)
|
||||
createBlockData := buildCreateBlockData(mediaType, 0, fileViewType)
|
||||
createBlockData["index"] = "<children_len>"
|
||||
batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption)
|
||||
|
||||
@@ -92,6 +114,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
mediaType := runtime.Str("type")
|
||||
alignStr := runtime.Str("align")
|
||||
caption := runtime.Str("caption")
|
||||
fileViewType := fileViewMap[runtime.Str("file-view")]
|
||||
|
||||
documentID, err := resolveDocxDocumentID(runtime, docInput)
|
||||
if err != nil {
|
||||
@@ -132,7 +155,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
|
||||
createData, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
|
||||
nil, buildCreateBlockData(mediaType, insertIndex))
|
||||
nil, buildCreateBlockData(mediaType, insertIndex, fileViewType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,12 +231,22 @@ func parentTypeForMediaType(mediaType string) string {
|
||||
return "docx_image"
|
||||
}
|
||||
|
||||
func buildCreateBlockData(mediaType string, index int) map[string]interface{} {
|
||||
func buildCreateBlockData(mediaType string, index int, fileViewType int) map[string]interface{} {
|
||||
child := map[string]interface{}{
|
||||
"block_type": blockTypeForMediaType(mediaType),
|
||||
}
|
||||
if mediaType == "file" {
|
||||
child["file"] = map[string]interface{}{}
|
||||
fileData := map[string]interface{}{}
|
||||
// view_type can only be set at block creation time; the PATCH
|
||||
// replace_file endpoint does not accept it, so if the caller wants
|
||||
// preview/inline rendering we must wire it in here. Whitelist the
|
||||
// concrete enum values so a stray positive int cannot produce a
|
||||
// malformed payload if Validate is ever bypassed.
|
||||
switch fileViewType {
|
||||
case 1, 2, 3:
|
||||
fileData["view_type"] = fileViewType
|
||||
}
|
||||
child["file"] = fileData
|
||||
} else {
|
||||
child["image"] = map[string]interface{}{}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("image", 3)
|
||||
got := buildCreateBlockData("image", 3, 0)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
@@ -29,7 +35,7 @@ func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) {
|
||||
func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 1)
|
||||
got := buildCreateBlockData("file", 1, 0)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
@@ -44,6 +50,113 @@ func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The `--file-view card` path sends a different request shape than
|
||||
// omitting the flag entirely: omitting produces `file: {}`, while
|
||||
// `card` produces `file: {view_type: 1}`. The two are intended to be
|
||||
// semantically equivalent at the API level, but the on-the-wire payload
|
||||
// is different and is part of the public flag contract, so pin it down.
|
||||
func TestBuildCreateBlockDataForFileWithCardView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 1) // card
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, card) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBlockDataForFileWithPreviewView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 2) // preview
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, preview) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBlockDataForFileWithInlineView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 3) // inline
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, inline) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// view_type must never leak into non-file blocks even if the caller
|
||||
// accidentally passes a non-zero fileViewType alongside --type=image.
|
||||
func TestBuildCreateBlockDataForImageIgnoresFileViewType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("image", 0, 2)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 27,
|
||||
"image": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(image, preview) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileViewMapCoversDocumentedValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Assert only the documented keys — leave room for future aliases
|
||||
// (e.g. a "player" synonym for preview) without breaking this test.
|
||||
want := map[string]int{
|
||||
"card": 1,
|
||||
"preview": 2,
|
||||
"inline": 3,
|
||||
}
|
||||
for key, expected := range want {
|
||||
got, ok := fileViewMap[key]
|
||||
if !ok {
|
||||
t.Errorf("fileViewMap missing required key %q", key)
|
||||
continue
|
||||
}
|
||||
if got != expected {
|
||||
t.Errorf("fileViewMap[%q] = %d, want %d", key, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeleteBlockDataUsesHalfOpenInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -161,3 +274,98 @@ func TestExtractCreatedBlockTargetsForFileUsesNestedFileBlock(t *testing.T) {
|
||||
t.Fatalf("extractCreatedBlockTargets(file) replaceBlockID = %q, want %q", replaceBlockID, "file_inner")
|
||||
}
|
||||
}
|
||||
|
||||
// newMediaInsertValidateRuntime builds a bare RuntimeContext wired with
|
||||
// only the flags that DocMediaInsert.Validate reads. It exists so the
|
||||
// Validate tests below can exercise the CLI contract without going
|
||||
// through the full cobra command tree.
|
||||
func newMediaInsertValidateRuntime(t *testing.T, doc, mediaType, fileView string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "docs +media-insert"}
|
||||
cmd.Flags().String("doc", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("file-view", "", "")
|
||||
if err := cmd.Flags().Set("doc", doc); err != nil {
|
||||
t.Fatalf("set --doc: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", mediaType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if fileView != "" {
|
||||
if err := cmd.Flags().Set("file-view", fileView); err != nil {
|
||||
t.Fatalf("set --file-view: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
// Validate is the real user-facing contract for --file-view: unknown
|
||||
// values must be rejected, and passing the flag alongside --type!=file
|
||||
// must also be rejected. buildCreateBlockData tests alone cannot catch
|
||||
// regressions here, so lock the guard logic down explicitly.
|
||||
func TestDocMediaInsertValidateFileView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mediaType string
|
||||
fileView string
|
||||
wantErr string // substring; empty means success expected
|
||||
}{
|
||||
{
|
||||
name: "file with card is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "card",
|
||||
},
|
||||
{
|
||||
name: "file with preview is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "preview",
|
||||
},
|
||||
{
|
||||
name: "file with inline is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "inline",
|
||||
},
|
||||
{
|
||||
name: "file without file-view is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "",
|
||||
},
|
||||
{
|
||||
name: "unknown file-view value is rejected",
|
||||
mediaType: "file",
|
||||
fileView: "bogus",
|
||||
wantErr: "invalid --file-view value",
|
||||
},
|
||||
{
|
||||
name: "file-view with image type is rejected",
|
||||
mediaType: "image",
|
||||
fileView: "preview",
|
||||
wantErr: "--file-view only applies when --type=file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, ttTemp := range tests {
|
||||
tt := ttTemp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newMediaInsertValidateRuntime(t, "doxcnValidateFileView", tt.mediaType, tt.fileView)
|
||||
err := DocMediaInsert.Validate(context.Background(), rt)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("Validate() error = nil, want error containing %q", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -63,7 +64,7 @@ const (
|
||||
var DriveAddComment = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-comment",
|
||||
Description: "Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx)",
|
||||
Description: "Add a full-document or local comment to doc/docx/sheet, also supports wiki URL resolving to doc/docx/sheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"docx:document:readonly",
|
||||
@@ -72,14 +73,15 @@ var DriveAddComment = common.Shortcut{
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, or wiki URL that resolves to doc/docx", Required: true},
|
||||
{Name: "doc", Desc: "document URL/token, sheet URL, or wiki URL that resolves to doc/docx/sheet", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, sheet (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
{Name: "block-id", Desc: "anchor block ID (skip MCP locate-doc if already known)"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"))
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,6 +90,21 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Sheet comment validation.
|
||||
if docRef.Kind == "sheet" {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
if _, err := parseSheetCellRef(blockID); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("full-comment") || strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return output.ErrValidation("--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with <sheetId>!<cell> format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if strings.TrimSpace(selection) != "" && blockID != "" {
|
||||
@@ -99,37 +116,69 @@ var DriveAddComment = common.Shortcut{
|
||||
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
if mode == commentModeLocal && docRef.Kind == "doc" {
|
||||
return output.ErrValidation("local comments only support docx documents; use --full-comment or omit location flags for a whole-document comment")
|
||||
return output.ErrValidation("local comments only support docx and sheet; old doc format only supports full comments")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"))
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
replyElements, _ := parseCommentReplyElements(runtime.Str("content"))
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
|
||||
// For wiki URLs, resolve the actual target type via API so dry-run
|
||||
// matches real execution behavior instead of guessing from --block-id.
|
||||
resolvedKind := docRef.Kind
|
||||
resolvedToken := docRef.Token
|
||||
isWiki := false
|
||||
if docRef.Kind == "wiki" {
|
||||
isWiki = true
|
||||
target, err := resolveCommentTarget(ctx, runtime, runtime.Str("doc"), commentModeFull)
|
||||
if err == nil {
|
||||
resolvedKind = target.FileType
|
||||
resolvedToken = target.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
// Sheet comment dry-run.
|
||||
if resolvedKind == "sheet" {
|
||||
anchor, _ := parseSheetCellRef(blockID)
|
||||
if anchor == nil {
|
||||
anchor = &sheetAnchor{SheetID: "<sheetId>", Col: 0, Row: 0}
|
||||
}
|
||||
commentBody := buildCommentCreateV2Request("sheet", "", replyElements, anchor)
|
||||
desc := "1-step request: create sheet comment"
|
||||
if isWiki {
|
||||
desc = "2-step orchestration: resolve wiki -> create sheet comment"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/:file_token/new_comments").
|
||||
Body(commentBody).
|
||||
Set("file_token", resolvedToken)
|
||||
}
|
||||
|
||||
// Doc/docx comment dry-run.
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
|
||||
targetToken, targetFileType, resolvedBy := dryRunResolvedCommentTarget(docRef, mode)
|
||||
|
||||
createPath := "/open-apis/drive/v1/files/:file_token/new_comments"
|
||||
commentBody := buildCommentCreateV2Request(targetFileType, "", replyElements)
|
||||
commentBody := buildCommentCreateV2Request(resolvedKind, "", replyElements, nil)
|
||||
if mode == commentModeLocal {
|
||||
commentBody = buildCommentCreateV2Request(targetFileType, anchorBlockIDForDryRun(blockID), replyElements)
|
||||
commentBody = buildCommentCreateV2Request(resolvedKind, anchorBlockIDForDryRun(blockID), replyElements, nil)
|
||||
}
|
||||
|
||||
mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand)
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
switch {
|
||||
case mode == commentModeFull && resolvedBy == "wiki":
|
||||
case mode == commentModeFull && isWiki:
|
||||
dry.Desc("2-step orchestration: resolve wiki -> create full comment")
|
||||
case mode == commentModeFull:
|
||||
dry.Desc("1-step request: create full comment")
|
||||
case resolvedBy == "wiki" && strings.TrimSpace(selection) != "":
|
||||
case isWiki && strings.TrimSpace(selection) != "":
|
||||
dry.Desc("3-step orchestration: resolve wiki -> locate block -> create local comment")
|
||||
case resolvedBy == "wiki":
|
||||
case isWiki:
|
||||
dry.Desc("2-step orchestration: resolve wiki -> create local comment")
|
||||
case strings.TrimSpace(selection) != "":
|
||||
dry.Desc("2-step orchestration: locate block -> create local comment")
|
||||
@@ -137,19 +186,17 @@ var DriveAddComment = common.Shortcut{
|
||||
dry.Desc("1-step request: create local comment with explicit block ID")
|
||||
}
|
||||
|
||||
if resolvedBy == "wiki" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to target document").
|
||||
Params(map[string]interface{}{"token": docRef.Token})
|
||||
}
|
||||
|
||||
if mode == commentModeLocal && strings.TrimSpace(selection) != "" {
|
||||
step := "[1]"
|
||||
if resolvedBy == "wiki" {
|
||||
if isWiki {
|
||||
step = "[2]"
|
||||
}
|
||||
docID := resolvedToken
|
||||
if isWiki && resolvedToken == docRef.Token {
|
||||
docID = "<resolved_docx_token>"
|
||||
}
|
||||
mcpArgs := map[string]interface{}{
|
||||
"doc_id": dryRunLocateDocRef(docRef),
|
||||
"doc_id": docID,
|
||||
"limit": defaultLocateDocLimit,
|
||||
"selection_with_ellipsis": selection,
|
||||
}
|
||||
@@ -171,23 +218,29 @@ var DriveAddComment = common.Shortcut{
|
||||
if mode == commentModeLocal {
|
||||
createDesc = "Create local comment"
|
||||
step = "[2]"
|
||||
if resolvedBy == "wiki" && strings.TrimSpace(selection) != "" {
|
||||
if isWiki && strings.TrimSpace(selection) != "" {
|
||||
step = "[3]"
|
||||
} else if resolvedBy == "wiki" || strings.TrimSpace(selection) != "" {
|
||||
} else if isWiki || strings.TrimSpace(selection) != "" {
|
||||
step = "[2]"
|
||||
} else {
|
||||
step = "[1]"
|
||||
}
|
||||
} else if resolvedBy == "wiki" {
|
||||
} else if isWiki {
|
||||
step = "[2]"
|
||||
}
|
||||
|
||||
return dry.POST(createPath).
|
||||
Desc(step+" "+createDesc).
|
||||
Body(commentBody).
|
||||
Set("file_token", targetToken)
|
||||
Set("file_token", resolvedToken)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Sheet comment: direct URL or token fast path.
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if docRef.Kind == "sheet" {
|
||||
return executeSheetComment(runtime, docRef)
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
@@ -197,6 +250,11 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Wiki resolved to sheet: redirect to sheet comment path.
|
||||
if target.FileType == "sheet" {
|
||||
return executeSheetComment(runtime, commentDocRef{Kind: "sheet", Token: target.FileToken})
|
||||
}
|
||||
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -225,9 +283,9 @@ var DriveAddComment = common.Shortcut{
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
|
||||
requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements)
|
||||
requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements, nil)
|
||||
if mode == commentModeLocal {
|
||||
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements)
|
||||
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements, nil)
|
||||
}
|
||||
|
||||
if mode == commentModeLocal {
|
||||
@@ -288,7 +346,7 @@ func resolveCommentMode(explicitFullComment bool, selection, blockID string) com
|
||||
return commentModeLocal
|
||||
}
|
||||
|
||||
func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return commentDocRef{}, output.ErrValidation("--doc cannot be empty")
|
||||
@@ -297,6 +355,9 @@ func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
if token, ok := extractURLToken(raw, "/wiki/"); ok {
|
||||
return commentDocRef{Kind: "wiki", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/sheets/"); ok {
|
||||
return commentDocRef{Kind: "sheet", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/docx/"); ok {
|
||||
return commentDocRef{Kind: "docx", Token: token}, nil
|
||||
}
|
||||
@@ -304,40 +365,29 @@ func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
return commentDocRef{Kind: "doc", Token: token}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx URL, a docx token, or a wiki URL that resolves to doc/docx", raw)
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/sheet URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet", raw)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw)
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
|
||||
}
|
||||
|
||||
return commentDocRef{Kind: "docx", Token: raw}, nil
|
||||
}
|
||||
|
||||
func dryRunResolvedCommentTarget(docRef commentDocRef, mode commentMode) (token, fileType, resolvedBy string) {
|
||||
switch docRef.Kind {
|
||||
case "docx":
|
||||
return docRef.Token, "docx", "docx"
|
||||
case "doc":
|
||||
return docRef.Token, "doc", "doc"
|
||||
case "wiki":
|
||||
if mode == commentModeFull {
|
||||
return "<resolved_file_token>", "<resolved_file_type>", "wiki"
|
||||
}
|
||||
return "<resolved_docx_token>", "docx", "wiki"
|
||||
default:
|
||||
return "<resolved_docx_token>", "docx", "docx"
|
||||
// Bare token: --type is required.
|
||||
docType = strings.TrimSpace(docType)
|
||||
if docType == "" {
|
||||
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet)")
|
||||
}
|
||||
return commentDocRef{Kind: docType, Token: raw}, nil
|
||||
}
|
||||
|
||||
func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, input string, mode commentMode) (resolvedCommentTarget, error) {
|
||||
docRef, err := parseCommentDocRef(input)
|
||||
docRef, err := parseCommentDocRef(input, runtime.Str("type"))
|
||||
if err != nil {
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" {
|
||||
if mode == commentModeLocal && docRef.Kind != "docx" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx documents")
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "sheet" {
|
||||
if mode == commentModeLocal && docRef.Kind != "docx" && docRef.Kind != "sheet" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx and sheet; old doc format only supports full comments")
|
||||
}
|
||||
return resolvedCommentTarget{
|
||||
DocID: docRef.Token,
|
||||
@@ -364,11 +414,22 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
if objType == "" || objToken == "" {
|
||||
return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType == "sheet" {
|
||||
// Sheet comments are handled via the sheet fast path in Execute.
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return resolvedCommentTarget{
|
||||
DocID: objToken,
|
||||
FileToken: objToken,
|
||||
FileType: "sheet",
|
||||
ResolvedBy: "wiki",
|
||||
WikiToken: docRef.Token,
|
||||
}, nil
|
||||
}
|
||||
if mode == commentModeLocal && objType != "docx" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments currently only support docx documents", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx and sheet; for sheet use --block-id <sheetId>!<cell>", objType)
|
||||
}
|
||||
if mode == commentModeFull && objType != "docx" && objType != "doc" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but full comments only support doc/docx documents", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet", objType)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -531,12 +592,24 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
return replyElements, nil
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}) map[string]interface{} {
|
||||
type sheetAnchor struct {
|
||||
SheetID string
|
||||
Col int
|
||||
Row int
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
"reply_elements": replyElements,
|
||||
}
|
||||
if strings.TrimSpace(blockID) != "" {
|
||||
if sheet != nil {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": sheet.SheetID,
|
||||
"sheet_col": sheet.Col,
|
||||
"sheet_row": sheet.Row,
|
||||
}
|
||||
} else if strings.TrimSpace(blockID) != "" {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": blockID,
|
||||
}
|
||||
@@ -551,13 +624,6 @@ func anchorBlockIDForDryRun(blockID string) string {
|
||||
return "<anchor_block_id>"
|
||||
}
|
||||
|
||||
func dryRunLocateDocRef(docRef commentDocRef) string {
|
||||
if docRef.Kind == "wiki" {
|
||||
return "<resolved_docx_token>"
|
||||
}
|
||||
return docRef.Token
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
@@ -576,6 +642,83 @@ func firstPresentValue(m map[string]interface{}, keys ...string) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSheetCellRef parses "<sheetId>!<cell>" (e.g. "a281f9!D6") into a sheetAnchor.
|
||||
// Column is converted from letter to 0-based index (A=0), row from 1-based to 0-based.
|
||||
func parseSheetCellRef(input string) (*sheetAnchor, error) {
|
||||
parts := strings.SplitN(input, "!", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, output.ErrValidation("--block-id for sheet must be <sheetId>!<cell> (e.g. a281f9!D6), got %q", input)
|
||||
}
|
||||
sheetID := parts[0]
|
||||
cell := strings.TrimSpace(parts[1])
|
||||
|
||||
// Parse cell reference like "D6" into col letter + row number.
|
||||
i := 0
|
||||
for i < len(cell) && ((cell[i] >= 'A' && cell[i] <= 'Z') || (cell[i] >= 'a' && cell[i] <= 'z')) {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i >= len(cell) {
|
||||
return nil, output.ErrValidation("--block-id cell reference %q is invalid (expected e.g. D6)", cell)
|
||||
}
|
||||
colStr := strings.ToUpper(cell[:i])
|
||||
rowStr := cell[i:]
|
||||
|
||||
// Column letter to 0-based index: A=0, B=1, ..., Z=25, AA=26.
|
||||
col := 0
|
||||
for _, ch := range colStr {
|
||||
col = col*26 + int(ch-'A'+1)
|
||||
}
|
||||
col-- // convert to 0-based
|
||||
|
||||
row, err := strconv.Atoi(rowStr)
|
||||
if err != nil || row < 1 {
|
||||
return nil, output.ErrValidation("--block-id row %q is invalid (must be >= 1)", rowStr)
|
||||
}
|
||||
row-- // convert to 0-based
|
||||
|
||||
return &sheetAnchor{SheetID: sheetID, Col: col, Row: row}, nil
|
||||
}
|
||||
|
||||
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
anchor, err := parseSheetCellRef(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(docRef.Token))
|
||||
requestBody := buildCommentCreateV2Request("sheet", "", replyElements, anchor)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating sheet comment in %s (sheet=%s, col=%d, row=%d)\n",
|
||||
common.MaskToken(docRef.Token), anchor.SheetID, anchor.Col, anchor.Row)
|
||||
|
||||
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"comment_id": data["comment_id"],
|
||||
"file_token": docRef.Token,
|
||||
"file_type": "sheet",
|
||||
"comment_mode": "sheet",
|
||||
"block_id": blockID,
|
||||
}
|
||||
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
|
||||
out["created_at"] = createdAt
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractURLToken(raw, marker string) (string, bool) {
|
||||
idx := strings.Index(raw, marker)
|
||||
if idx < 0 {
|
||||
|
||||
@@ -6,6 +6,9 @@ package drive
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestParseCommentDocRef(t *testing.T) {
|
||||
@@ -14,6 +17,7 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
docType string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantErr string
|
||||
@@ -31,11 +35,31 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "raw token treated as docx",
|
||||
name: "raw token with type docx",
|
||||
input: "xxxxxx",
|
||||
docType: "docx",
|
||||
wantKind: "docx",
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "raw token with type sheet",
|
||||
input: "shtToken",
|
||||
docType: "sheet",
|
||||
wantKind: "sheet",
|
||||
wantToken: "shtToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type doc",
|
||||
input: "docToken",
|
||||
docType: "doc",
|
||||
wantKind: "doc",
|
||||
wantToken: "docToken",
|
||||
},
|
||||
{
|
||||
name: "raw token without type",
|
||||
input: "xxxxxx",
|
||||
wantErr: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "old doc url",
|
||||
input: "https://example.larksuite.com/doc/xxxxxx",
|
||||
@@ -53,7 +77,7 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parseCommentDocRef(tt.input)
|
||||
got, err := parseCommentDocRef(tt.input, tt.docType)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
@@ -249,7 +273,7 @@ func TestBuildCommentCreateV2RequestFull(t *testing.T) {
|
||||
"text": "全文评论",
|
||||
},
|
||||
}
|
||||
got := buildCommentCreateV2Request("docx", "", replyElements)
|
||||
got := buildCommentCreateV2Request("docx", "", replyElements, nil)
|
||||
|
||||
if got["file_type"] != "docx" {
|
||||
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
|
||||
@@ -279,7 +303,7 @@ func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
|
||||
"text": "评论内容",
|
||||
},
|
||||
}
|
||||
got := buildCommentCreateV2Request("docx", "blk_123", replyElements)
|
||||
got := buildCommentCreateV2Request("docx", "blk_123", replyElements, nil)
|
||||
|
||||
if got["file_type"] != "docx" {
|
||||
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
|
||||
@@ -300,3 +324,540 @@ func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
|
||||
t.Fatalf("unexpected reply element: %#v", gotReplyElements[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment tests ─────────────────────────────────────────────────────
|
||||
|
||||
func TestParseCommentDocRefSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != "sheet" || ref.Token != "shtToken123" {
|
||||
t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentDocRefSheetWithQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123?sheet=abc", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != "sheet" || ref.Token != "shtToken123" {
|
||||
t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "请修正此单元格"},
|
||||
}
|
||||
got := buildCommentCreateV2Request("sheet", "", replyElements, &sheetAnchor{
|
||||
SheetID: "abc123",
|
||||
Col: 3,
|
||||
Row: 5,
|
||||
})
|
||||
|
||||
if got["file_type"] != "sheet" {
|
||||
t.Fatalf("expected file_type sheet, got %#v", got["file_type"])
|
||||
}
|
||||
anchor, ok := got["anchor"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected anchor map, got %#v", got["anchor"])
|
||||
}
|
||||
if anchor["block_id"] != "abc123" {
|
||||
t.Fatalf("expected block_id abc123, got %#v", anchor["block_id"])
|
||||
}
|
||||
if anchor["sheet_col"] != 3 {
|
||||
t.Fatalf("expected sheet_col 3, got %#v", anchor["sheet_col"])
|
||||
}
|
||||
if anchor["sheet_row"] != 5 {
|
||||
t.Fatalf("expected sheet_row 5, got %#v", anchor["sheet_row"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "test"},
|
||||
}
|
||||
// When both sheet anchor and blockID are provided, sheet anchor wins.
|
||||
got := buildCommentCreateV2Request("sheet", "should_be_ignored", replyElements, &sheetAnchor{
|
||||
SheetID: "s1",
|
||||
Col: 0,
|
||||
Row: 0,
|
||||
})
|
||||
anchor := got["anchor"].(map[string]interface{})
|
||||
if anchor["block_id"] != "s1" {
|
||||
t.Fatalf("expected sheet anchor block_id, got %#v", anchor["block_id"])
|
||||
}
|
||||
if _, exists := anchor["sheet_col"]; !exists {
|
||||
t.Fatal("expected sheet_col in anchor")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
|
||||
|
||||
func TestParseSheetCellRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
sheetID string
|
||||
col int
|
||||
row int
|
||||
}{
|
||||
{"A1", "s1!A1", "s1", 0, 0},
|
||||
{"D6", "abc!D6", "abc", 3, 5},
|
||||
{"AA1", "s1!AA1", "s1", 26, 0},
|
||||
{"lowercase", "s1!d6", "s1", 3, 5},
|
||||
{"B10", "sheet1!B10", "sheet1", 1, 9},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseSheetCellRef(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.SheetID != tc.sheetID || got.Col != tc.col || got.Row != tc.row {
|
||||
t.Fatalf("expected {%s %d %d}, got {%s %d %d}", tc.sheetID, tc.col, tc.row, got.SheetID, got.Col, got.Row)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSheetCellRefInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{"", "noExclamation", "s1!", "!A1", "s1!123", "s1!A"}
|
||||
for _, input := range cases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseSheetCellRef(input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment validate tests ────────────────────────────────────────────
|
||||
|
||||
func TestSheetCommentValidateMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateInvalidBlockIDFormat(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "no-exclamation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "<sheetId>!<cell>") {
|
||||
t.Fatalf("expected format error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--full-comment",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") {
|
||||
t.Fatalf("expected incompatible flags error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--selection-with-ellipsis", "something",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") {
|
||||
t.Fatalf("expected incompatible flags error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment execute tests ─────────────────────────────────────────────
|
||||
|
||||
func TestSheetCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "comment123", "created_at": 1700000000},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"请检查"}]`,
|
||||
"--block-id", "s1!D6",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "comment123") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentExecuteWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtFromURL/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "c456"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtFromURL?sheet=abc",
|
||||
"--content", `[{"type":"text","text":"ok"}]`,
|
||||
"--block-id", "abc!A1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentViaWikiResolve(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtResolved/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiSheetComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken123",
|
||||
"--content", `[{"type":"text","text":"wiki sheet comment"}]`,
|
||||
"--block-id", "s1!B3",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiSheetComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken123",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DryRun coverage ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestDryRunSheetDirectURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "sheet comment") {
|
||||
t.Fatalf("dry-run output missing sheet comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToSheet(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "sheet", "obj_token": "shtResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!D6",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "sheet comment") {
|
||||
t.Fatalf("dry-run output missing sheet comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToDocxFull(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "full comment") {
|
||||
t.Fatalf("dry-run output missing full comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDocxLocalWithBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "blk_123",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "local comment") {
|
||||
t.Fatalf("dry-run output missing local comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDocxFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "full comment") {
|
||||
t.Fatalf("dry-run output missing full comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── resolveCommentTarget coverage ───────────────────────────────────────────
|
||||
|
||||
func TestResolveWikiToDocxFullComment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/docxResolved/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiDocxComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiDocxComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToUnsupportedType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/sheet") {
|
||||
t.Fatalf("expected unsupported type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiIncompleteNodeData(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete node error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocOldFormatLocalCommentRejected(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/doc/oldDocToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "blk_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx and sheet") {
|
||||
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Additional unit function tests ──────────────────────────────────────────
|
||||
|
||||
func TestAnchorBlockIDForDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := anchorBlockIDForDryRun("blk_123"); got != "blk_123" {
|
||||
t.Fatalf("expected blk_123, got %s", got)
|
||||
}
|
||||
if got := anchorBlockIDForDryRun(""); got != "<anchor_block_id>" {
|
||||
t.Fatalf("expected placeholder, got %s", got)
|
||||
}
|
||||
if got := anchorBlockIDForDryRun(" "); got != "<anchor_block_id>" {
|
||||
t.Fatalf("expected placeholder for whitespace, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSheetCellRefRowZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseSheetCellRef("s1!A0")
|
||||
if err == nil || !strings.Contains(err.Error(), "must be >= 1") {
|
||||
t.Fatalf("expected row validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentDocRefPathLikeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseCommentDocRef("token/with/slash", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported --doc input") {
|
||||
t.Fatalf("expected unsupported doc error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractURLTokenEmptyAfterMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ok := extractURLToken("https://example.com/sheets/", "/sheets/")
|
||||
if ok {
|
||||
t.Fatal("expected false for empty token after marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments",
|
||||
Status: 400, Body: map[string]interface{}{"code": 1061002, "msg": "params error"},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +152,9 @@ func ResolveSenderNames(runtime *common.RuntimeContext, messages []map[string]in
|
||||
// This API has lighter permission requirements and works with user identity
|
||||
// even when the target user is not in the app's visible range.
|
||||
// Response uses "users" (not "items") and "user_id" (not "open_id").
|
||||
// The basic_batch endpoint caps user_ids at 10 per request.
|
||||
func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []string, nameMap map[string]string) {
|
||||
const batchSize = 50
|
||||
const batchSize = 10
|
||||
for i := 0; i < len(missingIDs); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(missingIDs) {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package convertlib
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -170,6 +172,57 @@ func TestResolveSenderNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchResolveByBasicContactRespectsAPILimit(t *testing.T) {
|
||||
// basic_batch allows at most 10 user_ids per request. Given 25 missing IDs,
|
||||
// expect three requests with sizes 10 / 10 / 5.
|
||||
var batchSizes []int
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/basic_batch") {
|
||||
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
|
||||
}
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs, _ := payload["user_ids"].([]interface{})
|
||||
if len(userIDs) > 10 {
|
||||
t.Fatalf("batch exceeded API limit: size = %d", len(userIDs))
|
||||
}
|
||||
batchSizes = append(batchSizes, len(userIDs))
|
||||
|
||||
users := make([]interface{}, 0, len(userIDs))
|
||||
for _, raw := range userIDs {
|
||||
id, _ := raw.(string)
|
||||
users = append(users, map[string]interface{}{
|
||||
"user_id": id,
|
||||
"name": "name-" + id,
|
||||
})
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"users": users},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
missingIDs := make([]string, 25)
|
||||
for i := range missingIDs {
|
||||
missingIDs[i] = fmt.Sprintf("ou_%02d", i)
|
||||
}
|
||||
nameMap := map[string]string{}
|
||||
batchResolveByBasicContact(runtime, missingIDs, nameMap)
|
||||
|
||||
if want := []int{10, 10, 5}; !reflect.DeepEqual(batchSizes, want) {
|
||||
t.Fatalf("batch sizes = %v, want %v", batchSizes, want)
|
||||
}
|
||||
if len(nameMap) != 25 {
|
||||
t.Fatalf("resolved name count = %d, want 25", len(nameMap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSenderNamesAPIFailure(t *testing.T) {
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
|
||||
@@ -59,8 +59,12 @@ func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML st
|
||||
return err
|
||||
}
|
||||
|
||||
func Send(runtime *common.RuntimeContext, mailboxID, draftID string) (map[string]interface{}, error) {
|
||||
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, nil)
|
||||
func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (map[string]interface{}, error) {
|
||||
var bodyParams map[string]interface{}
|
||||
if sendTime != "" {
|
||||
bodyParams = map[string]interface{}{"send_time": sendTime}
|
||||
}
|
||||
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
|
||||
}
|
||||
|
||||
func extractDraftID(data map[string]interface{}) string {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
@@ -1162,6 +1163,7 @@ func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interf
|
||||
out["date_formatted"] = normalized.DateFormatted
|
||||
out["message_state_text"] = normalized.MessageStateText
|
||||
if normalized.PriorityType != "" {
|
||||
out["priority_type"] = normalized.PriorityType
|
||||
out["priority_type_text"] = normalized.PriorityTypeText
|
||||
}
|
||||
out["body_plain_text"] = normalized.BodyPlainText
|
||||
@@ -1240,11 +1242,22 @@ func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string
|
||||
out.MessageStateText = messageStateText(state)
|
||||
out.FolderID = strVal(msg["folder_id"])
|
||||
out.LabelIDs = toStringList(msg["label_ids"])
|
||||
// Priority: prefer label_ids (HIGH_PRIORITY/LOW_PRIORITY), fall back to priority_type field.
|
||||
priorityType := strVal(msg["priority_type"])
|
||||
out.PriorityType = priorityType
|
||||
if priorityType != "" {
|
||||
out.PriorityTypeText = priorityTypeText(priorityType)
|
||||
}
|
||||
for _, label := range out.LabelIDs {
|
||||
switch label {
|
||||
case "HIGH_PRIORITY":
|
||||
out.PriorityType = "1"
|
||||
out.PriorityTypeText = "high"
|
||||
case "LOW_PRIORITY":
|
||||
out.PriorityType = "5"
|
||||
out.PriorityTypeText = "low"
|
||||
}
|
||||
}
|
||||
if securityLevel := toSecurityLevel(msg["security_level"]); securityLevel != nil {
|
||||
out.SecurityLevel = securityLevel
|
||||
}
|
||||
@@ -1707,6 +1720,48 @@ func priorityTypeText(priorityType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// priorityFlag is the common flag definition for --priority, shared by all compose shortcuts.
|
||||
var priorityFlag = common.Flag{
|
||||
Name: "priority",
|
||||
Desc: "Email priority: high, normal, low. If omitted, no priority header is set.",
|
||||
}
|
||||
|
||||
// parsePriority parses the --priority flag value and returns the X-Cli-Priority
|
||||
// header value. Returns "" if the priority should not be set (empty or "normal").
|
||||
func parsePriority(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "":
|
||||
return "", nil
|
||||
case "high":
|
||||
return "1", nil
|
||||
case "normal":
|
||||
return "", nil
|
||||
case "low":
|
||||
return "5", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --priority value %q: expected high, normal, or low", value)
|
||||
}
|
||||
}
|
||||
|
||||
// validatePriorityFlag validates the --priority flag value in Validate, so invalid
|
||||
// values are caught before Execute (and before dry-run prints an API plan).
|
||||
func validatePriorityFlag(runtime *common.RuntimeContext) error {
|
||||
v := runtime.Str("priority")
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := parsePriority(v)
|
||||
return err
|
||||
}
|
||||
|
||||
// applyPriority sets the X-Cli-Priority header on the EML builder if priority is non-empty.
|
||||
func applyPriority(bld emlbuilder.Builder, priority string) emlbuilder.Builder {
|
||||
if priority == "" {
|
||||
return bld
|
||||
}
|
||||
return bld.Header("X-Cli-Priority", priority)
|
||||
}
|
||||
|
||||
// parseNetAddrs converts a comma-separated address string to []net/mail.Address.
|
||||
// It reuses ParseMailboxList for display-name-aware parsing and deduplicates
|
||||
// by email address (case-insensitive), preserving the first occurrence.
|
||||
@@ -1906,6 +1961,27 @@ func checkAttachmentSizeLimit(fio fileio.FileIO, filePaths []string, extraBytes
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSendTime checks that --send-time, if provided, requires --confirm-send,
|
||||
// is a valid Unix timestamp in seconds, and is at least 5 minutes in the future.
|
||||
func validateSendTime(runtime *common.RuntimeContext) error {
|
||||
sendTime := runtime.Str("send-time")
|
||||
if sendTime == "" {
|
||||
return nil
|
||||
}
|
||||
if !runtime.Bool("confirm-send") {
|
||||
return fmt.Errorf("--send-time requires --confirm-send to be set")
|
||||
}
|
||||
ts, err := strconv.ParseInt(sendTime, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
|
||||
}
|
||||
minTime := time.Now().Unix() + 5*60
|
||||
if ts < minTime {
|
||||
return fmt.Errorf("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfirmSendScope checks that the user's token includes the
|
||||
// mail:user_mailbox.message:send scope when --confirm-send is set.
|
||||
// This scope is not declared in the shortcut's static Scopes (to keep the
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -1006,3 +1008,212 @@ func TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount(t *testing.T) {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateSendTime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newSendTimeRuntime(t *testing.T, sendTime string, confirmSend bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("send-time", "", "")
|
||||
cmd.Flags().Bool("confirm-send", false, "")
|
||||
if sendTime != "" {
|
||||
_ = cmd.Flags().Set("send-time", sendTime)
|
||||
}
|
||||
if confirmSend {
|
||||
_ = cmd.Flags().Set("confirm-send", "true")
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_Empty(t *testing.T) {
|
||||
rt := newSendTimeRuntime(t, "", false)
|
||||
if err := validateSendTime(rt); err != nil {
|
||||
t.Fatalf("expected nil when send-time is empty, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_RequiresConfirmSend(t *testing.T) {
|
||||
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
||||
rt := newSendTimeRuntime(t, future, false)
|
||||
err := validateSendTime(rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --send-time is set without --confirm-send")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--confirm-send") {
|
||||
t.Errorf("expected error to mention --confirm-send, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_InvalidInteger(t *testing.T) {
|
||||
rt := newSendTimeRuntime(t, "not-a-number", true)
|
||||
err := validateSendTime(rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --send-time is not a valid integer")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Unix timestamp") {
|
||||
t.Errorf("expected error to mention Unix timestamp, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_TooSoon(t *testing.T) {
|
||||
// Just 1 minute in the future — below the 5-minute minimum.
|
||||
soon := strconv.FormatInt(time.Now().Unix()+60, 10)
|
||||
rt := newSendTimeRuntime(t, soon, true)
|
||||
err := validateSendTime(rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --send-time is less than 5 minutes in the future")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected error to mention 5 minute minimum, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_Valid(t *testing.T) {
|
||||
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
||||
rt := newSendTimeRuntime(t, future, true)
|
||||
if err := validateSendTime(rt); err != nil {
|
||||
t.Fatalf("expected nil for valid future send-time, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePriority(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", "", false},
|
||||
{"high", "high", "1", false},
|
||||
{"normal", "normal", "", false},
|
||||
{"low", "low", "5", false},
|
||||
{"case-insensitive HIGH", "HIGH", "1", false},
|
||||
{"whitespace padding", " low ", "5", false},
|
||||
{"invalid", "urgent", "", true},
|
||||
{"numeric not accepted", "1", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parsePriority(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("parsePriority(%q): expected error, got nil", tc.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parsePriority(%q): unexpected error: %v", tc.input, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("parsePriority(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageOutput_PriorityFromLabels(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
labels []interface{}
|
||||
priorityType string
|
||||
wantType string
|
||||
wantText string
|
||||
}{
|
||||
{"high from label", []interface{}{"UNREAD", "HIGH_PRIORITY"}, "", "1", "high"},
|
||||
{"low from label", []interface{}{"LOW_PRIORITY"}, "", "5", "low"},
|
||||
{"no priority label", []interface{}{"UNREAD"}, "", "", ""},
|
||||
{"label overrides priority_type field", []interface{}{"HIGH_PRIORITY"}, "5", "1", "high"},
|
||||
{"priority_type fallback when no label", []interface{}{"UNREAD"}, "1", "1", "high"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
msg := map[string]interface{}{
|
||||
"message_id": "m1",
|
||||
"label_ids": tc.labels,
|
||||
}
|
||||
if tc.priorityType != "" {
|
||||
msg["priority_type"] = tc.priorityType
|
||||
}
|
||||
out := buildMessageOutput(msg, false)
|
||||
gotText, _ := out["priority_type_text"].(string)
|
||||
if gotText != tc.wantText {
|
||||
t.Errorf("priority_type_text = %q, want %q", gotText, tc.wantText)
|
||||
}
|
||||
gotType, _ := out["priority_type"].(string)
|
||||
if gotType != tc.wantType {
|
||||
t.Errorf("priority_type = %q, want %q", gotType, tc.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPriority(t *testing.T) {
|
||||
// Empty priority: EML must not contain X-Cli-Priority header.
|
||||
emptyBld := emlbuilder.New().
|
||||
From("", "sender@example.com").
|
||||
To("", "recipient@example.com").
|
||||
Subject("no priority").
|
||||
TextBody([]byte("body"))
|
||||
emptyBld = applyPriority(emptyBld, "")
|
||||
raw, err := emptyBld.BuildBase64URL()
|
||||
if err != nil {
|
||||
t.Fatalf("build EML failed: %v", err)
|
||||
}
|
||||
eml := decodeBase64URL(raw)
|
||||
if strings.Contains(eml, "X-Cli-Priority") {
|
||||
t.Errorf("expected no X-Cli-Priority header when priority is empty, got EML:\n%s", eml)
|
||||
}
|
||||
|
||||
// Non-empty priority: header must be present with the exact value.
|
||||
highBld := emlbuilder.New().
|
||||
From("", "sender@example.com").
|
||||
To("", "recipient@example.com").
|
||||
Subject("high priority").
|
||||
TextBody([]byte("body"))
|
||||
highBld = applyPriority(highBld, "1")
|
||||
raw, err = highBld.BuildBase64URL()
|
||||
if err != nil {
|
||||
t.Fatalf("build EML failed: %v", err)
|
||||
}
|
||||
eml = decodeBase64URL(raw)
|
||||
if !strings.Contains(eml, "X-Cli-Priority: 1") {
|
||||
t.Errorf("expected X-Cli-Priority: 1 in EML, got:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePriorityFlag(t *testing.T) {
|
||||
makeRuntime := func(priority string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("priority", "", "")
|
||||
if priority != "" {
|
||||
_ = cmd.Flags().Set("priority", priority)
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
priority string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty ok", "", false},
|
||||
{"high ok", "high", false},
|
||||
{"normal ok", "normal", false},
|
||||
{"low ok", "low", false},
|
||||
{"invalid urgent", "urgent", true},
|
||||
{"invalid numeric", "1", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validatePriorityFlag(makeRuntime(tc.priority))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("validatePriorityFlag(%q): expected error, got nil", tc.priority)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("validatePriorityFlag(%q): unexpected error: %v", tc.priority, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
{Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."},
|
||||
{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
signatureFlag,
|
||||
priorityFlag,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
input, err := parseDraftCreateInput(runtime)
|
||||
@@ -79,19 +80,23 @@ var MailDraftCreate = common.Shortcut{
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
input, err := parseDraftCreateInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawEML, err := buildRawEMLForDraftCreate(runtime, input, sigResult)
|
||||
rawEML, err := buildRawEMLForDraftCreate(runtime, input, sigResult, priority)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -129,7 +134,7 @@ func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, er
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult) (string, error) {
|
||||
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult, priority string) (string, error) {
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
if senderEmail == "" {
|
||||
return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly")
|
||||
@@ -190,6 +195,7 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(input.Body))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(input.Attach), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello <b>world</b></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
|
||||
Attach: "./big.txt",
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
|
||||
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for orphaned --inline CID not referenced in body")
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
|
||||
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CID reference")
|
||||
}
|
||||
@@ -142,6 +142,40 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) {
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "priority test",
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
eml := decodeBase64URL(rawEML)
|
||||
if !strings.Contains(eml, "X-Cli-Priority: 1") {
|
||||
t.Errorf("expected X-Cli-Priority: 1 in EML, got:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) {
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "no priority",
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
eml := decodeBase64URL(rawEML)
|
||||
if strings.Contains(eml, "X-Cli-Priority") {
|
||||
t.Errorf("expected no X-Cli-Priority header when priority is empty, got:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("img.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
@@ -153,7 +187,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
PlainText: true,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ var MailDraftEdit = common.Shortcut{
|
||||
{Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
{Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."},
|
||||
{Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."},
|
||||
{Name: "set-priority", Desc: "Set email priority: high, normal, low. Setting 'normal' removes any existing priority header."},
|
||||
{Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -276,6 +277,19 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
|
||||
setRecipients("cc", runtime.Str("set-cc"))
|
||||
setRecipients("bcc", runtime.Str("set-bcc"))
|
||||
|
||||
// --set-priority → inject set_header / remove_header op
|
||||
if setPriority := runtime.Str("set-priority"); setPriority != "" {
|
||||
headerVal, pErr := parsePriority(setPriority)
|
||||
if pErr != nil {
|
||||
return patch, pErr
|
||||
}
|
||||
if headerVal != "" {
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_header", Name: "X-Cli-Priority", Value: headerVal})
|
||||
} else {
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "remove_header", Name: "X-Cli-Priority"})
|
||||
}
|
||||
}
|
||||
|
||||
if len(patch.Ops) == 0 {
|
||||
return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
|
||||
}
|
||||
|
||||
92
shortcuts/mail/mail_draft_edit_test.go
Normal file
92
shortcuts/mail/mail_draft_edit_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newDraftEditRuntime creates a minimal RuntimeContext with the draft-edit
|
||||
// flags used by buildDraftEditPatch.
|
||||
func newDraftEditRuntime(flags map[string]string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, name := range []string{
|
||||
"set-subject", "set-to", "set-cc", "set-bcc",
|
||||
"set-priority", "patch-file",
|
||||
} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name, val := range flags {
|
||||
_ = cmd.Flags().Set(name, val)
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetPriorityHigh(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "high"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patch.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(patch.Ops))
|
||||
}
|
||||
op := patch.Ops[0]
|
||||
if op.Op != "set_header" {
|
||||
t.Errorf("Op = %q, want set_header", op.Op)
|
||||
}
|
||||
if op.Name != "X-Cli-Priority" {
|
||||
t.Errorf("Name = %q, want X-Cli-Priority", op.Name)
|
||||
}
|
||||
if op.Value != "1" {
|
||||
t.Errorf("Value = %q, want 1", op.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetPriorityLow(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "low"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patch.Ops) != 1 || patch.Ops[0].Value != "5" {
|
||||
t.Fatalf("expected single set_header with value 5, got %+v", patch.Ops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetPriorityNormalClears(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "normal"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patch.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(patch.Ops))
|
||||
}
|
||||
if patch.Ops[0].Op != "remove_header" || patch.Ops[0].Name != "X-Cli-Priority" {
|
||||
t.Errorf("expected remove_header X-Cli-Priority, got %+v", patch.Ops[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_InvalidPriority(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "urgent"})
|
||||
if _, err := buildDraftEditPatch(rt); err == nil {
|
||||
t.Fatal("expected error for invalid --set-priority value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_NoPriority(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-subject": "hello"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Only the set_subject op should be present; no priority op injected.
|
||||
if len(patch.Ops) != 1 || patch.Ops[0].Op != "set_subject" {
|
||||
t.Errorf("expected single set_subject op, got %+v", patch.Ops)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,9 @@ var MailForward = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated, appended after original attachments (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
to := runtime.Str("to")
|
||||
@@ -59,6 +61,9 @@ var MailForward = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("confirm-send") {
|
||||
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
|
||||
return err
|
||||
@@ -67,7 +72,10 @@ var MailForward = common.Shortcut{
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -79,6 +87,12 @@ var MailForward = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
@@ -169,6 +183,7 @@ var MailForward = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(buildForwardedMessage(&orig, body)))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
// Download original attachments and accumulate size for limit check
|
||||
type downloadedAtt struct {
|
||||
content []byte
|
||||
@@ -231,7 +246,7 @@ var MailForward = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ var MailReply = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -56,10 +58,16 @@ var MailReply = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -71,6 +79,12 @@ var MailReply = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
@@ -170,6 +184,7 @@ var MailReply = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(bodyStr + quoted))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return err
|
||||
@@ -194,7 +209,7 @@ var MailReply = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ var MailReplyAll = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -57,10 +59,16 @@ var MailReplyAll = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -73,6 +81,12 @@ var MailReplyAll = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
@@ -184,6 +198,7 @@ var MailReplyAll = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(bodyStr + quoted))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return err
|
||||
@@ -208,7 +223,7 @@ var MailReplyAll = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ var MailSend = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
to := runtime.Str("to")
|
||||
subject := runtime.Str("subject")
|
||||
@@ -62,9 +64,15 @@ var MailSend = common.Shortcut{
|
||||
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePriorityFlag(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -77,9 +85,14 @@ var MailSend = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
signatureID := runtime.Str("signature-id")
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
|
||||
@@ -136,6 +149,7 @@ var MailSend = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(body))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return err
|
||||
@@ -162,7 +176,7 @@ var MailSend = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
135
shortcuts/mail/mail_send_time_integration_test.go
Normal file
135
shortcuts/mail/mail_send_time_integration_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// mailShortcutTestFactoryWithSendScope mirrors mailShortcutTestFactory but
|
||||
// additionally grants the mail:user_mailbox.message:send scope so tests can
|
||||
// exercise code paths guarded by validateConfirmSendScope (e.g. validateSendTime).
|
||||
func mailShortcutTestFactoryWithSendScope(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
cfg := mailTestConfig()
|
||||
token := &auth.StoredUAToken{
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AppId: cfg.AppID,
|
||||
AccessToken: "test-user-access-token",
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:send mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = auth.RemoveStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
})
|
||||
return cmdutil.TestFactory(t, cfg)
|
||||
}
|
||||
|
||||
// tooSoonSendTime returns a send-time 60s in the future — below the 5-minute
|
||||
// floor enforced by validateSendTime.
|
||||
func tooSoonSendTime() string {
|
||||
return strconv.FormatInt(time.Now().Unix()+60, 10)
|
||||
}
|
||||
|
||||
// futureSendTime returns a send-time 10 minutes in the future — above the floor.
|
||||
func futureSendTime() string {
|
||||
return strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invalid --send-time rejected by each compose shortcut
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMailSend_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailSend, []string{
|
||||
"+send", "--to", "alice@example.com", "--subject", "hi", "--body", "hello",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailReply_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailReply, []string{
|
||||
"+reply", "--message-id", "msg_001", "--body", "hello",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailReplyAll_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailReplyAll, []string{
|
||||
"+reply-all", "--message-id", "msg_001", "--body", "hello",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailForward_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailForward, []string{
|
||||
"+forward", "--message-id", "msg_001", "--to", "alice@example.com",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// --send-time without --confirm-send is rejected up front
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMailSend_SendTimeWithoutConfirmSend(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailSend, []string{
|
||||
"+send", "--to", "alice@example.com", "--subject", "hi", "--body", "hello",
|
||||
"--send-time", futureSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --send-time without --confirm-send, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--confirm-send") {
|
||||
t.Errorf("expected error to mention --confirm-send, got: %v", err)
|
||||
}
|
||||
}
|
||||
101
shortcuts/okr/okr_cli_resp.go
Normal file
101
shortcuts/okr/okr_cli_resp.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
// RespAlignment 对齐关系
|
||||
type RespAlignment struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
FromOwner RespOwner `json:"from_owner"`
|
||||
ToOwner RespOwner `json:"to_owner"`
|
||||
FromEntityType string `json:"from_entity_type"`
|
||||
FromEntityID string `json:"from_entity_id"`
|
||||
ToEntityType string `json:"to_entity_type"`
|
||||
ToEntityID string `json:"to_entity_id"`
|
||||
}
|
||||
|
||||
// RespCategory 分类
|
||||
type RespCategory struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
CategoryType string `json:"category_type"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
Name CategoryName `json:"name"`
|
||||
}
|
||||
|
||||
// RespCycle 周期
|
||||
type RespCycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicator 指标
|
||||
type RespIndicator struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
EntityType *string `json:"entity_type,omitempty"`
|
||||
EntityID *string `json:"entity_id,omitempty"`
|
||||
IndicatorStatus *string `json:"indicator_status,omitempty"`
|
||||
StatusCalculateType *string `json:"status_calculate_type,omitempty"`
|
||||
StartValue *float64 `json:"start_value,omitempty"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
CurrentValue *float64 `json:"current_value,omitempty"`
|
||||
CurrentValueCalculateType *string `json:"current_value_calculate_type,omitempty"`
|
||||
Unit *RespIndicatorUnit `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicatorUnit 指标单位
|
||||
type RespIndicatorUnit struct {
|
||||
UnitType *string `json:"unit_type,omitempty"`
|
||||
UnitValue *string `json:"unit_value,omitempty"`
|
||||
}
|
||||
|
||||
// RespKeyResult 关键结果
|
||||
type RespKeyResult struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// RespObjective 目标
|
||||
type RespObjective struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
KeyResults []RespKeyResult `json:"key_results,omitempty"`
|
||||
}
|
||||
|
||||
// RespOwner OKR 所有者
|
||||
type RespOwner struct {
|
||||
OwnerType string `json:"owner_type"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
182
shortcuts/okr/okr_cycle_detail.go
Normal file
182
shortcuts/okr/okr_cycle_detail.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// OKRCycleDetail lists all objectives and their key results under a given OKR cycle.
|
||||
var OKRCycleDetail = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-detail",
|
||||
Description: "List objectives and key results under an OKR cycle",
|
||||
Risk: "read",
|
||||
Scopes: []string{"okr:okr.content:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if cycleID == "" {
|
||||
return common.FlagErrorf("--cycle-id is required")
|
||||
}
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return common.FlagErrorf("--cycle-id must be a positive int64")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
params := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/okr/v2/cycles/:cycle_id/objectives").
|
||||
Params(params).
|
||||
Set("cycle_id", cycleID).
|
||||
Desc("Auto-paginates objectives in the cycle, then calls GET /open-apis/okr/v2/objectives/:objective_id/key_results for each objective to fetch key results")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
|
||||
// Paginate objectives under the cycle.
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("page_size", "100")
|
||||
|
||||
var objectives []Objective
|
||||
page := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var obj Objective
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
objectives = append(objectives, obj)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
// For each objective, paginate key results and convert to response format.
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
krQuery := make(larkcore.QueryParams)
|
||||
krQuery.Set("page_size", "100")
|
||||
|
||||
var keyResults []KeyResult
|
||||
krPage := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if krPage > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
krPage++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
||||
data, err := runtime.DoAPIJSON("GET", path, krQuery, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var kr KeyResult
|
||||
if err := json.Unmarshal(raw, &kr); err != nil {
|
||||
continue
|
||||
}
|
||||
keyResults = append(keyResults, kr)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
krQuery.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToResp(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
}
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
|
||||
for _, o := range respObjectives {
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
561
shortcuts/okr/okr_cycle_detail_test.go
Normal file
561
shortcuts/okr/okr_cycle_detail_test.go
Normal file
@@ -0,0 +1,561 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func cycleDetailTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
replacer := strings.NewReplacer("/", "-", " ", "-")
|
||||
suffix := replacer.Replace(strings.ToLower(t.Name()))
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-detail-" + suffix,
|
||||
AppSecret: "secret-okr-detail-" + suffix,
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runCycleDetailShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRCycleDetail.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func decodeEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in output envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestCycleDetailValidate_MissingCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --cycle-id")
|
||||
}
|
||||
// cobra catches required flag before our Validate runs
|
||||
if !strings.Contains(err.Error(), "cycle-id") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_NonNumeric(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "abc"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-numeric --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_Zero(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "0"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for zero --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_Negative(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "-1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_ValidCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
// Need to register stubs because Validate passes and Execute runs
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "123"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestCycleDetailDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{
|
||||
"+cycle-detail",
|
||||
"--cycle-id", "456",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "456") {
|
||||
t.Fatalf("dry-run output should contain cycle-id 456, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/456/objectives") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestCycleDetailExecute_NoObjectives(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/100/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "100"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["cycle_id"] != "100" {
|
||||
t.Fatalf("cycle_id = %v, want 100", data["cycle_id"])
|
||||
}
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 0 {
|
||||
t.Fatalf("objectives = %v, want empty", objs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_WithObjectivesAndKeyResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
|
||||
// Stub for objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/200/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "200",
|
||||
"score": 0.8,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{
|
||||
"text": "Improve team productivity",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Stub for key results of obj-1
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-1",
|
||||
"objective_id": "obj-1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.9,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{
|
||||
"text": "Reduce response time by 50%",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "200"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["cycle_id"] != "200" {
|
||||
t.Fatalf("cycle_id = %v, want 200", data["cycle_id"])
|
||||
}
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 1 {
|
||||
t.Fatalf("objectives count = %d, want 1", len(objs))
|
||||
}
|
||||
obj, _ := objs[0].(map[string]interface{})
|
||||
if obj["id"] != "obj-1" {
|
||||
t.Fatalf("objective id = %v, want obj-1", obj["id"])
|
||||
}
|
||||
krs, _ := obj["key_results"].([]interface{})
|
||||
if len(krs) != 1 {
|
||||
t.Fatalf("key results count = %d, want 1", len(krs))
|
||||
}
|
||||
kr, _ := krs[0].(map[string]interface{})
|
||||
if kr["id"] != "kr-1" {
|
||||
t.Fatalf("key result id = %v, want kr-1", kr["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
|
||||
// First page of objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/300/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "300",
|
||||
"score": 0.5,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "Page1 obj"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next_page_token",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Second page of objectives (no more)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/300/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "300",
|
||||
"score": 0.6,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "Page2 obj"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Key results for obj-p1: first page with has_more=true
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p1-1",
|
||||
"objective_id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.7,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 1 for obj-p1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "kr-p1-next",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Key results for obj-p1: second page with has_more=false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p1-2",
|
||||
"objective_id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.8,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 2 for obj-p1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Key results for obj-p2: first page with has_more=true
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p2/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p2-1",
|
||||
"objective_id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.6,
|
||||
"weight": 0.4,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 1 for obj-p2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "kr-p2-next",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Key results for obj-p2: second page with has_more=false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p2/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p2-2",
|
||||
"objective_id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.9,
|
||||
"weight": 0.6,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 2 for obj-p2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "300"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeEnvelope(t, stdout)
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 2 {
|
||||
t.Fatalf("objectives count = %d, want 2", len(objs))
|
||||
}
|
||||
|
||||
// Verify key_results are aggregated across pages for each objective
|
||||
for i, objRaw := range objs {
|
||||
obj, _ := objRaw.(map[string]interface{})
|
||||
objID, _ := obj["id"].(string)
|
||||
krs, _ := obj["key_results"].([]interface{})
|
||||
if len(krs) != 2 {
|
||||
t.Fatalf("objective[%d] %s: key_results count = %d, want 2", i, objID, len(krs))
|
||||
}
|
||||
// Verify KR IDs are distinct (from different pages)
|
||||
krIDs := make(map[string]bool)
|
||||
for _, krRaw := range krs {
|
||||
kr, _ := krRaw.(map[string]interface{})
|
||||
krID, _ := kr["id"].(string)
|
||||
krIDs[krID] = true
|
||||
}
|
||||
if len(krIDs) != 2 {
|
||||
t.Fatalf("objective %s: expected 2 distinct KR IDs, got %v", objID, krIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_APIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/400/objectives",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "internal error",
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "400"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
189
shortcuts/okr/okr_cycle_list.go
Normal file
189
shortcuts/okr/okr_cycle_list.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// parseTimeRange parses a "YYYY-MM--YYYY-MM" string into two time.Time values.
|
||||
// The start is the first moment of the start month; the end is the last moment of the end month.
|
||||
func parseTimeRange(s string) (start, end time.Time, err error) {
|
||||
parts := strings.SplitN(s, "--", 2)
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid time-range format %q, expected YYYY-MM--YYYY-MM", s)
|
||||
}
|
||||
start, err = time.Parse("2006-01", strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid start month %q: %w", parts[0], err)
|
||||
}
|
||||
end, err = time.Parse("2006-01", strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid end month %q: %w", parts[1], err)
|
||||
}
|
||||
// end is the last moment of the end month
|
||||
end = end.AddDate(0, 1, 0).Add(-time.Millisecond)
|
||||
if start.After(end) {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("start month %s is after end month %s", parts[0], parts[1])
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// cycleOverlaps checks whether a cycle's [startMs, endMs] overlaps with [rangeStart, rangeEnd].
|
||||
func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
|
||||
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
|
||||
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
cycleStart := time.UnixMilli(startMs)
|
||||
cycleEnd := time.UnixMilli(endMs)
|
||||
// Two ranges overlap iff one starts before the other ends
|
||||
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
|
||||
}
|
||||
|
||||
var OKRListCycles = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-list",
|
||||
Description: "List okr cycles of a certain user",
|
||||
Risk: "read",
|
||||
Scopes: []string{"okr:okr.period:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Desc: "user ID", Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "time-range", Desc: "specify time range. Use Format as YYYY-MM--YYYY-MM. leave empty to fetch all user cycles."},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
|
||||
}
|
||||
userID := runtime.Str("user-id")
|
||||
if err := validate.RejectControlChars(userID, "user-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr := runtime.Str("time-range")
|
||||
if tr != "" {
|
||||
if err := validate.RejectControlChars(tr, "time-range"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseTimeRange(tr); err != nil {
|
||||
return common.FlagErrorf("--time-range: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{
|
||||
"user_id": runtime.Str("user-id"),
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"page_size": 100,
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/okr/v2/cycles").
|
||||
Params(params).
|
||||
Desc("List OKR cycles for user, paginated at 100 per page, filtered by time-range")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
userID := runtime.Str("user-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
timeRange := runtime.Str("time-range")
|
||||
|
||||
// Parse time range for filtering
|
||||
var rangeStart, rangeEnd time.Time
|
||||
var hasRange bool
|
||||
if timeRange != "" {
|
||||
var err error
|
||||
rangeStart, rangeEnd, err = parseTimeRange(timeRange)
|
||||
if err != nil {
|
||||
return common.FlagErrorf("--time-range: %s", err)
|
||||
}
|
||||
hasRange = true
|
||||
}
|
||||
|
||||
// Paginated fetch of all cycles
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id", userID)
|
||||
queryParams.Set("user_id_type", userIDType)
|
||||
queryParams.Set("page_size", "100")
|
||||
|
||||
var allCycles []Cycle
|
||||
page := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
data, err := runtime.DoAPIJSON("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cycle Cycle
|
||||
if err := json.Unmarshal(raw, &cycle); err != nil {
|
||||
continue
|
||||
}
|
||||
allCycles = append(allCycles, cycle)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
// Filter by time-range overlap
|
||||
var filtered []Cycle
|
||||
for i := range allCycles {
|
||||
if !hasRange || cycleOverlaps(&allCycles[i], rangeStart, rangeEnd) {
|
||||
filtered = append(filtered, allCycles[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
respCycles := make([]*RespCycle, 0, len(filtered))
|
||||
for i := range filtered {
|
||||
respCycles = append(respCycles, filtered[i].ToResp())
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||
for _, c := range respCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
448
shortcuts/okr/okr_cycle_list_test.go
Normal file
448
shortcuts/okr/okr_cycle_list_test.go
Normal file
@@ -0,0 +1,448 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func cycleListTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
replacer := strings.NewReplacer("/", "-", " ", "-")
|
||||
suffix := replacer.Replace(strings.ToLower(t.Name()))
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-list-" + suffix,
|
||||
AppSecret: "secret-okr-list-" + suffix,
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runCycleListShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRListCycles.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestCycleListValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--user-id-type", "invalid_type",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --user-id-type")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--user-id-type must be one of") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ControlCharsInUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-\t123",
|
||||
"--user-id-type", "open_id",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for control chars in --user-id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ControlCharsInTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--user-id-type", "open_id",
|
||||
"--time-range", "2025-01\t--2025-06",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for control chars in --time-range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_InvalidTimeRangeFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01-2025-06",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --time-range format")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--time-range") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_StartAfterEndTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-06--2025-01",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for start after end in --time-range")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--time-range") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ValidNoTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ValidWithTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_AllUserIDTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, idType := range []string{"open_id", "union_id", "user_id"} {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "test-id",
|
||||
"--user-id-type", idType,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("user-id-type=%q: unexpected error: %v", idType, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestCycleListDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-456",
|
||||
"--user-id-type", "open_id",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "ou-456") {
|
||||
t.Fatalf("dry-run output should contain user-id ou-456, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-789",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestCycleListExecute_NoCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 0 {
|
||||
t.Fatalf("cycles = %v, want empty", cycles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1751318400000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-1",
|
||||
"score": 0.75,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-2",
|
||||
"start_time": "1704067200000",
|
||||
"end_time": "1719792000000",
|
||||
"cycle_status": 2,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-2",
|
||||
"score": 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
total, _ := data["total"].(float64)
|
||||
if int(total) != 2 {
|
||||
t.Fatalf("total = %v, want 2", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// Return two cycles: one inside the range, one outside
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-in-range",
|
||||
"start_time": "1735689600000", // 2025-01-01
|
||||
"end_time": "1738368000000", // 2025-02-01
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-out-range",
|
||||
"start_time": "1704067200000", // 2024-01-01
|
||||
"end_time": "1706745600000", // 2024-02-01
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 1 {
|
||||
t.Fatalf("cycles count = %d, want 1 (only cycle-in-range should pass filter)", len(cycles))
|
||||
}
|
||||
cycle, _ := cycles[0].(map[string]interface{})
|
||||
if cycle["id"] != "cycle-in-range" {
|
||||
t.Fatalf("cycle id = %v, want cycle-in-range", cycle["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// First page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-p1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1738368000000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next_page",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Second page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-p2",
|
||||
"start_time": "1738368000000",
|
||||
"end_time": "1743465600000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_APIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "internal error",
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
361
shortcuts/okr/okr_openapi.go
Normal file
361
shortcuts/okr/okr_openapi.go
Normal file
@@ -0,0 +1,361 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CycleStatus 周期状态
|
||||
type CycleStatus int32
|
||||
|
||||
const (
|
||||
CycleStatusDefault CycleStatus = 0
|
||||
CycleStatusNormal CycleStatus = 1
|
||||
CycleStatusInvalid CycleStatus = 2
|
||||
CycleStatusHidden CycleStatus = 3
|
||||
)
|
||||
|
||||
func (t CycleStatus) Ptr() *CycleStatus { return &t }
|
||||
|
||||
// StatusCalculateType 状态计算类型
|
||||
type StatusCalculateType int32
|
||||
|
||||
const (
|
||||
StatusCalculateTypeManualUpdate StatusCalculateType = 0
|
||||
StatusCalculateTypeAutomaticallyUpdatesBasedOnProgressAndCurrentTime StatusCalculateType = 1
|
||||
StatusCalculateTypeStatusUpdatesBasedOnTheHighestRiskKeyResults StatusCalculateType = 2
|
||||
)
|
||||
|
||||
// BlockElementType 块元素类型
|
||||
type BlockElementType string
|
||||
|
||||
const (
|
||||
BlockElementTypeGallery BlockElementType = "gallery"
|
||||
BlockElementTypeParagraph BlockElementType = "paragraph"
|
||||
)
|
||||
|
||||
func (t BlockElementType) Ptr() *BlockElementType { return &t }
|
||||
|
||||
// CategoryName 分类名称
|
||||
type CategoryName struct {
|
||||
Zh *string `json:"zh,omitempty"`
|
||||
En *string `json:"en,omitempty"`
|
||||
Ja *string `json:"ja,omitempty"`
|
||||
}
|
||||
|
||||
// ListType 列表类型
|
||||
type ListType string
|
||||
|
||||
const (
|
||||
ListTypeBullet ListType = "bullet"
|
||||
ListTypeCheckBox ListType = "checkBox"
|
||||
ListTypeCheckedBox ListType = "checkedBox"
|
||||
ListTypeIndent ListType = "indent"
|
||||
ListTypeNumber ListType = "number"
|
||||
)
|
||||
|
||||
// OwnerType 所有者类型
|
||||
type OwnerType string
|
||||
|
||||
const (
|
||||
OwnerTypeDepartment OwnerType = "department"
|
||||
OwnerTypeUser OwnerType = "user"
|
||||
)
|
||||
|
||||
// ParagraphElementType 段落元素类型
|
||||
type ParagraphElementType string
|
||||
|
||||
const (
|
||||
ParagraphElementTypeDocsLink ParagraphElementType = "docsLink"
|
||||
ParagraphElementTypeMention ParagraphElementType = "mention"
|
||||
ParagraphElementTypeTextRun ParagraphElementType = "textRun"
|
||||
)
|
||||
|
||||
func (t ParagraphElementType) Ptr() *ParagraphElementType { return &t }
|
||||
|
||||
// ContentBlock 内容块
|
||||
type ContentBlock struct {
|
||||
Blocks []ContentBlockElement `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
// ContentBlockElement 内容块元素
|
||||
type ContentBlockElement struct {
|
||||
BlockElementType *BlockElementType `json:"block_element_type,omitempty"`
|
||||
Paragraph *ContentParagraph `json:"paragraph,omitempty"`
|
||||
Gallery *ContentGallery `json:"gallery,omitempty"`
|
||||
}
|
||||
|
||||
// ContentColor 颜色
|
||||
type ContentColor struct {
|
||||
Red *int32 `json:"red,omitempty"`
|
||||
Green *int32 `json:"green,omitempty"`
|
||||
Blue *int32 `json:"blue,omitempty"`
|
||||
Alpha *float64 `json:"alpha,omitempty"`
|
||||
}
|
||||
|
||||
// ContentDocsLink 文档链接
|
||||
type ContentDocsLink struct {
|
||||
URL *string `json:"url,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// ContentGallery 图库
|
||||
type ContentGallery struct {
|
||||
Images []ContentImageItem `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// ContentImageItem 图片项
|
||||
type ContentImageItem struct {
|
||||
FileToken *string `json:"file_token,omitempty"`
|
||||
Src *string `json:"src,omitempty"`
|
||||
Width *float64 `json:"width,omitempty"`
|
||||
Height *float64 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
// ContentLink 链接
|
||||
type ContentLink struct {
|
||||
URL *string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// ContentList 列表
|
||||
type ContentList struct {
|
||||
ListType *ListType `json:"list_type,omitempty"`
|
||||
IndentLevel *int32 `json:"indent_level,omitempty"`
|
||||
Number *int32 `json:"number,omitempty"`
|
||||
}
|
||||
|
||||
// ContentMention 提及
|
||||
type ContentMention struct {
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraph 段落
|
||||
type ContentParagraph struct {
|
||||
Style *ContentParagraphStyle `json:"style,omitempty"`
|
||||
Elements []ContentParagraphElement `json:"elements,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraphElement 段落元素
|
||||
type ContentParagraphElement struct {
|
||||
ParagraphElementType *ParagraphElementType `json:"paragraph_element_type,omitempty"`
|
||||
TextRun *ContentTextRun `json:"text_run,omitempty"`
|
||||
DocsLink *ContentDocsLink `json:"docs_link,omitempty"`
|
||||
Mention *ContentMention `json:"mention,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraphStyle 段落样式
|
||||
type ContentParagraphStyle struct {
|
||||
List *ContentList `json:"list,omitempty"`
|
||||
}
|
||||
|
||||
// ContentTextRun 文本块
|
||||
type ContentTextRun struct {
|
||||
Text *string `json:"text,omitempty"`
|
||||
Style *ContentTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// ContentTextStyle 文本样式
|
||||
type ContentTextStyle struct {
|
||||
Bold *bool `json:"bold,omitempty"`
|
||||
StrikeThrough *bool `json:"strike_through,omitempty"`
|
||||
BackColor *ContentColor `json:"back_color,omitempty"`
|
||||
TextColor *ContentColor `json:"text_color,omitempty"`
|
||||
Link *ContentLink `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
// Cycle 周期
|
||||
type Cycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner Owner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *CycleStatus `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// KeyResult 关键结果
|
||||
type KeyResult struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner Owner `json:"owner"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// Objective 目标
|
||||
type Objective struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner Owner `json:"owner"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *ContentBlock `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
}
|
||||
|
||||
// Owner OKR 所有者
|
||||
type Owner struct {
|
||||
OwnerType OwnerType `json:"owner_type"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// ToString CycleStatus to string
|
||||
func (t CycleStatus) ToString() string {
|
||||
switch t {
|
||||
case CycleStatusDefault:
|
||||
return "default"
|
||||
case CycleStatusNormal:
|
||||
return "normal"
|
||||
case CycleStatusInvalid:
|
||||
return "invalid"
|
||||
case CycleStatusHidden:
|
||||
return "hidden"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// formatTimestamp 格式化毫秒级时间戳为 DateTime 格式
|
||||
func formatTimestamp(ts string) string {
|
||||
if ts == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
return ts
|
||||
}
|
||||
t := time.UnixMilli(millis)
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// ToResp converts Cycle to RespCycle
|
||||
func (c *Cycle) ToResp() *RespCycle {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &RespCycle{
|
||||
ID: c.ID,
|
||||
CreateTime: formatTimestamp(c.CreateTime),
|
||||
UpdateTime: formatTimestamp(c.UpdateTime),
|
||||
TenantCycleID: c.TenantCycleID,
|
||||
Owner: *c.Owner.ToResp(),
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
Score: c.Score,
|
||||
}
|
||||
if c.CycleStatus != nil {
|
||||
s := c.CycleStatus.ToString()
|
||||
resp.CycleStatus = &s
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// ToResp converts KeyResult to RespKeyResult
|
||||
func (k *KeyResult) ToResp() *RespKeyResult {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespKeyResult{
|
||||
ID: k.ID,
|
||||
CreateTime: formatTimestamp(k.CreateTime),
|
||||
UpdateTime: formatTimestamp(k.UpdateTime),
|
||||
Owner: *k.Owner.ToResp(),
|
||||
ObjectiveID: k.ObjectiveID,
|
||||
Position: k.Position,
|
||||
Score: k.Score,
|
||||
Weight: k.Weight,
|
||||
}
|
||||
if k.Deadline != nil {
|
||||
d := formatTimestamp(*k.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
// Serialize ContentBlock to JSON string (only if Content is not nil and has blocks)
|
||||
if k.Content != nil && len(k.Content.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(k.Content); err == nil {
|
||||
s := string(bytes)
|
||||
result.Content = &s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToResp converts Objective to RespObjective
|
||||
func (o *Objective) ToResp() *RespObjective {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespObjective{
|
||||
ID: o.ID,
|
||||
CreateTime: formatTimestamp(o.CreateTime),
|
||||
UpdateTime: formatTimestamp(o.UpdateTime),
|
||||
Owner: *o.Owner.ToResp(),
|
||||
CycleID: o.CycleID,
|
||||
Position: o.Position,
|
||||
Score: o.Score,
|
||||
Weight: o.Weight,
|
||||
CategoryID: o.CategoryID,
|
||||
}
|
||||
if o.Deadline != nil {
|
||||
d := formatTimestamp(*o.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
// Serialize Content to JSON string
|
||||
if o.Content != nil && len(o.Content.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(o.Content); err == nil {
|
||||
s := string(bytes)
|
||||
result.Content = &s
|
||||
}
|
||||
}
|
||||
// Serialize Notes to JSON string
|
||||
if o.Notes != nil && len(o.Notes.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(o.Notes); err == nil {
|
||||
s := string(bytes)
|
||||
result.Notes = &s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToResp converts Owner to RespOwner
|
||||
func (o *Owner) ToResp() *RespOwner {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return &RespOwner{
|
||||
OwnerType: string(o.OwnerType),
|
||||
UserID: o.UserID,
|
||||
}
|
||||
}
|
||||
|
||||
// ptrStr dereferences a string pointer, returning "" for nil.
|
||||
func ptrStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// ptrFloat64 dereferences a float64 pointer, returning 0 for nil.
|
||||
func ptrFloat64(p *float64) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
142
shortcuts/okr/okr_openapi_test.go
Normal file
142
shortcuts/okr/okr_openapi_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestFormatTimestamp(t *testing.T) {
|
||||
convey.Convey("formatTimestamp", t, func() {
|
||||
convey.Convey("empty string returns empty", func() {
|
||||
result := formatTimestamp("")
|
||||
convey.So(result, convey.ShouldEqual, "")
|
||||
})
|
||||
|
||||
convey.Convey("valid timestamp formats correctly", func() {
|
||||
result := formatTimestamp("1735689600000")
|
||||
// 不检查具体的时分秒,因为时区不同结果会不同
|
||||
convey.So(result, convey.ShouldStartWith, "2025-01-01")
|
||||
})
|
||||
|
||||
convey.Convey("invalid timestamp returns original", func() {
|
||||
result := formatTimestamp("not-a-number")
|
||||
convey.So(result, convey.ShouldEqual, "not-a-number")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestToRespMethods(t *testing.T) {
|
||||
convey.Convey("ToResp methods handle nil", t, func() {
|
||||
convey.So((*Cycle)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*KeyResult)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*Objective)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*Owner)(nil).ToResp(), convey.ShouldBeNil)
|
||||
})
|
||||
|
||||
convey.Convey("ToResp methods work with valid objects", t, func() {
|
||||
convey.Convey("Cycle", func() {
|
||||
cycle := &Cycle{
|
||||
ID: "cycle-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
TenantCycleID: "tenant-cycle-id",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
StartTime: "1735689600000",
|
||||
EndTime: "1751318400000",
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
Score: float64Ptr(0.75),
|
||||
}
|
||||
resp := cycle.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
||||
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
|
||||
})
|
||||
|
||||
convey.Convey("Objective", func() {
|
||||
obj := &Objective{
|
||||
ID: "obj-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
CycleID: "cycle-id",
|
||||
Position: int32Ptr(1),
|
||||
Score: float64Ptr(0.8),
|
||||
Weight: float64Ptr(1.0),
|
||||
Deadline: strPtr("1751318400000"),
|
||||
Content: &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Test objective"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
resp := obj.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "obj-id")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.8)
|
||||
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
|
||||
convey.Convey("KeyResult", func() {
|
||||
kr := &KeyResult{
|
||||
ID: "kr-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
ObjectiveID: "obj-id",
|
||||
Position: int32Ptr(1),
|
||||
Content: &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Test KR"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Score: float64Ptr(0.9),
|
||||
Weight: float64Ptr(0.5),
|
||||
Deadline: strPtr("1751318400000"),
|
||||
}
|
||||
resp := kr.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "kr-id")
|
||||
convey.So(resp.ObjectiveID, convey.ShouldEqual, "obj-id")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.9)
|
||||
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// strPtr returns a pointer to the given string value.
|
||||
func strPtr(v string) *string { return &v }
|
||||
|
||||
// int32Ptr returns a pointer to the given int32 value.
|
||||
func int32Ptr(v int32) *int32 { return &v }
|
||||
|
||||
// float64Ptr returns a pointer to the given float64 value.
|
||||
func float64Ptr(v float64) *float64 { return &v }
|
||||
16
shortcuts/okr/shortcuts.go
Normal file
16
shortcuts/okr/shortcuts.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Shortcuts returns all okr shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
OKRListCycles,
|
||||
OKRCycleDetail,
|
||||
}
|
||||
}
|
||||
17
shortcuts/okr/shortcuts_test.go
Normal file
17
shortcuts/okr/shortcuts_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestShortcutsRegistration(t *testing.T) {
|
||||
convey.Convey("Shortcuts() returns all commands", t, func() {
|
||||
list := Shortcuts()
|
||||
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/okr"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -45,6 +46,7 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
|
||||
}
|
||||
|
||||
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
|
||||
|
||||
@@ -22,7 +22,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "data", Desc: "JSON array of {ranges, style} objects", Required: true},
|
||||
{Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
@@ -49,6 +49,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
}
|
||||
var data interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("data")), &data)
|
||||
normalizeBatchStyleRanges(data)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
|
||||
Body(map[string]interface{}{
|
||||
@@ -66,6 +67,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
|
||||
result, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
|
||||
@@ -81,3 +83,34 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// normalizeBatchStyleRanges mutates each string entry in data[].ranges in place
|
||||
// so the /styles_batch_update endpoint accepts single-cell shorthand.
|
||||
// Entries carrying a sheetId! prefix (e.g. "sheet1!A1") are expanded to
|
||||
// "sheet1!A1:A1"; multi-cell spans pass through unchanged.
|
||||
// A bare single cell without the sheetId! prefix (e.g. "A1") cannot be
|
||||
// expanded because the helper has no sheet-id context (the shortcut exposes
|
||||
// no --sheet-id flag), and the backend would reject the payload anyway —
|
||||
// such entries pass through unchanged. Non-string entries, missing
|
||||
// ranges keys, and non-array top-level inputs are ignored silently.
|
||||
func normalizeBatchStyleRanges(data interface{}) {
|
||||
items, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ranges, ok := entry["ranges"].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, r := range ranges {
|
||||
if s, ok := r.(string); ok {
|
||||
ranges[i] = normalizePointRange("", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +414,46 @@ func TestSheetSetStyleExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleDryRunExpandsSingleCell(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "A1", "sheet-id": "sheet1",
|
||||
"style": `{"font":{"bold":true}}`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:A1"`) {
|
||||
t.Fatalf("DryRun should expand single cell to A1:A1: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteExpandsSingleCell(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"updates": map[string]interface{}{"updatedCells": float64(1), "updatedRange": "sheet1!A1:A1"},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetSetStyle, []string{
|
||||
"+set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--range", "A1",
|
||||
"--style", `{"font":{"bold":true}}`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
appendStyle, _ := body["appendStyle"].(map[string]interface{})
|
||||
if appendStyle["range"] != "sheet1!A1:A1" {
|
||||
t.Fatalf("single cell should be expanded to sheet1!A1:A1, got: %v", appendStyle["range"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -523,6 +563,51 @@ func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleDryRunExpandsSingleCells(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test",
|
||||
"data": `[{"ranges":["sheet1!A2","sheet1!B2"],"style":{"font":{"bold":true}}}]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"sheet1!A2:A2"`) || !strings.Contains(got, `"sheet1!B2:B2"`) {
|
||||
t.Fatalf("DryRun should expand single cells to A2:A2 and B2:B2: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteNormalizesMixedRanges(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"totalUpdatedCells": float64(5),
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
|
||||
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--data", `[{"ranges":["sheet1!C1:D2","sheet1!E3"],"style":{"font":{"italic":true}}}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
data, _ := body["data"].([]interface{})
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("expected 1 data entry, got %d", len(data))
|
||||
}
|
||||
entry, _ := data[0].(map[string]interface{})
|
||||
ranges, _ := entry["ranges"].([]interface{})
|
||||
if len(ranges) != 2 || ranges[0] != "sheet1!C1:D2" || ranges[1] != "sheet1!E3:E3" {
|
||||
t.Fatalf("ranges should preserve span and expand single cell, got: %v", ranges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -537,3 +622,101 @@ func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBatchStyleRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("single cell with sheet prefix is expanded in place", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"sheet1!A1", "sheet1!B2"},
|
||||
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "sheet1!A1:A1" || got[1] != "sheet1!B2:B2" {
|
||||
t.Fatalf("want [sheet1!A1:A1 sheet1!B2:B2], got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-cell span passes through unchanged", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"sheet1!A1:B2"},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "sheet1!A1:B2" {
|
||||
t.Fatalf("multi-cell span should be unchanged, got %v", got[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare single cell without sheet prefix passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Without a sheetId! prefix there's no sheet context; entry is left
|
||||
// alone and the backend will reject it. Documented in the helper.
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"A1"},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "A1" {
|
||||
t.Fatalf("bare single cell should pass through, got %v", got[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-string entries are preserved", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"sheet1!A1", 42, nil, "sheet1!B2"},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "sheet1!A1:A1" {
|
||||
t.Fatalf("first entry should be expanded, got %v", got[0])
|
||||
}
|
||||
if got[1] != 42 {
|
||||
t.Fatalf("int entry should be preserved, got %v", got[1])
|
||||
}
|
||||
if got[2] != nil {
|
||||
t.Fatalf("nil entry should be preserved, got %v", got[2])
|
||||
}
|
||||
if got[3] != "sheet1!B2:B2" {
|
||||
t.Fatalf("last entry should be expanded, got %v", got[3])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing or non-array ranges key is skipped", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"ranges": "not-an-array",
|
||||
},
|
||||
"not-a-map",
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
if data[1].(map[string]interface{})["ranges"] != "not-an-array" {
|
||||
t.Fatal("non-array ranges should be left alone")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("top-level non-array inputs do not panic", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Any of these would panic if the helper didn't guard its type assertions.
|
||||
normalizeBatchStyleRanges(nil)
|
||||
normalizeBatchStyleRanges(map[string]interface{}{"foo": "bar"})
|
||||
normalizeBatchStyleRanges("string")
|
||||
normalizeBatchStyleRanges(42)
|
||||
})
|
||||
}
|
||||
|
||||
325
shortcuts/sheets/sheet_float_image.go
Normal file
325
shortcuts/sheets/sheet_float_image.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func floatImageBasePath(token, sheetID string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/float_images",
|
||||
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
|
||||
}
|
||||
|
||||
func floatImageItemPath(token, sheetID, floatImageID string) string {
|
||||
return fmt.Sprintf("%s/%s", floatImageBasePath(token, sheetID), validate.EncodePathSegment(floatImageID))
|
||||
}
|
||||
|
||||
func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if u := runtime.Str("url"); u != "" {
|
||||
if parsed := extractSpreadsheetToken(u); parsed != u {
|
||||
token = parsed
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func validateFloatImageRange(sheetID, rangeVal string) error {
|
||||
if rangeVal == "" {
|
||||
return nil
|
||||
}
|
||||
if err := validateSingleCellRange(rangeVal); err != nil {
|
||||
return err
|
||||
}
|
||||
if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID {
|
||||
return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFloatImageUpdatePayload rejects an update request that carries no
|
||||
// mutable field. Without this, PATCH {} reaches the server as a confusing
|
||||
// no-op or opaque error.
|
||||
func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
|
||||
hasField := runtime.Str("range") != "" ||
|
||||
runtime.Cmd.Flags().Changed("width") ||
|
||||
runtime.Cmd.Flags().Changed("height") ||
|
||||
runtime.Cmd.Flags().Changed("offset-x") ||
|
||||
runtime.Cmd.Flags().Changed("offset-y")
|
||||
if !hasField {
|
||||
return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFloatImageDims checks the numeric bounds we can verify without
|
||||
// fetching cell dimensions: width/height >= 20 and offset-x/offset-y >= 0.
|
||||
// The upper bounds (offset < anchor cell's width/height) are validated by
|
||||
// the server and surfaced through the 1310246 error hint.
|
||||
// Only flags explicitly supplied by the user are checked, so omitted flags
|
||||
// (which fall back to server defaults) pass through unchanged.
|
||||
func validateFloatImageDims(runtime *common.RuntimeContext) error {
|
||||
if runtime.Cmd.Flags().Changed("width") {
|
||||
if v := runtime.Int("width"); v < 20 {
|
||||
return common.FlagErrorf("--width must be >= 20 pixels, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("height") {
|
||||
if v := runtime.Int("height"); v < 20 {
|
||||
return common.FlagErrorf("--height must be >= 20 pixels, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-x") {
|
||||
if v := runtime.Int("offset-x"); v < 0 {
|
||||
return common.FlagErrorf("--offset-x must be >= 0, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-y") {
|
||||
if v := runtime.Int("offset-y"); v < 0 {
|
||||
return common.FlagErrorf("--offset-y must be >= 0, got %d", v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if includeToken {
|
||||
if s := runtime.Str("float-image-token"); s != "" {
|
||||
body["float_image_token"] = s
|
||||
}
|
||||
}
|
||||
if s := runtime.Str("range"); s != "" {
|
||||
body["range"] = s
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("width") {
|
||||
body["width"] = runtime.Int("width")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("height") {
|
||||
body["height"] = runtime.Int("height")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-x") {
|
||||
body["offset_x"] = runtime.Int("offset-x")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-y") {
|
||||
body["offset_y"] = runtime.Int("offset-y")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// SheetCreateFloatImage creates a float image on a sheet.
|
||||
var SheetCreateFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-float-image",
|
||||
Description: "Create a floating image on a sheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-token", Desc: "image file token (from upload API)", Required: true},
|
||||
{Name: "range", Desc: "anchor cell, must be a single cell (e.g. sheetId!A1:A1)", Required: true},
|
||||
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
|
||||
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
|
||||
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
|
||||
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
|
||||
{Name: "float-image-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFloatImageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFloatImageDims(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, true)
|
||||
if s := runtime.Str("float-image-id"); s != "" {
|
||||
body["float_image_id"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, true)
|
||||
if s := runtime.Str("float-image-id"); s != "" {
|
||||
body["float_image_id"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetUpdateFloatImage updates a float image's properties.
|
||||
var SheetUpdateFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-float-image",
|
||||
Description: "Update a floating image",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-id", Desc: "float image ID", Required: true},
|
||||
{Name: "range", Desc: "new anchor cell, must be a single cell (e.g. sheetId!B2:B2)"},
|
||||
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
|
||||
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
|
||||
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
|
||||
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFloatImageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFloatImageUpdatePayload(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFloatImageDims(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, false)
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, false)
|
||||
data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetGetFloatImage retrieves a single float image.
|
||||
var SheetGetFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-float-image",
|
||||
Description: "Get a floating image by ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-id", Desc: "float image ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFloatImageToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetListFloatImages queries all float images in a sheet.
|
||||
var SheetListFloatImages = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+list-float-images",
|
||||
Description: "List all floating images in a sheet",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFloatImageToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/query").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetDeleteFloatImage deletes a float image.
|
||||
var SheetDeleteFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-float-image",
|
||||
Description: "Delete a floating image",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-id", Desc: "float image ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFloatImageToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
524
shortcuts/sheets/sheet_float_image_test.go
Normal file
524
shortcuts/sheets/sheet_float_image_test.go
Normal file
@@ -0,0 +1,524 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ── CreateFloatImage ────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateFloatImageValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Pixel flags are int-typed by the shortcut; leave them unset (empty
|
||||
// intFlags map) so Cmd.Flags().Changed(...) returns false and
|
||||
// validateFloatImageDims doesn't try to read non-existent ints.
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
|
||||
}, nil, nil)
|
||||
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateRejectsMultiCellRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:B2",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "single cell") {
|
||||
t.Fatalf("expected single-cell error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-token": "boxToken", "range": "other!A1:A1",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
|
||||
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
intFlags map[string]int
|
||||
wantSubst string
|
||||
}{
|
||||
{"width below 20", map[string]int{"width": 5}, "--width must be >= 20"},
|
||||
{"height below 20", map[string]int{"height": 10}, "--height must be >= 20"},
|
||||
{"negative offset-x", map[string]int{"offset-x": -1}, "--offset-x must be >= 0"},
|
||||
{"negative offset-y", map[string]int{"offset-y": -5}, "--offset-y must be >= 0"},
|
||||
}
|
||||
|
||||
baseStr := map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
|
||||
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateAcceptsBoundaryDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Boundary values exactly at the lower bound should pass.
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
|
||||
},
|
||||
map[string]int{"width": 20, "height": 20, "offset-x": 0, "offset-y": 0}, nil)
|
||||
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("boundary values should pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"float-image-token": "boxToken", "range": "sheet1!A1:A1", "float-image-id": "",
|
||||
},
|
||||
map[string]int{"width": 200, "height": 150}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreateFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"POST"`) {
|
||||
t.Fatalf("DryRun should use POST: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `float_images`) {
|
||||
t.Fatalf("DryRun URL missing float_images: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"float_image_token":"boxToken"`) {
|
||||
t.Fatalf("DryRun missing float_image_token: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"width":200`) || !strings.Contains(got, `"height":150`) {
|
||||
t.Fatalf("DryRun should emit numeric width/height, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{
|
||||
"float_image_id": "fi12345678", "float_image_token": "boxToken",
|
||||
"range": "sheet1!A1:A1", "width": 200, "height": 150,
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
|
||||
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
|
||||
"--range", "sheet1!A1:A1", "--width", "200", "--height", "150",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "float_image_id") {
|
||||
t.Fatalf("stdout missing float_image_id: %s", stdout.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["float_image_token"] != "boxToken" {
|
||||
t.Fatalf("unexpected float_image_token: %v", body["float_image_token"])
|
||||
}
|
||||
if w, ok := body["width"].(float64); !ok || w != 200 {
|
||||
t.Fatalf("width should be numeric 200, got %T=%v", body["width"], body["width"])
|
||||
}
|
||||
if h, ok := body["height"].(float64); !ok || h != 150 {
|
||||
t.Fatalf("height should be numeric 150, got %T=%v", body["height"], body["height"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi12345678"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
|
||||
"+create-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
|
||||
"--range", "sheet1!A1:A1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
|
||||
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
|
||||
"--range", "sheet1!A1:A1", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateFloatImage ────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateFloatImageValidateRejectsEmptyPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Only IDs set, no mutable field: PATCH would be an empty {} body.
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "",
|
||||
}, nil, nil)
|
||||
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "specify at least one of --range") {
|
||||
t.Fatalf("expected empty-payload error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageValidateAcceptsSingleField(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Any single mutable field should satisfy the payload check.
|
||||
tests := []struct {
|
||||
name string
|
||||
strFlags map[string]string
|
||||
intFlags map[string]int
|
||||
}{
|
||||
{
|
||||
name: "range only",
|
||||
strFlags: map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "sheet1!B2:B2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "offset-x only (zero value)",
|
||||
strFlags: map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "",
|
||||
},
|
||||
intFlags: map[string]int{"offset-x": 0},
|
||||
},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil)
|
||||
if err := SheetUpdateFloatImage.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "other!A1:A1",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "",
|
||||
}, nil)
|
||||
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
|
||||
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
intFlags map[string]int
|
||||
wantSubst string
|
||||
}{
|
||||
{"width below 20", map[string]int{"width": 19}, "--width must be >= 20"},
|
||||
{"height below 20", map[string]int{"height": 0}, "--height must be >= 20"},
|
||||
{"negative offset-x", map[string]int{"offset-x": -10}, "--offset-x must be >= 0"},
|
||||
{"negative offset-y", map[string]int{"offset-y": -1}, "--offset-y must be >= 0"},
|
||||
}
|
||||
|
||||
baseStr := map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "",
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
|
||||
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
|
||||
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi12345678", "range": "sheet1!B2:B2",
|
||||
},
|
||||
map[string]int{"width": 300, "offset-y": 10}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetUpdateFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PATCH"`) {
|
||||
t.Fatalf("DryRun should use PATCH: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `fi12345678`) {
|
||||
t.Fatalf("DryRun missing float_image_id: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"width":300`) || !strings.Contains(got, `"offset_y":10`) {
|
||||
t.Fatalf("DryRun should emit numeric width/offset_y, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi123", "width": 300},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
|
||||
"+update-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123",
|
||||
"--width", "300", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi123"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
|
||||
"+update-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123",
|
||||
"--range", "sheet1!C3:C3", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetFloatImage ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetFloatImageValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "float-image-id": "fi1",
|
||||
}, nil)
|
||||
err := SheetGetFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetGetFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"GET"`) {
|
||||
t.Fatalf("DryRun should use GET: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `fi123`) {
|
||||
t.Fatalf("DryRun missing float_image_id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{
|
||||
"float_image_id": "fi123", "range": "sheet1!A1:A1", "width": 100, "height": 100,
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
|
||||
"+get-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "fi123") {
|
||||
t.Fatalf("stdout missing fi123: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi123"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
|
||||
"+get-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListFloatImages ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestListFloatImagesDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetListFloatImages.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `float_images/query`) {
|
||||
t.Fatalf("DryRun URL missing query: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFloatImagesExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"float_image_id": "fi1"},
|
||||
map[string]interface{}{"float_image_id": "fi2"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFloatImages, []string{
|
||||
"+list-float-images", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "fi1") {
|
||||
t.Fatalf("stdout missing fi1: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFloatImagesWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFloatImages, []string{
|
||||
"+list-float-images", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteFloatImage ────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetDeleteFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
|
||||
"+delete-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
|
||||
"+delete-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
172
shortcuts/sheets/sheet_media_upload.go
Normal file
172
shortcuts/sheets/sheet_media_upload.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// sheetImageParentType is the parent_type accepted by the drive media upload
|
||||
// endpoint for media that will be anchored via +create-float-image.
|
||||
const sheetImageParentType = "sheet_image"
|
||||
|
||||
// SheetMediaUpload uploads a local image to the drive media endpoint against
|
||||
// a spreadsheet and returns the file_token. The token is usable as the
|
||||
// --float-image-token argument to +create-float-image.
|
||||
//
|
||||
// Files up to 20 MB go through /drive/v1/medias/upload_all; larger files are
|
||||
// streamed via upload_prepare / upload_part / upload_finish. This matches the
|
||||
// pattern used by docs +media-upload and drive +import.
|
||||
var SheetMediaUpload = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+media-upload",
|
||||
Description: "Upload a local image for use as a floating image and return the file_token",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.media:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSheetMediaUploadParent(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
parentNode, err := resolveSheetMediaUploadParent(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
filePath := runtime.Str("file")
|
||||
fileName := filepath.Base(filePath)
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.Desc("chunked media upload (files > 20MB)").
|
||||
POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return dry.Set("spreadsheet_token", parentNode)
|
||||
}
|
||||
return dry.Desc("multipart/form-data upload").
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + filePath,
|
||||
}).
|
||||
Set("spreadsheet_token", parentNode)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
parentNode, err := resolveSheetMediaUploadParent(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := runtime.Str("file")
|
||||
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n",
|
||||
fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode))
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
fileToken, err := uploadSheetMediaFile(runtime, filePath, fileName, stat.Size(), parentNode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"size": stat.Size(),
|
||||
"spreadsheet_token": parentNode,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// resolveSheetMediaUploadParent returns the spreadsheet token to use as parent_node,
|
||||
// accepting either --url or --spreadsheet-token.
|
||||
func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if u := runtime.Str("url"); u != "" {
|
||||
if parsed := extractSpreadsheetToken(u); parsed != "" {
|
||||
token = parsed
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// uploadSheetMediaFile routes to the single-part or multipart upload path based
|
||||
// on file size. Always uses parent_type=sheet_image so the returned token can
|
||||
// be consumed by +create-float-image.
|
||||
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
pn := parentNode
|
||||
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: &pn,
|
||||
})
|
||||
}
|
||||
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
|
||||
// sheetMediaShouldUseMultipart mirrors docMediaShouldUseMultipart: dry-run uses
|
||||
// local stat as a best-effort planning hint. Execute re-validates before
|
||||
// choosing the actual upload path.
|
||||
func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
||||
}
|
||||
237
shortcuts/sheets/sheet_media_upload_test.go
Normal file
237
shortcuts/sheets/sheet_media_upload_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSheetMediaUploadValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload", "--file", "img.png", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunSmallFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
|
||||
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"sheet_image"`) {
|
||||
t.Fatalf("dry-run should include parent_type=sheet_image, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "upload_prepare") {
|
||||
t.Fatalf("dry-run should not use multipart for small file, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--url", "https://example.feishu.cn/sheets/shtFromURL?sheet=abc",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "shtFromURL") {
|
||||
t.Fatalf("dry-run should extract token from URL, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunLargeFileUsesMultipart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
// Sparse file: 20MB + 1 byte, triggers multipart path without allocating disk.
|
||||
largeFile, err := os.Create("big.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := largeFile.Truncate(20*1024*1024 + 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = largeFile.Close()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err = mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "big.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v1/medias/upload_prepare",
|
||||
"/open-apis/drive/v1/medias/upload_part",
|
||||
"/open-apis/drive/v1/medias/upload_finish",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run should include %q for large file, got: %s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "upload_all") {
|
||||
t.Fatalf("dry-run should not use upload_all for large file, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "boxTOK123"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "img.png",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("parse output: %v", err)
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data["file_token"] != "boxTOK123" {
|
||||
t.Fatalf("file_token = %v, want boxTOK123", data["file_token"])
|
||||
}
|
||||
if data["spreadsheet_token"] != "shtSTUB" {
|
||||
t.Fatalf("spreadsheet_token = %v, want shtSTUB", data["spreadsheet_token"])
|
||||
}
|
||||
|
||||
body := decodeSheetsMultipartBody(t, stub)
|
||||
if got := body.Fields["parent_type"]; got != sheetImageParentType {
|
||||
t.Fatalf("parent_type = %q, want %q", got, sheetImageParentType)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != "shtSTUB" {
|
||||
t.Fatalf("parent_node = %q, want shtSTUB", got)
|
||||
}
|
||||
if got := body.Fields["file_name"]; got != "img.png" {
|
||||
t.Fatalf("file_name = %q, want img.png", got)
|
||||
}
|
||||
if got := body.Fields["size"]; got != "9" {
|
||||
t.Fatalf("size = %q, want 9 (len of png-bytes)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadFileNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "missing.png",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") {
|
||||
t.Fatalf("err = %v, want file-not-found error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// withSheetsTestWorkingDir chdirs to dir for this test. Not compatible with
|
||||
// t.Parallel — chdir is process-wide.
|
||||
func withSheetsTestWorkingDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
}
|
||||
|
||||
type capturedSheetsMultipart struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
func decodeSheetsMultipartBody(t *testing.T, stub *httpmock.Stub) capturedSheetsMultipart {
|
||||
t.Helper()
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse content-type %q: %v", contentType, err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := capturedSheetsMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
_, _ = buf.ReadFrom(part)
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = buf.Bytes()
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = buf.String()
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -51,7 +51,7 @@ var SheetSetStyle = common.Shortcut{
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("style")), &style)
|
||||
return common.NewDryRunAPI().
|
||||
@@ -70,7 +70,7 @@ var SheetSetStyle = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
|
||||
@@ -40,5 +40,11 @@ func Shortcuts() []common.Shortcut {
|
||||
SheetUpdateDropdown,
|
||||
SheetGetDropdown,
|
||||
SheetDeleteDropdown,
|
||||
SheetMediaUpload,
|
||||
SheetCreateFloatImage,
|
||||
SheetUpdateFloatImage,
|
||||
SheetGetFloatImage,
|
||||
SheetListFloatImages,
|
||||
SheetDeleteFloatImage,
|
||||
}
|
||||
}
|
||||
|
||||
88
sidecar/hmac.go
Normal file
88
sidecar/hmac.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BodySHA256 returns the hex-encoded SHA-256 digest of body.
|
||||
// An empty or nil body produces the SHA-256 of the empty string.
|
||||
func BodySHA256(body []byte) string {
|
||||
h := sha256.Sum256(body)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// CanonicalRequest is the set of fields covered by the HMAC signature.
|
||||
// Clients and servers must populate every field identically for verification
|
||||
// to succeed; any field that is forwarded but *not* covered by this struct can
|
||||
// be tampered with inside the MaxTimestampDrift replay window without
|
||||
// invalidating the signature.
|
||||
//
|
||||
// Version must be set to a known protocol constant (ProtocolV1). It is the
|
||||
// first field in the canonical string so that a future v2 with different
|
||||
// structure cannot be confused for v1 output under the same key.
|
||||
type CanonicalRequest struct {
|
||||
Version string // e.g. ProtocolV1
|
||||
Method string // e.g. "GET", "POST"
|
||||
Host string // e.g. "open.feishu.cn"
|
||||
PathAndQuery string // e.g. "/open-apis/calendar/v4/events?page_size=50"
|
||||
BodySHA256 string // hex-encoded SHA-256 of the request body
|
||||
Timestamp string // Unix epoch seconds string
|
||||
Identity string // IdentityUser or IdentityBot
|
||||
AuthHeader string // header the server should inject the real token into
|
||||
}
|
||||
|
||||
// canonicalString joins the fields with newlines. Field order is part of the
|
||||
// protocol contract — do not reorder without bumping Version.
|
||||
func (c CanonicalRequest) canonicalString() string {
|
||||
return strings.Join([]string{
|
||||
c.Version,
|
||||
c.Method,
|
||||
c.Host,
|
||||
c.PathAndQuery,
|
||||
c.BodySHA256,
|
||||
c.Timestamp,
|
||||
c.Identity,
|
||||
c.AuthHeader,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// Sign computes the HMAC-SHA256 signature over the canonical request string.
|
||||
func Sign(key []byte, req CanonicalRequest) string {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(req.canonicalString()))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// Verify checks that signature matches the HMAC-SHA256 of the canonical
|
||||
// request and that the timestamp is within MaxTimestampDrift seconds of now.
|
||||
// Returns nil on success.
|
||||
func Verify(key []byte, req CanonicalRequest, signature string) error {
|
||||
ts, err := strconv.ParseInt(req.Timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timestamp %q: %w", req.Timestamp, err)
|
||||
}
|
||||
drift := math.Abs(float64(time.Now().Unix() - ts))
|
||||
if drift > MaxTimestampDrift {
|
||||
return fmt.Errorf("timestamp drift %.0fs exceeds limit %ds", drift, MaxTimestampDrift)
|
||||
}
|
||||
expected := Sign(key, req)
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||
return fmt.Errorf("HMAC signature mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Timestamp returns the current Unix epoch seconds as a string.
|
||||
func Timestamp() string {
|
||||
return strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
300
sidecar/hmac_test.go
Normal file
300
sidecar/hmac_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBodySHA256_Empty(t *testing.T) {
|
||||
// SHA-256 of empty string is a well-known constant.
|
||||
want := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
if got := BodySHA256(nil); got != want {
|
||||
t.Errorf("BodySHA256(nil) = %q, want %q", got, want)
|
||||
}
|
||||
if got := BodySHA256([]byte{}); got != want {
|
||||
t.Errorf("BodySHA256([]byte{}) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodySHA256_NonEmpty(t *testing.T) {
|
||||
got := BodySHA256([]byte(`{"key":"value"}`))
|
||||
if len(got) != 64 {
|
||||
t.Errorf("expected 64-char hex string, got %d chars", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// canonical is a test helper that builds a fully-populated CanonicalRequest
|
||||
// with reasonable defaults, so individual tests can override just the field
|
||||
// they want to tamper with.
|
||||
func canonical(override func(*CanonicalRequest)) CanonicalRequest {
|
||||
c := CanonicalRequest{
|
||||
Version: ProtocolV1,
|
||||
Method: "POST",
|
||||
Host: "open.feishu.cn",
|
||||
PathAndQuery: "/open-apis/im/v1/messages?receive_id_type=chat_id",
|
||||
BodySHA256: BodySHA256([]byte(`{"content":"hello"}`)),
|
||||
Timestamp: Timestamp(),
|
||||
Identity: IdentityUser,
|
||||
AuthHeader: "Authorization",
|
||||
}
|
||||
if override != nil {
|
||||
override(&c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestSignAndVerify(t *testing.T) {
|
||||
key := []byte("test-secret-key-32bytes-long!!!!!")
|
||||
req := canonical(nil)
|
||||
|
||||
sig := Sign(key, req)
|
||||
if len(sig) != 64 {
|
||||
t.Fatalf("signature should be 64-char hex, got %d chars", len(sig))
|
||||
}
|
||||
|
||||
// Valid verification
|
||||
if err := Verify(key, req, sig); err != nil {
|
||||
t.Fatalf("Verify failed for valid signature: %v", err)
|
||||
}
|
||||
|
||||
// Wrong key
|
||||
if err := Verify([]byte("wrong-key"), req, sig); err == nil {
|
||||
t.Error("Verify should fail with wrong key")
|
||||
}
|
||||
|
||||
// Each field must be covered by the signature — tampering with any one
|
||||
// invalidates it.
|
||||
fields := map[string]func(*CanonicalRequest){
|
||||
"version": func(c *CanonicalRequest) { c.Version = "v2" },
|
||||
"method": func(c *CanonicalRequest) { c.Method = "GET" },
|
||||
"host": func(c *CanonicalRequest) { c.Host = "evil.com" },
|
||||
"pathAndQuery": func(c *CanonicalRequest) { c.PathAndQuery = "/steal" },
|
||||
"bodySHA256": func(c *CanonicalRequest) { c.BodySHA256 = BodySHA256([]byte("tampered")) },
|
||||
"identity": func(c *CanonicalRequest) { c.Identity = IdentityBot },
|
||||
"authHeader": func(c *CanonicalRequest) { c.AuthHeader = "Cookie" },
|
||||
}
|
||||
for name, mutate := range fields {
|
||||
t.Run("tamper_"+name, func(t *testing.T) {
|
||||
tampered := canonical(mutate)
|
||||
if err := Verify(key, tampered, sig); err == nil {
|
||||
t.Errorf("Verify should fail when %s is tampered", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_PrivilegeConfusion proves C1: without identity and authHeader in
|
||||
// the canonical string, an attacker holding a captured user-signed request
|
||||
// could replay it as bot (or vice versa) by flipping the header. With both
|
||||
// fields now covered, such a flip must invalidate the signature.
|
||||
func TestVerify_PrivilegeConfusion(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
signed := canonical(func(c *CanonicalRequest) { c.Identity = IdentityUser })
|
||||
sig := Sign(key, signed)
|
||||
|
||||
replayed := signed
|
||||
replayed.Identity = IdentityBot // attacker flips identity
|
||||
if err := Verify(key, replayed, sig); err == nil {
|
||||
t.Error("identity flip must invalidate signature")
|
||||
}
|
||||
|
||||
replayed = signed
|
||||
replayed.AuthHeader = "Cookie" // attacker redirects injection target
|
||||
if err := Verify(key, replayed, sig); err == nil {
|
||||
t.Error("auth-header flip must invalidate signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_TimestampDrift(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
|
||||
// Timestamp too old
|
||||
oldTs := strconv.FormatInt(time.Now().Unix()-MaxTimestampDrift-10, 10)
|
||||
oldReq := canonical(func(c *CanonicalRequest) { c.Timestamp = oldTs })
|
||||
sig := Sign(key, oldReq)
|
||||
if err := Verify(key, oldReq, sig); err == nil {
|
||||
t.Error("Verify should reject expired timestamp")
|
||||
}
|
||||
|
||||
// Timestamp too far in future
|
||||
futureTs := strconv.FormatInt(time.Now().Unix()+MaxTimestampDrift+10, 10)
|
||||
futureReq := canonical(func(c *CanonicalRequest) { c.Timestamp = futureTs })
|
||||
sig = Sign(key, futureReq)
|
||||
if err := Verify(key, futureReq, sig); err == nil {
|
||||
t.Error("Verify should reject future timestamp")
|
||||
}
|
||||
|
||||
// Invalid timestamp
|
||||
badTs := canonical(func(c *CanonicalRequest) { c.Timestamp = "not-a-number" })
|
||||
if err := Verify(key, badTs, "sig"); err == nil {
|
||||
t.Error("Verify should reject invalid timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignDeterministic(t *testing.T) {
|
||||
key := []byte("key")
|
||||
req := canonical(func(c *CanonicalRequest) { c.Timestamp = "12345" })
|
||||
a, b := Sign(key, req), Sign(key, req)
|
||||
if a != b {
|
||||
t.Errorf("Sign should be deterministic: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProxyAddr(t *testing.T) {
|
||||
valid := []string{
|
||||
// loopback IPs
|
||||
"http://127.0.0.1:16384",
|
||||
"127.0.0.1:16384",
|
||||
"[::1]:16384",
|
||||
"http://[::1]:16384",
|
||||
// recognized same-host aliases
|
||||
"http://localhost:8080",
|
||||
"localhost:8080",
|
||||
"http://host.docker.internal:16384",
|
||||
"http://host.containers.internal:16384",
|
||||
"http://host.lima.internal:16384",
|
||||
"http://gateway.docker.internal:16384",
|
||||
// trailing slash is tolerated
|
||||
"http://127.0.0.1:8080/",
|
||||
}
|
||||
for _, addr := range valid {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("ValidateProxyAddr(%q) unexpected error: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
"foobar",
|
||||
"ftp://127.0.0.1:16384",
|
||||
"http://",
|
||||
"http://127.0.0.1:16384/some/path",
|
||||
":16384",
|
||||
}
|
||||
for _, addr := range invalid {
|
||||
if err := ValidateProxyAddr(addr); err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q) expected error, got nil", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_HostConstraint pins C2: the sidecar pattern is
|
||||
// same-machine by definition, so the validator rejects any host that isn't
|
||||
// loopback or a recognized same-host alias. Tampered /etc/hosts is out of
|
||||
// scope (attacker already has ambient host access).
|
||||
func TestValidateProxyAddr_HostConstraint(t *testing.T) {
|
||||
sameHost := []string{
|
||||
"http://127.0.0.1:16384",
|
||||
"http://localhost:8080",
|
||||
"http://host.docker.internal:16384",
|
||||
"http://host.containers.internal:16384",
|
||||
"http://host.lima.internal:16384",
|
||||
"http://gateway.docker.internal:16384",
|
||||
"http://[::1]:16384",
|
||||
// bare form
|
||||
"127.0.0.1:16384",
|
||||
"localhost:8080",
|
||||
"host.docker.internal:16384",
|
||||
}
|
||||
for _, addr := range sameHost {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("expected %q to pass as same-host, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
notSameHost := map[string]string{
|
||||
// The interesting ones — plausible misconfigurations / attacks
|
||||
"public DNS name": "http://attacker.com:8080",
|
||||
"cloud metadata IMDS": "http://169.254.169.254",
|
||||
"private RFC1918": "http://10.0.0.1:16384",
|
||||
"other RFC1918": "http://192.168.1.2:16384",
|
||||
"link-local IPv4": "http://169.254.1.1:16384",
|
||||
"unspecified IPv4 (0.0.0.0)": "http://0.0.0.0:16384",
|
||||
"bare public IP": "http://8.8.8.8:16384",
|
||||
"bare RFC1918": "10.0.0.1:16384",
|
||||
}
|
||||
for name, addr := range notSameHost {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection for %q", addr)
|
||||
}
|
||||
// Error must name the constraint so users know why.
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "loopback") && !strings.Contains(msg, "same-host") {
|
||||
t.Errorf("error should explain same-host requirement, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_RejectsUserinfo closes the URL-phishing vector
|
||||
// http://127.0.0.1@attacker.com (where "127.0.0.1" is actually basic-auth
|
||||
// userinfo and the real host is attacker.com). userinfo has no legitimate
|
||||
// use in the sidecar protocol.
|
||||
func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"http://user@127.0.0.1:16384",
|
||||
"http://user:pass@127.0.0.1:16384",
|
||||
"http://127.0.0.1@attacker.com:16384",
|
||||
} {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected rejection, got nil", addr)
|
||||
continue
|
||||
}
|
||||
// Either "userinfo" (for addresses parsed with user) or the same-host
|
||||
// message (for e.g. http://127.0.0.1@attacker.com where the REAL
|
||||
// host parses as attacker.com) is acceptable — both reject the
|
||||
// phishing attempt.
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "userinfo") && !strings.Contains(msg, "same-host") && !strings.Contains(msg, "loopback") {
|
||||
t.Errorf("error should reject userinfo or flag wrong host, got: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_HTTPSRejected pins the current contract: https is
|
||||
// rejected explicitly (not lumped into a generic "bad scheme" error) because
|
||||
// the interceptor hardcodes http and would silently downgrade an https URL
|
||||
// otherwise. The message must mention https so users understand why their
|
||||
// perfectly-looking config is refused.
|
||||
func TestValidateProxyAddr_HTTPSRejected(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"https://127.0.0.1:16384",
|
||||
"https://sidecar.corp.internal:443",
|
||||
} {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected error, got nil", addr)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "https") {
|
||||
t.Errorf("ValidateProxyAddr(%q): error should mention https, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"http://127.0.0.1:16384", "127.0.0.1:16384"},
|
||||
{"http://0.0.0.0:8080", "0.0.0.0:8080"},
|
||||
{"http://host.docker.internal:16384/", "host.docker.internal:16384"},
|
||||
{"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if got := ProxyHost(tt.input); got != tt.want {
|
||||
t.Errorf("ProxyHost(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
198
sidecar/protocol.go
Normal file
198
sidecar/protocol.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package sidecar defines the wire protocol shared between the CLI client
|
||||
// (running inside a sandbox) and the auth sidecar proxy (running in a
|
||||
// trusted environment). Communication uses plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProtocolV1 is the wire-protocol version string embedded in every signed
|
||||
// request. Servers must reject requests whose HeaderProxyVersion is not a
|
||||
// version they understand. Bump this constant (and update the canonical
|
||||
// string) for any breaking change to signing inputs.
|
||||
const ProtocolV1 = "v1"
|
||||
|
||||
// Proxy request headers set by the CLI transport interceptor.
|
||||
const (
|
||||
// HeaderProxyVersion carries the wire-protocol version (e.g. ProtocolV1).
|
||||
// Servers must reject requests whose version they do not understand. The
|
||||
// value is also included in the canonical signing string so that a request
|
||||
// signed for one version cannot be replayed as another.
|
||||
HeaderProxyVersion = "X-Lark-Proxy-Version"
|
||||
|
||||
// HeaderProxyTarget carries the original request host (e.g. "open.feishu.cn").
|
||||
HeaderProxyTarget = "X-Lark-Proxy-Target"
|
||||
|
||||
// HeaderProxyIdentity carries the resolved identity type ("user" or "bot").
|
||||
HeaderProxyIdentity = "X-Lark-Proxy-Identity"
|
||||
|
||||
// HeaderProxySignature carries the HMAC-SHA256 hex signature.
|
||||
HeaderProxySignature = "X-Lark-Proxy-Signature"
|
||||
|
||||
// HeaderProxyTimestamp carries the Unix epoch seconds string used in signing.
|
||||
HeaderProxyTimestamp = "X-Lark-Proxy-Timestamp"
|
||||
|
||||
// HeaderBodySHA256 carries the hex-encoded SHA-256 digest of the request body.
|
||||
HeaderBodySHA256 = "X-Lark-Body-SHA256"
|
||||
|
||||
// HeaderProxyAuthHeader tells the sidecar which header to inject the real
|
||||
// token into. Defaults to "Authorization" for standard OpenAPI requests.
|
||||
// MCP requests use "X-Lark-MCP-UAT" or "X-Lark-MCP-TAT".
|
||||
HeaderProxyAuthHeader = "X-Lark-Proxy-Auth-Header"
|
||||
)
|
||||
|
||||
// MCP auth headers used by the Lark MCP protocol.
|
||||
const (
|
||||
HeaderMCPUAT = "X-Lark-MCP-UAT"
|
||||
HeaderMCPTAT = "X-Lark-MCP-TAT"
|
||||
)
|
||||
|
||||
// Sentinel token values returned by the noop credential provider.
|
||||
// These are placeholder strings that flow through the SDK auth pipeline
|
||||
// but are stripped by the transport interceptor before reaching the sidecar.
|
||||
const (
|
||||
SentinelUAT = "sidecar-managed-uat" // User Access Token placeholder
|
||||
SentinelTAT = "sidecar-managed-tat" // Tenant Access Token placeholder
|
||||
)
|
||||
|
||||
// IdentityUser and IdentityBot are the wire values for HeaderProxyIdentity.
|
||||
const (
|
||||
IdentityUser = "user"
|
||||
IdentityBot = "bot"
|
||||
)
|
||||
|
||||
// MaxTimestampDrift is the maximum allowed difference (in seconds) between
|
||||
// the request timestamp and the server's current time.
|
||||
const MaxTimestampDrift = 60
|
||||
|
||||
// DefaultListenAddr is the default sidecar listen address (localhost only).
|
||||
const DefaultListenAddr = "127.0.0.1:16384"
|
||||
|
||||
// sameHostAliases names DNS aliases commonly used to reach the host running
|
||||
// the sandbox across a container / VM boundary. Traffic to these names stays
|
||||
// on the physical machine (via a virtual bridge), so a plaintext sidecar
|
||||
// channel still satisfies the sidecar pattern's same-host confidentiality
|
||||
// requirement. Adding to this list has real security implications — only add
|
||||
// names that are universally same-host by the runtime's design.
|
||||
var sameHostAliases = map[string]bool{
|
||||
"localhost": true, // universal
|
||||
"host.docker.internal": true, // Docker Desktop (macOS / Windows)
|
||||
"host.containers.internal": true, // Podman Desktop
|
||||
"host.lima.internal": true, // Lima / colima / rancher-desktop
|
||||
"gateway.docker.internal": true, // Docker Desktop alt name
|
||||
}
|
||||
|
||||
// isSameHost returns true when host is either a loopback IP or a recognized
|
||||
// same-host DNS alias. Does not perform DNS resolution — a tampered /etc/hosts
|
||||
// that points an alias elsewhere is out of scope (attacker with that access
|
||||
// already has ambient control of the machine).
|
||||
func isSameHost(host string) bool {
|
||||
if sameHostAliases[host] {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// errNotSameHost is the shared error returned when the sidecar address does
|
||||
// not resolve to the same physical host as the sandbox. Kept in one place so
|
||||
// tests can look for a stable marker.
|
||||
func errNotSameHost(addr string) error {
|
||||
return fmt.Errorf("invalid proxy address %q: host must be loopback "+
|
||||
"(127.0.0.1 / ::1) or a recognized same-host alias "+
|
||||
"(localhost, host.docker.internal, host.containers.internal, "+
|
||||
"host.lima.internal, gateway.docker.internal). "+
|
||||
"The sidecar must run on the same physical machine as the sandbox — "+
|
||||
"cross-machine deployment is not a sidecar and is not supported", addr)
|
||||
}
|
||||
|
||||
// ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value.
|
||||
// Accepted formats:
|
||||
// - http://host:port
|
||||
// - host:port (bare address, treated as http)
|
||||
//
|
||||
// Host must be loopback or in sameHostAliases. The sidecar pattern is
|
||||
// inherently same-machine; cross-machine deployment is a different product
|
||||
// and is not supported by this feature.
|
||||
//
|
||||
// https:// is rejected because sidecar is a same-host pattern: loopback
|
||||
// and virtual same-host bridges don't traverse any untrusted medium, so
|
||||
// TLS adds no security. Cross-machine deployment is out of scope (see the
|
||||
// host constraint above), so there is no scenario today where https
|
||||
// provides a real benefit over http on loopback.
|
||||
//
|
||||
// userinfo (user:pass@) is rejected unconditionally — the sidecar protocol
|
||||
// does not use basic auth, and the syntactic slot exists only as a phishing
|
||||
// vector (e.g. http://127.0.0.1@attacker.com).
|
||||
//
|
||||
// Returns an error if the value is not a valid proxy address.
|
||||
func ValidateProxyAddr(addr string) error {
|
||||
if addr == "" {
|
||||
return fmt.Errorf("proxy address is empty")
|
||||
}
|
||||
|
||||
// Bare host:port (no scheme) — validate as a net address.
|
||||
if !strings.Contains(addr, "://") {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: expected host:port or http://host:port", addr)
|
||||
}
|
||||
if host == "" || port == "" {
|
||||
return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr)
|
||||
}
|
||||
if !isSameHost(host) {
|
||||
return errNotSameHost(addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: %w", addr, err)
|
||||
}
|
||||
if u.User != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr)
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return fmt.Errorf("invalid proxy address %q: use http:// — sidecar is "+
|
||||
"same-host only (loopback or virtual same-host bridge), so TLS adds "+
|
||||
"no security; cross-machine deployment is out of scope", addr)
|
||||
}
|
||||
if u.Scheme != "http" {
|
||||
return fmt.Errorf("invalid proxy address %q: scheme must be http", addr)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("invalid proxy address %q: missing host", addr)
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return fmt.Errorf("invalid proxy address %q: path is not allowed", addr)
|
||||
}
|
||||
// u.Hostname() strips the port and unwraps IPv6 brackets.
|
||||
if !isSameHost(u.Hostname()) {
|
||||
return errNotSameHost(addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProxyHost extracts the host:port from an AUTH_PROXY URL.
|
||||
// Input is expected to be an HTTP URL like "http://127.0.0.1:16384".
|
||||
// Returns the host:port portion for URL rewriting.
|
||||
func ProxyHost(authProxy string) string {
|
||||
// Strip scheme
|
||||
host := authProxy
|
||||
if i := strings.Index(host, "://"); i >= 0 {
|
||||
host = host[i+3:]
|
||||
}
|
||||
// Strip trailing slash
|
||||
host = strings.TrimRight(host, "/")
|
||||
return host
|
||||
}
|
||||
197
sidecar/server-demo/README.md
Normal file
197
sidecar/server-demo/README.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Sidecar Server Reference Implementation
|
||||
|
||||
> ⚠️ **This is a demo.** For production deployment, implement your own sidecar
|
||||
> server conforming to the wire protocol in `github.com/larksuite/cli/sidecar`.
|
||||
|
||||
This example shows how to implement a sidecar auth proxy server that receives
|
||||
HMAC-signed requests from lark-cli sandbox clients and forwards them to the
|
||||
Lark/Feishu API with real credentials injected.
|
||||
|
||||
## What this demo shows
|
||||
|
||||
- HMAC-SHA256 request verification (timestamp drift, body digest, signature)
|
||||
- Target host allowlist + https-only target validation (anti-SSRF / anti-downgrade)
|
||||
- Identity-based token resolution (UAT for user, TAT for bot)
|
||||
- Auth-header allowlist: real token may only be injected into `Authorization`
|
||||
/ `X-Lark-MCP-UAT` / `X-Lark-MCP-TAT`, rejecting attempts to smuggle it into
|
||||
`Cookie`, `User-Agent`, or other intermediate-logged headers
|
||||
- Audit logging with path ID-segment sanitization and upstream error truncation
|
||||
- Safe request forwarding (strips client-supplied auth headers)
|
||||
|
||||
## What this demo does NOT handle
|
||||
|
||||
- **TAT refresh** — the shared `DefaultTokenProvider` caches the TAT via
|
||||
`sync.Once`, which never refreshes. A long-running server will return an
|
||||
expired TAT after 2 hours. Production implementations should maintain a
|
||||
TTL-based cache with early renewal.
|
||||
- High availability / load balancing / hot key rotation
|
||||
- TLS termination
|
||||
- Rate limiting / per-identity quotas
|
||||
|
||||
## Both sides need the right build tags
|
||||
|
||||
Sidecar is split into **two separate binaries** with **different build tags**:
|
||||
|
||||
| Side | Binary | Build tag | How to build |
|
||||
| --- | --- | --- | --- |
|
||||
| Sandbox (client) | `lark-cli` | `authsidecar` | `go build -tags authsidecar -o lark-cli .` |
|
||||
| Trusted (server) | `sidecar-server-demo` | `authsidecar_demo` | `go build -tags authsidecar_demo -o sidecar-server-demo ./sidecar/server-demo/` |
|
||||
|
||||
If the sandbox runs a standard `lark-cli` **without** `-tags authsidecar`, the
|
||||
`LARKSUITE_CLI_AUTH_PROXY` env var is ignored and requests bypass the sidecar
|
||||
entirely — real credentials (if any) leak to the sandbox.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The demo reuses the lark-cli credential pipeline, so the trusted machine must
|
||||
have an app configured:
|
||||
|
||||
```bash
|
||||
lark-cli config init --new # configure app_id / app_secret (required)
|
||||
lark-cli auth login # store user refresh_token in keychain
|
||||
# (only required if sandbox will use --as user)
|
||||
```
|
||||
|
||||
`auth login` is **only required for user identity**. If the server will only
|
||||
serve bot requests (TAT), `config init` alone is enough because the TAT is
|
||||
minted from `app_id + app_secret`.
|
||||
|
||||
Also, the server process **must not** inherit `LARKSUITE_CLI_AUTH_PROXY` — if
|
||||
it does, the sidecar credential provider would activate inside the server and
|
||||
return sentinel tokens instead of real ones. The demo rejects this at startup
|
||||
with a clear error, but you should make sure to `unset LARKSUITE_CLI_AUTH_PROXY`
|
||||
in the server shell before launching.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
./sidecar-server-demo \
|
||||
--listen 127.0.0.1:16384 \
|
||||
--key-file <HOME>/.lark-sidecar/proxy.key \
|
||||
--log-file <HOME>/.lark-sidecar/audit.log
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `--listen` | `127.0.0.1:16384` | Address to bind the HTTP listener |
|
||||
| `--key-file` | `<HOME>/.lark-sidecar/proxy.key` | Path to write the generated HMAC key (mode 0600) |
|
||||
| `--log-file` | *(empty, stderr)* | Audit log output path |
|
||||
| `--profile` | *(empty, active profile)* | lark-cli profile name for credential lookup |
|
||||
|
||||
### Startup output
|
||||
|
||||
```
|
||||
Auth sidecar listening on http://127.0.0.1:16384
|
||||
HMAC key prefix: a3b2c1d4
|
||||
Full key written to /Users/alice/.lark-sidecar/proxy.key (mode 0600)
|
||||
|
||||
Set in sandbox:
|
||||
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384"
|
||||
export LARKSUITE_CLI_PROXY_KEY="<read from /Users/alice/.lark-sidecar/proxy.key>"
|
||||
export LARKSUITE_CLI_APP_ID="cli_xxx"
|
||||
export LARKSUITE_CLI_BRAND="feishu"
|
||||
```
|
||||
|
||||
The `key-file` path is printed exactly as passed on the command line (relative
|
||||
paths stay relative). The `HMAC key prefix` is the first 8 characters for
|
||||
identification without revealing the full key.
|
||||
|
||||
### Sandbox env vars (complete list)
|
||||
|
||||
The startup banner only prints the *required* variables. Two more are
|
||||
optional:
|
||||
|
||||
```bash
|
||||
export LARKSUITE_CLI_AUTH_PROXY="http://..." # required (see constraints below)
|
||||
export LARKSUITE_CLI_PROXY_KEY="..." # required
|
||||
export LARKSUITE_CLI_APP_ID="cli_xxx" # required
|
||||
export LARKSUITE_CLI_BRAND="feishu" # required (feishu | lark)
|
||||
export LARKSUITE_CLI_DEFAULT_AS="user" # optional: force default identity
|
||||
export LARKSUITE_CLI_STRICT_MODE="user" # optional: lock sandbox to one identity
|
||||
```
|
||||
|
||||
**`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup:
|
||||
|
||||
- Scheme must be `http://` (or bare `host:port`). `https://` is rejected
|
||||
today because the interceptor does not yet perform TLS; a future PR that
|
||||
wires up real TLS will relax this.
|
||||
- Host must be loopback (`127.0.0.1`, `::1`) or one of the recognized
|
||||
same-host aliases: `localhost`, `host.docker.internal`,
|
||||
`host.containers.internal`, `host.lima.internal`, `gateway.docker.internal`.
|
||||
The sidecar pattern is inherently same-machine; cross-machine deployment
|
||||
is a different product (auth broker / STS) with different security
|
||||
requirements (mTLS, cert rotation, per-client keys) and is not supported
|
||||
by this feature.
|
||||
- No path, query, fragment, or `user:pass@` in the URL.
|
||||
|
||||
**How auto identity detection works in sidecar mode**: on every invocation the
|
||||
CLI asks the sidecar to look up the logged-in user's `open_id` via
|
||||
`/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`;
|
||||
if it fails (trusted side has no valid user login, or the call errors out),
|
||||
it falls back to `bot`. Setting `LARKSUITE_CLI_DEFAULT_AS=user` lets you
|
||||
short-circuit this and always default to user regardless of the lookup
|
||||
result; set it to `bot` for the opposite.
|
||||
|
||||
**Note**: `LARKSUITE_CLI_STRICT_MODE` and the server's identity allowlist are
|
||||
two separate enforcement points:
|
||||
- `STRICT_MODE` is interpreted locally by the sandbox CLI — it rejects
|
||||
`--as` values the sandbox itself disallows, before any request goes out.
|
||||
- The server's allowlist is built from the **trusted-side** config's
|
||||
`SupportedIdentities` (`sidecar/server-demo/allowlist.go`). The sandbox
|
||||
cannot override it.
|
||||
|
||||
A well-configured deployment aligns both (e.g. both set to `user` when the
|
||||
app only supports user tokens), but they are computed independently.
|
||||
|
||||
### Graceful shutdown
|
||||
|
||||
Send `SIGINT` (`Ctrl+C`) or `SIGTERM` to stop the server. The demo drains
|
||||
in-flight requests with a 5-second timeout before exiting.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
See the [`sidecar` package on pkg.go.dev](https://pkg.go.dev/github.com/larksuite/cli/sidecar)
|
||||
for protocol constants, HMAC signing/verification, and address validation utilities.
|
||||
|
||||
Headers (client → server):
|
||||
|
||||
| Header | Purpose |
|
||||
| --- | --- |
|
||||
| `X-Lark-Proxy-Version` | Wire-protocol version (currently `"v1"`). Server rejects unknown values with 400. |
|
||||
| `X-Lark-Proxy-Target` | Original target **scheme + host only** (e.g. `https://open.feishu.cn`). Must be `https://`; any path/query/fragment/userinfo in this header is rejected. The path and query come from the request line itself; the server reconstructs the upstream URL as `https://<host> + requestURI`. |
|
||||
| `X-Lark-Proxy-Identity` | `"user"` or `"bot"`. Covered by the signature. |
|
||||
| `X-Lark-Proxy-Auth-Header` | Which header the server should inject real token into. Covered by the signature. |
|
||||
| `X-Lark-Proxy-Signature` | hex-encoded HMAC-SHA256 |
|
||||
| `X-Lark-Proxy-Timestamp` | Unix seconds (drift ≤ 60s) |
|
||||
| `X-Lark-Body-SHA256` | hex-encoded SHA-256 of the request body |
|
||||
|
||||
Signing material (newline-separated, in order):
|
||||
|
||||
```text
|
||||
version
|
||||
method
|
||||
host
|
||||
pathAndQuery
|
||||
bodySHA256
|
||||
timestamp
|
||||
identity
|
||||
authHeader
|
||||
```
|
||||
|
||||
Every field above is part of the canonical string. In particular, `identity`
|
||||
and `authHeader` are covered so a captured request cannot be replayed with
|
||||
its identity flipped (bot↔user) or its auth-header redirected (e.g. into
|
||||
`Cookie`) inside the 60s drift window.
|
||||
|
||||
## Source layout
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `main.go` | Entry point: flag parsing, server lifecycle |
|
||||
| `handler.go` | `proxyHandler.ServeHTTP` — main request flow |
|
||||
| `forward.go` | Forwarding HTTP client + proxy-header filter |
|
||||
| `allowlist.go` | Target host / identity allowlists |
|
||||
| `audit.go` | Log path/error sanitization |
|
||||
| `handler_test.go` | Unit tests for all of the above |
|
||||
44
sidecar/server-demo/allowlist.go
Normal file
44
sidecar/server-demo/allowlist.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// buildAllowedHosts extracts the set of allowed target hostnames from
|
||||
// multiple brand endpoints so the sidecar can serve both feishu and lark clients.
|
||||
func buildAllowedHosts(endpoints ...core.Endpoints) map[string]bool {
|
||||
hosts := make(map[string]bool)
|
||||
for _, ep := range endpoints {
|
||||
for _, u := range []string{ep.Open, ep.Accounts, ep.MCP} {
|
||||
if idx := strings.Index(u, "://"); idx >= 0 {
|
||||
hosts[u[idx+3:]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
// buildAllowedIdentities returns the set of identities the sidecar is allowed to serve,
|
||||
// based on the trusted-side strict mode / SupportedIdentities configuration.
|
||||
func buildAllowedIdentities(cfg *core.CliConfig) map[string]bool {
|
||||
ids := make(map[string]bool)
|
||||
switch {
|
||||
case cfg.SupportedIdentities == 0: // unknown/unset → allow both
|
||||
ids[sidecar.IdentityUser] = true
|
||||
ids[sidecar.IdentityBot] = true
|
||||
case cfg.SupportedIdentities&1 != 0: // SupportsUser bit
|
||||
ids[sidecar.IdentityUser] = true
|
||||
}
|
||||
if cfg.SupportedIdentities == 0 || cfg.SupportedIdentities&2 != 0 { // SupportsBot bit
|
||||
ids[sidecar.IdentityBot] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
51
sidecar/server-demo/audit.go
Normal file
51
sidecar/server-demo/audit.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizePath strips query parameters and replaces ID-like path segments
|
||||
// with ":id" to prevent document tokens, chat IDs, etc. from leaking into logs.
|
||||
// Example: /open-apis/docx/v1/documents/doxcnXXXX/blocks → /open-apis/docx/v1/documents/:id/blocks
|
||||
func sanitizePath(pathAndQuery string) string {
|
||||
// Strip query
|
||||
path := pathAndQuery
|
||||
if i := strings.IndexByte(path, '?'); i >= 0 {
|
||||
path = path[:i]
|
||||
}
|
||||
// Replace ID-like segments (8+ chars, not a pure API keyword)
|
||||
parts := strings.Split(path, "/")
|
||||
for i, p := range parts {
|
||||
if looksLikeID(p) {
|
||||
parts[i] = ":id"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// looksLikeID returns true if a path segment appears to be a resource identifier
|
||||
// rather than an API route keyword. Heuristic: 8+ chars and contains a digit.
|
||||
func looksLikeID(seg string) bool {
|
||||
if len(seg) < 8 {
|
||||
return false
|
||||
}
|
||||
for _, c := range seg {
|
||||
if c >= '0' && c <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sanitizeError returns a safe error string for logging, capped at 200 bytes
|
||||
// to avoid dumping upstream response bodies into audit logs.
|
||||
func sanitizeError(err error) string {
|
||||
s := err.Error()
|
||||
if len(s) > 200 {
|
||||
return s[:200] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
51
sidecar/server-demo/forward.go
Normal file
51
sidecar/server-demo/forward.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// newForwardClient creates an HTTP client for forwarding requests to the
|
||||
// Lark API. It strips Authorization on cross-host redirects and disables
|
||||
// proxy to prevent real tokens from leaking through environment proxies.
|
||||
func newForwardClient() *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil // never proxy the trusted hop
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del(sidecar.HeaderMCPUAT)
|
||||
req.Header.Del(sidecar.HeaderMCPTAT)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// isProxyHeader returns true for headers specific to the sidecar protocol.
|
||||
func isProxyHeader(key string) bool {
|
||||
switch http.CanonicalHeaderKey(key) {
|
||||
case http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
271
sidecar/server-demo/handler.go
Normal file
271
sidecar/server-demo/handler.go
Normal file
@@ -0,0 +1,271 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// proxyHandler handles HTTP requests from sandbox CLI instances.
|
||||
type proxyHandler struct {
|
||||
key []byte
|
||||
cred *credential.CredentialProvider
|
||||
appID string
|
||||
brand core.LarkBrand
|
||||
logger *log.Logger
|
||||
forwardCl *http.Client
|
||||
allowedHosts map[string]bool // target host allowlist derived from brand
|
||||
allowedIDs map[string]bool // identity allowlist derived from strict mode
|
||||
}
|
||||
|
||||
// allowedAuthHeaders lists the only header names the sidecar will inject real
|
||||
// tokens into. Limiting this prevents a compromised sandbox from signing a
|
||||
// request with X-Lark-Proxy-Auth-Header: Cookie (or User-Agent /
|
||||
// X-Forwarded-For / any X-* header) and having the real token smuggled into
|
||||
// an upstream header that Lark ignores for auth but intermediate logs may
|
||||
// capture — an indirect exfiltration path.
|
||||
//
|
||||
// These three are the only values the CLI interceptor ever emits
|
||||
// (Authorization for OpenAPI, MCP-UAT/TAT for the MCP protocol), so anything
|
||||
// else is by definition a misuse.
|
||||
var allowedAuthHeaders = map[string]bool{
|
||||
"Authorization": true,
|
||||
sidecar.HeaderMCPUAT: true, // X-Lark-MCP-UAT
|
||||
sidecar.HeaderMCPTAT: true, // X-Lark-MCP-TAT
|
||||
}
|
||||
|
||||
func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// 0. Check protocol version. We reject rather than default so that an
|
||||
// old client paired with a newer server (or vice versa) fails loudly
|
||||
// instead of silently producing mismatched signatures.
|
||||
version := r.Header.Get(sidecar.HeaderProxyVersion)
|
||||
if version != sidecar.ProtocolV1 {
|
||||
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Verify timestamp
|
||||
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
|
||||
if ts == "" {
|
||||
http.Error(w, "missing "+sidecar.HeaderProxyTimestamp, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Read body and verify SHA256
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body.Close()
|
||||
|
||||
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
|
||||
if claimedSHA == "" {
|
||||
http.Error(w, "missing "+sidecar.HeaderBodySHA256, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
actualSHA := sidecar.BodySHA256(body)
|
||||
if claimedSHA != actualSHA {
|
||||
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Verify HMAC signature
|
||||
//Enforce scheme=https and reject any path/query embedded in the target.
|
||||
// The sandbox is untrusted: without this check it could send
|
||||
// X-Lark-Proxy-Target: http://open.feishu.cn to force the injected real
|
||||
// token out over cleartext HTTP, exposing it to any on-path attacker
|
||||
// between the sidecar and upstream.
|
||||
target := r.Header.Get(sidecar.HeaderProxyTarget)
|
||||
if target == "" {
|
||||
http.Error(w, "missing "+sidecar.HeaderProxyTarget, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pathAndQuery := r.URL.RequestURI()
|
||||
targetHost, err := parseTarget(target)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+err.Error(), http.StatusForbidden)
|
||||
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Identity and auth-header must be read before HMAC verification because
|
||||
// both are covered by the canonical signing string. Defaulting either one
|
||||
// server-side would let an attacker flip the injected token's identity or
|
||||
// target header within the replay window without invalidating the sig.
|
||||
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
|
||||
if identity == "" {
|
||||
http.Error(w, "missing "+sidecar.HeaderProxyIdentity, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
authHeader := r.Header.Get(sidecar.HeaderProxyAuthHeader)
|
||||
if authHeader == "" {
|
||||
http.Error(w, "missing "+sidecar.HeaderProxyAuthHeader, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
signature := r.Header.Get(sidecar.HeaderProxySignature)
|
||||
if err := sidecar.Verify(h.key, sidecar.CanonicalRequest{
|
||||
Version: version,
|
||||
Method: r.Method,
|
||||
Host: targetHost,
|
||||
PathAndQuery: pathAndQuery,
|
||||
BodySHA256: claimedSHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
}, signature); err != nil {
|
||||
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
|
||||
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Validate target host against allowlist
|
||||
if !h.allowedHosts[targetHost] {
|
||||
http.Error(w, "target host not allowed: "+targetHost, http.StatusForbidden)
|
||||
h.logger.Printf("REJECT method=%s path=%s reason=\"target host %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), targetHost)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Validate identity
|
||||
if !h.allowedIDs[identity] {
|
||||
http.Error(w, "identity not allowed: "+identity, http.StatusForbidden)
|
||||
h.logger.Printf("REJECT method=%s path=%s reason=\"identity %s not allowed by strict mode\"", r.Method, sanitizePath(pathAndQuery), identity)
|
||||
return
|
||||
}
|
||||
|
||||
// 5.5 Validate auth-header (required — the client controls this value,
|
||||
// and without an allowlist a compromised sandbox could direct the real
|
||||
// token into arbitrary forwarded headers).
|
||||
if !allowedAuthHeaders[authHeader] {
|
||||
http.Error(w, "auth-header not allowed: "+authHeader, http.StatusForbidden)
|
||||
h.logger.Printf("REJECT method=%s path=%s reason=\"auth-header %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), authHeader)
|
||||
return
|
||||
}
|
||||
|
||||
// 6. Resolve real token
|
||||
var tokenType credential.TokenType
|
||||
switch identity {
|
||||
case sidecar.IdentityUser:
|
||||
tokenType = credential.TokenTypeUAT
|
||||
default:
|
||||
tokenType = credential.TokenTypeTAT
|
||||
}
|
||||
|
||||
tokenResult, err := h.cred.ResolveToken(r.Context(), credential.TokenSpec{
|
||||
Type: tokenType,
|
||||
AppID: h.appID,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "failed to resolve token: "+err.Error(), http.StatusInternalServerError)
|
||||
h.logger.Printf("TOKEN_ERROR method=%s path=%s identity=%s error=%q", r.Method, sanitizePath(pathAndQuery), identity, sanitizeError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 7. Build forwarding request. Scheme is pinned to https here (not taken
|
||||
// from the client-supplied target) so any future change to parseTarget
|
||||
// cannot regress the cleartext-leak protection.
|
||||
forwardURL := "https://" + targetHost + pathAndQuery
|
||||
forwardReq, err := http.NewRequestWithContext(r.Context(), r.Method, forwardURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to create forward request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Copy non-proxy headers
|
||||
for k, vs := range r.Header {
|
||||
if isProxyHeader(k) {
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
forwardReq.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Strip any client-supplied auth headers. The sidecar is the sole source
|
||||
// of authentication material on the forwarded request; a client could
|
||||
// otherwise smuggle an extra Authorization/MCP token alongside the one
|
||||
// the sidecar injects below.
|
||||
forwardReq.Header.Del("Authorization")
|
||||
forwardReq.Header.Del(sidecar.HeaderMCPUAT)
|
||||
forwardReq.Header.Del(sidecar.HeaderMCPTAT)
|
||||
|
||||
// 8. Inject real token into the header the client committed to in the
|
||||
// signature. Standard OpenAPI uses "Authorization: Bearer <token>"; MCP
|
||||
// uses "X-Lark-MCP-UAT: <token>" or "X-Lark-MCP-TAT: <token>".
|
||||
if authHeader == "Authorization" {
|
||||
forwardReq.Header.Set("Authorization", "Bearer "+tokenResult.Token)
|
||||
} else {
|
||||
forwardReq.Header.Set(authHeader, tokenResult.Token)
|
||||
}
|
||||
|
||||
// 9. Forward request
|
||||
resp, err := h.forwardCl.Do(forwardReq)
|
||||
if err != nil {
|
||||
http.Error(w, "forward request failed: "+err.Error(), http.StatusBadGateway)
|
||||
h.logger.Printf("FORWARD_ERROR method=%s path=%s error=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 10. Copy response back
|
||||
for k, vs := range resp.Header {
|
||||
for _, v := range vs {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
|
||||
// 11. Audit log
|
||||
h.logger.Printf("FORWARD method=%s path=%s identity=%s status=%d duration=%s",
|
||||
r.Method, sanitizePath(pathAndQuery), identity, resp.StatusCode, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// parseTarget validates X-Lark-Proxy-Target and returns the host portion for
|
||||
// HMAC input and allowlist lookup. The target must be "https://<host>" with no
|
||||
// path, query, fragment, userinfo, or non-https scheme. Rejecting these shapes
|
||||
// closes a token-leak channel: a compromised sandbox holding PROXY_KEY could
|
||||
// otherwise request cleartext HTTP forwarding (or inject a path to a different
|
||||
// endpoint than the allowlist entry implies).
|
||||
func parseTarget(target string) (host string, err error) {
|
||||
u, perr := url.Parse(target)
|
||||
if perr != nil {
|
||||
return "", fmt.Errorf("parse: %w", perr)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", fmt.Errorf("missing host")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("userinfo not allowed")
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return "", fmt.Errorf("query not allowed")
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return "", fmt.Errorf("fragment not allowed")
|
||||
}
|
||||
return u.Host, nil
|
||||
}
|
||||
670
sidecar/server-demo/handler_test.go
Normal file
670
sidecar/server-demo/handler_test.go
Normal file
@@ -0,0 +1,670 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// fakeExtProvider is a stub extcred.Provider for tests that returns a fixed token.
|
||||
type fakeExtProvider struct {
|
||||
token string
|
||||
}
|
||||
|
||||
func (f *fakeExtProvider) Name() string { return "fake" }
|
||||
func (f *fakeExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return &extcred.Token{Value: f.token, Source: "fake"}, nil
|
||||
}
|
||||
|
||||
func discardLogger() *log.Logger {
|
||||
return log.New(io.Discard, "", 0)
|
||||
}
|
||||
|
||||
func newTestHandler(key []byte) *proxyHandler {
|
||||
return &proxyHandler{
|
||||
key: key,
|
||||
logger: discardLogger(),
|
||||
forwardCl: &http.Client{},
|
||||
allowedHosts: map[string]bool{
|
||||
"open.feishu.cn": true,
|
||||
"accounts.feishu.cn": true,
|
||||
"mcp.feishu.cn": true,
|
||||
},
|
||||
allowedIDs: map[string]bool{
|
||||
sidecar.IdentityUser: true,
|
||||
sidecar.IdentityBot: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// signedReq creates a properly signed request for testing handler logic past
|
||||
// HMAC verification. Identity defaults to bot and auth-header to
|
||||
// "Authorization"; callers can override by mutating the returned request
|
||||
// before calling ServeHTTP (and re-signing if they need the signature to
|
||||
// remain valid after the mutation).
|
||||
func signedReq(t *testing.T, key []byte, method, target, path string, body []byte) *http.Request {
|
||||
t.Helper()
|
||||
targetHost := target
|
||||
if idx := strings.Index(target, "://"); idx >= 0 {
|
||||
targetHost = target[idx+3:]
|
||||
}
|
||||
bodySHA := sidecar.BodySHA256(body)
|
||||
ts := sidecar.Timestamp()
|
||||
identity := sidecar.IdentityBot
|
||||
authHeader := "Authorization"
|
||||
sig := sidecar.Sign(key, sidecar.CanonicalRequest{
|
||||
Version: sidecar.ProtocolV1,
|
||||
Method: method,
|
||||
Host: targetHost,
|
||||
PathAndQuery: path,
|
||||
BodySHA256: bodySHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
})
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, bodyReader)
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTarget, target)
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
|
||||
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
return req
|
||||
}
|
||||
|
||||
// resign recomputes the HMAC signature over the request's current proxy
|
||||
// headers. Use this in tests that mutate a signed field (Identity,
|
||||
// AuthHeader, Target host, etc.) after calling signedReq.
|
||||
func resign(t *testing.T, key []byte, req *http.Request, body []byte) {
|
||||
t.Helper()
|
||||
target := req.Header.Get(sidecar.HeaderProxyTarget)
|
||||
targetHost := target
|
||||
if idx := strings.Index(target, "://"); idx >= 0 {
|
||||
targetHost = target[idx+3:]
|
||||
}
|
||||
sig := sidecar.Sign(key, sidecar.CanonicalRequest{
|
||||
Version: req.Header.Get(sidecar.HeaderProxyVersion),
|
||||
Method: req.Method,
|
||||
Host: targetHost,
|
||||
PathAndQuery: req.URL.RequestURI(),
|
||||
BodySHA256: sidecar.BodySHA256(body),
|
||||
Timestamp: req.Header.Get(sidecar.HeaderProxyTimestamp),
|
||||
Identity: req.Header.Get(sidecar.HeaderProxyIdentity),
|
||||
AuthHeader: req.Header.Get(sidecar.HeaderProxyAuthHeader),
|
||||
})
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
}
|
||||
|
||||
// TestProxyHandler_UnsupportedVersion verifies the handler rejects requests
|
||||
// whose HeaderProxyVersion is absent or set to an unknown value. Kept in
|
||||
// front so an old client paired with a newer server (or vice versa) surfaces
|
||||
// a clear 400 instead of a misleading HMAC mismatch downstream.
|
||||
func TestProxyHandler_UnsupportedVersion(t *testing.T) {
|
||||
h := newTestHandler([]byte("key"))
|
||||
for _, v := range []string{"", "v0", "v2"} {
|
||||
req := httptest.NewRequest("GET", "/path", nil)
|
||||
if v != "" {
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("version=%q: expected 400, got %d", v, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_MissingTimestamp(t *testing.T) {
|
||||
h := newTestHandler([]byte("key"))
|
||||
req := httptest.NewRequest("GET", "/path", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_MissingBodySHA(t *testing.T) {
|
||||
h := newTestHandler([]byte("key"))
|
||||
req := httptest.NewRequest("GET", "/path", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp())
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_BadHMAC(t *testing.T) {
|
||||
h := newTestHandler([]byte("real-key"))
|
||||
|
||||
bodySHA := sidecar.BodySHA256(nil)
|
||||
ts := sidecar.Timestamp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/path", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn")
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityBot)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, "Authorization")
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, "bad-signature")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_BodySHA256Mismatch(t *testing.T) {
|
||||
h := newTestHandler([]byte("key"))
|
||||
|
||||
req := httptest.NewRequest("POST", "/path", bytes.NewReader([]byte("real body")))
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn")
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp())
|
||||
req.Header.Set(sidecar.HeaderBodySHA256, sidecar.BodySHA256([]byte("different body")))
|
||||
req.Header.Set(sidecar.HeaderProxySignature, "whatever")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_TargetNotAllowed(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
|
||||
req := signedReq(t, key, "GET", "https://evil.com", "/steal", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for disallowed host, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_IdentityNotAllowed(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
// Restrict to bot only
|
||||
h.allowedIDs = map[string]bool{sidecar.IdentityBot: true}
|
||||
|
||||
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser)
|
||||
resign(t, key, req, nil) // identity is signed; must re-sign after mutation
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for disallowed identity, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTarget covers the per-shape rejections directly, without the
|
||||
// surrounding HTTP plumbing.
|
||||
func TestParseTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
target string
|
||||
wantErr bool
|
||||
wantSub string // expected fragment of the error message
|
||||
}{
|
||||
{name: "valid https", target: "https://open.feishu.cn", wantErr: false},
|
||||
{name: "valid https trailing slash", target: "https://open.feishu.cn/", wantErr: false},
|
||||
{name: "http downgrade", target: "http://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
|
||||
{name: "missing scheme", target: "open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
|
||||
{name: "ftp scheme", target: "ftp://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
|
||||
{name: "empty", target: "", wantErr: true, wantSub: "scheme must be https"},
|
||||
{name: "empty host", target: "https://", wantErr: true, wantSub: "missing host"},
|
||||
{name: "with path", target: "https://open.feishu.cn/open-apis", wantErr: true, wantSub: "path not allowed"},
|
||||
{name: "with query", target: "https://open.feishu.cn?a=1", wantErr: true, wantSub: "query not allowed"},
|
||||
{name: "with fragment", target: "https://open.feishu.cn#frag", wantErr: true, wantSub: "fragment not allowed"},
|
||||
{name: "with userinfo", target: "https://attacker:pw@open.feishu.cn", wantErr: true, wantSub: "userinfo not allowed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
host, err := parseTarget(tc.target)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got host=%q", host)
|
||||
}
|
||||
if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tc.wantSub)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "open.feishu.cn" {
|
||||
t.Errorf("host = %q, want %q", host, "open.feishu.cn")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyHandler_RejectsNonHTTPSTarget verifies end-to-end that a
|
||||
// compromised sandbox holding a valid PROXY_KEY cannot coerce the sidecar
|
||||
// into forwarding real tokens over cleartext HTTP or to an unexpected path.
|
||||
// The check must fire before HMAC verification so that the request is
|
||||
// rejected even when the signature is technically valid.
|
||||
func TestProxyHandler_RejectsNonHTTPSTarget(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
target string
|
||||
}{
|
||||
{"http downgrade", "http://open.feishu.cn"},
|
||||
{"bare hostname", "open.feishu.cn"},
|
||||
{"ftp scheme", "ftp://open.feishu.cn"},
|
||||
{"target with path", "https://open.feishu.cn/open-apis/evil"},
|
||||
{"target with query", "https://open.feishu.cn?steal=1"},
|
||||
{"target with userinfo", "https://attacker:pw@open.feishu.cn"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Sign with a valid key against the malicious target — proves the
|
||||
// scheme/shape check is not bypassed by signature legitimacy.
|
||||
req := signedReq(t, key, "GET", tc.target, "/open-apis/im/v1/chats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for target %q, got %d (body: %s)", tc.target, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyHandler_RejectsIdentityReplay locks in C1 end-to-end: a captured
|
||||
// bot-signed request whose identity header is flipped to user (or vice versa)
|
||||
// must be rejected at HMAC verification, not silently served with the wrong
|
||||
// token type. Without identity in the canonical string this returns 200.
|
||||
func TestProxyHandler_RejectsIdentityReplay(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
|
||||
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
|
||||
// Attacker flips identity without touching signature.
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("identity replay must fail signature verify (got %d, want 401): %s",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyHandler_RejectsAuthHeaderReplay is the companion: flipping
|
||||
// X-Lark-Proxy-Auth-Header post-signature must invalidate the signature so
|
||||
// an attacker cannot redirect the injected token into an unintended header.
|
||||
func TestProxyHandler_RejectsAuthHeaderReplay(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
|
||||
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, "Cookie")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("auth-header replay must fail signature verify (got %d, want 401): %s",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyHandler_RejectsAuthHeaderNotInAllowlist pins the auth-header
|
||||
// allowlist: even a correctly-signed request must be rejected if it asks
|
||||
// the sidecar to inject the real token into an unintended header (e.g.
|
||||
// Cookie / User-Agent / X-Forwarded-For). This closes the sidechannel
|
||||
// where the real token ends up in headers that Lark ignores for auth but
|
||||
// intermediate logs may capture.
|
||||
func TestProxyHandler_RejectsAuthHeaderNotInAllowlist(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
|
||||
for _, bad := range []string{"Cookie", "User-Agent", "X-Forwarded-For", "X-Real-IP", "Set-Cookie"} {
|
||||
t.Run(bad, func(t *testing.T) {
|
||||
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, bad)
|
||||
resign(t, key, req, nil) // auth-header is signed; must re-sign after override
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("authHeader=%q: expected 403, got %d (body: %s)",
|
||||
bad, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyHandler_AcceptsAllowedAuthHeaders confirms the three protocol
|
||||
// header names remain accepted after the allowlist is enforced. Uses
|
||||
// newTestHandler which has no upstream forwarding set up, so reaching the
|
||||
// forward step is proof the auth-header check passed.
|
||||
func TestProxyHandler_AcceptsAllowedAuthHeaders(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
|
||||
for _, good := range []string{"Authorization", sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT} {
|
||||
t.Run(good, func(t *testing.T) {
|
||||
// Use a handler with a real (fake) credential provider so we can
|
||||
// distinguish auth-header reject (403) from later failures.
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{token: "real-token"}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
h := &proxyHandler{
|
||||
key: key,
|
||||
cred: cred,
|
||||
appID: "cli_test",
|
||||
logger: discardLogger(),
|
||||
forwardCl: &http.Client{},
|
||||
allowedHosts: map[string]bool{"open.feishu.cn": true},
|
||||
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
|
||||
}
|
||||
|
||||
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, good)
|
||||
resign(t, key, req, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
// Expect NOT 403 "auth-header not allowed" — the request will fail
|
||||
// at forward (502 because open.feishu.cn isn't reachable without
|
||||
// an actual upstream in tests), but it must get past our check.
|
||||
if w.Code == http.StatusForbidden && strings.Contains(w.Body.String(), "auth-header not allowed") {
|
||||
t.Errorf("authHeader=%q was rejected by allowlist: %s", good, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_RejectsSelfProxy(t *testing.T) {
|
||||
old, had := os.LookupEnv(envvars.CliAuthProxy)
|
||||
os.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
defer func() {
|
||||
if had {
|
||||
os.Setenv(envvars.CliAuthProxy, old)
|
||||
} else {
|
||||
os.Unsetenv(envvars.CliAuthProxy)
|
||||
}
|
||||
}()
|
||||
|
||||
err := run(context.Background(), "127.0.0.1:0", "/tmp/should-not-be-created.key", "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when AUTH_PROXY is set")
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAuthProxy) {
|
||||
t.Errorf("error should mention %s, got: %v", envvars.CliAuthProxy, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardClient_RedirectStripsAuth(t *testing.T) {
|
||||
redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
t.Errorf("Authorization leaked to redirect target: %s", auth)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer redirectTarget.Close()
|
||||
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound)
|
||||
}))
|
||||
defer origin.Close()
|
||||
|
||||
client := newForwardClient()
|
||||
req, _ := http.NewRequest("GET", origin.URL+"/start", nil)
|
||||
req.Header.Set("Authorization", "Bearer real-token")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestForwardClient_RedirectStripsMCPHeaders(t *testing.T) {
|
||||
redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if v := r.Header.Get(sidecar.HeaderMCPUAT); v != "" {
|
||||
t.Errorf("X-Lark-MCP-UAT leaked to redirect target: %s", v)
|
||||
}
|
||||
if v := r.Header.Get(sidecar.HeaderMCPTAT); v != "" {
|
||||
t.Errorf("X-Lark-MCP-TAT leaked to redirect target: %s", v)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer redirectTarget.Close()
|
||||
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound)
|
||||
}))
|
||||
defer origin.Close()
|
||||
|
||||
client := newForwardClient()
|
||||
req, _ := http.NewRequest("POST", origin.URL+"/mcp", nil)
|
||||
req.Header.Set(sidecar.HeaderMCPUAT, "real-uat-token")
|
||||
req.Header.Set(sidecar.HeaderMCPTAT, "real-tat-token")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// TestProxyHandler_StripsClientSuppliedAuthHeaders verifies that the sidecar
|
||||
// is the sole source of auth headers on the forwarded request. A malicious
|
||||
// sandbox client must not be able to smuggle an Authorization/MCP header that
|
||||
// rides along with the sidecar-injected real token.
|
||||
func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
|
||||
const realToken = "real-tenant-access-token"
|
||||
|
||||
// Capture what the upstream receives after sidecar forwarding.
|
||||
// TLS is required because parseTarget rejects non-https targets.
|
||||
var captured http.Header
|
||||
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
captured = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
// Strip "https://" prefix to get host:port (matches what the handler sees).
|
||||
upstreamHost := strings.TrimPrefix(upstream.URL, "https://")
|
||||
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{token: realToken}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
key := []byte("test-key")
|
||||
h := &proxyHandler{
|
||||
key: key,
|
||||
cred: cred,
|
||||
appID: "cli_test",
|
||||
logger: discardLogger(),
|
||||
forwardCl: upstream.Client(), // trusts the httptest CA
|
||||
allowedHosts: map[string]bool{upstreamHost: true},
|
||||
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
proxyAuthHeader string // which header sidecar should inject into
|
||||
wantInjectedHeader string // the header the real token ends up in
|
||||
wantInjectedValue string
|
||||
wantStrippedHeaders []string
|
||||
}{
|
||||
{
|
||||
name: "inject Authorization, strip MCP attacker headers",
|
||||
proxyAuthHeader: "Authorization",
|
||||
wantInjectedHeader: "Authorization",
|
||||
wantInjectedValue: "Bearer " + realToken,
|
||||
wantStrippedHeaders: []string{sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT},
|
||||
},
|
||||
{
|
||||
name: "inject MCP UAT, strip Authorization attacker header",
|
||||
proxyAuthHeader: sidecar.HeaderMCPUAT,
|
||||
wantInjectedHeader: sidecar.HeaderMCPUAT,
|
||||
wantInjectedValue: realToken,
|
||||
wantStrippedHeaders: []string{"Authorization", sidecar.HeaderMCPTAT},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
captured = nil
|
||||
|
||||
req := signedReq(t, key, "GET", "https://"+upstreamHost, "/open-apis/test", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, tc.proxyAuthHeader)
|
||||
resign(t, key, req, nil) // auth-header is signed; re-sign after override
|
||||
|
||||
// Attacker smuggles all three possible auth headers with bogus values.
|
||||
req.Header.Set("Authorization", "Bearer attacker-token")
|
||||
req.Header.Set(sidecar.HeaderMCPUAT, "attacker-uat")
|
||||
req.Header.Set(sidecar.HeaderMCPTAT, "attacker-tat")
|
||||
|
||||
// Non-auth headers should still pass through.
|
||||
req.Header.Set("X-Custom-Header", "keep-me")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 from upstream, got %d; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("upstream handler was not invoked")
|
||||
}
|
||||
|
||||
// Injected header contains the real token (not the attacker value).
|
||||
if got := captured.Get(tc.wantInjectedHeader); got != tc.wantInjectedValue {
|
||||
t.Errorf("%s = %q, want %q", tc.wantInjectedHeader, got, tc.wantInjectedValue)
|
||||
}
|
||||
|
||||
// All other auth headers must be stripped.
|
||||
for _, h := range tc.wantStrippedHeaders {
|
||||
if got := captured.Get(h); got != "" {
|
||||
t.Errorf("%s should be stripped, got %q", h, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-auth headers still forwarded.
|
||||
if got := captured.Get("X-Custom-Header"); got != "keep-me" {
|
||||
t.Errorf("X-Custom-Header = %q, want %q", got, "keep-me")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAllowedHosts(t *testing.T) {
|
||||
feishu := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
|
||||
}
|
||||
lark := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
|
||||
}
|
||||
hosts := buildAllowedHosts(feishu, lark)
|
||||
// feishu hosts
|
||||
if !hosts["open.feishu.cn"] {
|
||||
t.Error("expected open.feishu.cn in allowlist")
|
||||
}
|
||||
if !hosts["mcp.feishu.cn"] {
|
||||
t.Error("expected mcp.feishu.cn in allowlist")
|
||||
}
|
||||
// lark hosts
|
||||
if !hosts["open.larksuite.com"] {
|
||||
t.Error("expected open.larksuite.com in allowlist")
|
||||
}
|
||||
if !hosts["mcp.larksuite.com"] {
|
||||
t.Error("expected mcp.larksuite.com in allowlist")
|
||||
}
|
||||
// evil host
|
||||
if hosts["evil.com"] {
|
||||
t.Error("evil.com should not be in allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"/open-apis/im/v1/messages?receive_id_type=chat_id", "/open-apis/im/v1/messages"},
|
||||
{"/open-apis/calendar/v4/events", "/open-apis/calendar/v4/events"},
|
||||
{"/open-apis/docx/v1/documents/doxcnABCD1234/blocks", "/open-apis/docx/v1/documents/:id/blocks"},
|
||||
{"/open-apis/im/v1/chats/oc_abcdef12345678/members", "/open-apis/im/v1/chats/:id/members"},
|
||||
{"/path?secret=abc", "/path"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := sanitizePath(tt.input); got != tt.want {
|
||||
t.Errorf("sanitizePath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeID(t *testing.T) {
|
||||
tests := []struct {
|
||||
seg string
|
||||
want bool
|
||||
}{
|
||||
{"doxcnABCD1234", true}, // doc token
|
||||
{"oc_abcdef12345678", true}, // chat ID
|
||||
{"v1", false}, // API version
|
||||
{"messages", false}, // route keyword
|
||||
{"open-apis", false}, // route prefix
|
||||
{"ab1", false}, // too short
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := looksLikeID(tt.seg); got != tt.want {
|
||||
t.Errorf("looksLikeID(%q) = %v, want %v", tt.seg, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeError(t *testing.T) {
|
||||
short := fmt.Errorf("short error")
|
||||
if got := sanitizeError(short); got != "short error" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
|
||||
longMsg := make([]byte, 300)
|
||||
for i := range longMsg {
|
||||
longMsg[i] = 'x'
|
||||
}
|
||||
long := fmt.Errorf("%s", string(longMsg))
|
||||
got := sanitizeError(long)
|
||||
if len(got) > 210 {
|
||||
t.Errorf("expected truncation, got %d chars", len(got))
|
||||
}
|
||||
if !bytes.HasSuffix([]byte(got), []byte("...")) {
|
||||
t.Errorf("expected '...' suffix, got %q", got[len(got)-10:])
|
||||
}
|
||||
}
|
||||
167
sidecar/server-demo/main.go
Normal file
167
sidecar/server-demo/main.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
// Command sidecar-server-demo is a reference implementation of a sidecar
|
||||
// auth proxy server. It is NOT production-ready — integrators should
|
||||
// implement their own server conforming to the wire protocol defined in
|
||||
// github.com/larksuite/cli/sidecar.
|
||||
//
|
||||
// The demo reuses the lark-cli credential pipeline (keychain + config) to
|
||||
// resolve real tokens, so it only works on a machine that has been
|
||||
// configured with `lark-cli auth login`.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", sidecar.DefaultListenAddr, "listen address (host:port)")
|
||||
keyFile := flag.String("key-file", defaultKeyFile(), "path to write the HMAC key")
|
||||
logFile := flag.String("log-file", "", "audit log file (stderr if empty)")
|
||||
profile := flag.String("profile", "", "lark-cli profile name (empty = active profile)")
|
||||
flag.Parse()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if err := run(ctx, *listen, *keyFile, *logFile, *profile); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultKeyFile() string {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".lark-sidecar", "proxy.key")
|
||||
}
|
||||
return "/tmp/lark-sidecar/proxy.key"
|
||||
}
|
||||
|
||||
func run(ctx context.Context, listen, keyFile, logFile, profile string) error {
|
||||
// Reject self-proxy: if this process inherited AUTH_PROXY, the sidecar
|
||||
// credential provider would activate and return sentinel tokens instead
|
||||
// of real ones, breaking the "trusted side holds real credentials" premise.
|
||||
if v := os.Getenv(envvars.CliAuthProxy); v != "" {
|
||||
return fmt.Errorf("%s is set in this environment (%s); unset it before starting the sidecar server", envvars.CliAuthProxy, v)
|
||||
}
|
||||
if listen == "" {
|
||||
return fmt.Errorf("invalid --listen address: empty")
|
||||
}
|
||||
|
||||
// Generate HMAC key (32 bytes = 256 bits) and write it to disk (0600).
|
||||
keyBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(keyBytes); err != nil {
|
||||
return fmt.Errorf("failed to generate HMAC key: %v", err)
|
||||
}
|
||||
keyHex := hex.EncodeToString(keyBytes)
|
||||
|
||||
keyDir := filepath.Dir(keyFile)
|
||||
if err := vfs.MkdirAll(keyDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create key directory: %v", err)
|
||||
}
|
||||
if err := vfs.WriteFile(keyFile, []byte(keyHex), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write key file: %v", err)
|
||||
}
|
||||
|
||||
// Audit logger: file or stderr.
|
||||
var auditLogger *log.Logger
|
||||
if logFile != "" {
|
||||
f, err := vfs.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
auditLogger = log.New(f, "", log.LstdFlags)
|
||||
} else {
|
||||
auditLogger = log.New(os.Stderr, "[audit] ", log.LstdFlags)
|
||||
}
|
||||
|
||||
// Reuse the lark-cli credential pipeline. A production implementation
|
||||
// would likely source credentials from a secrets manager instead.
|
||||
factory := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
|
||||
cfg, err := factory.Config()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on %s: %v", listen, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
allowedHosts := buildAllowedHosts(
|
||||
core.ResolveEndpoints(core.BrandFeishu),
|
||||
core.ResolveEndpoints(core.BrandLark),
|
||||
)
|
||||
allowedIDs := buildAllowedIdentities(cfg)
|
||||
|
||||
handler := &proxyHandler{
|
||||
key: []byte(keyHex),
|
||||
cred: factory.Credential,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
logger: auditLogger,
|
||||
forwardCl: newForwardClient(),
|
||||
allowedHosts: allowedHosts,
|
||||
allowedIDs: allowedIDs,
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
auditLogger.Println("shutting down...")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
auditLogger.Printf("shutdown error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
keyPrefix := keyHex
|
||||
if len(keyPrefix) > 8 {
|
||||
keyPrefix = keyPrefix[:8]
|
||||
}
|
||||
proxyURL := "http://" + listen
|
||||
fmt.Fprintf(os.Stderr, "Auth sidecar listening on %s\n", proxyURL)
|
||||
fmt.Fprintf(os.Stderr, "HMAC key prefix: %s\n", keyPrefix)
|
||||
fmt.Fprintf(os.Stderr, "Full key written to %s (mode 0600)\n", keyFile)
|
||||
fmt.Fprintf(os.Stderr, "\nSet in sandbox:\n")
|
||||
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAuthProxy, proxyURL)
|
||||
fmt.Fprintf(os.Stderr, " export %s=\"<read from %s>\"\n", envvars.CliProxyKey, keyFile)
|
||||
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAppID, cfg.AppID)
|
||||
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliBrand, string(cfg.Brand))
|
||||
|
||||
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("sidecar server exited unexpectedly: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -42,7 +42,7 @@
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **确认投递** — 发送后用 `send_status` 查询投递状态,向用户报告结果
|
||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
|
||||
### CRITICAL — 首次使用任何命令前先查 `-h`
|
||||
@@ -104,15 +104,17 @@ lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
|
||||
|
||||
### 命令选择:先判断邮件类型,再决定草稿还是发送
|
||||
|
||||
| 邮件类型 | 存草稿(不发送) | 直接发送 |
|
||||
|----------|-----------------|---------|
|
||||
| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` |
|
||||
| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` 或 `+reply-all --confirm-send` |
|
||||
| **转发** | `+forward` | `+forward --confirm-send` |
|
||||
| 邮件类型 | 存草稿(不发送) | 直接发送 | 定时发送 |
|
||||
|----------|-----------------|---------|----------|
|
||||
| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` | `+send --confirm-send --send-time <unix_timestamp>` |
|
||||
| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` 或 `+reply-all --confirm-send` | `+reply --confirm-send --send-time <unix_timestamp>` 或 `+reply-all --confirm-send --send-time <unix_timestamp>` |
|
||||
| **转发** | `+forward` | `+forward --confirm-send` | `+forward --confirm-send --send-time <unix_timestamp>` |
|
||||
|
||||
- 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`**
|
||||
- **发送前必须向用户确认收件人和内容,用户明确同意后才可加 `--confirm-send`**
|
||||
- **发送后必须调用 `send_status` 确认投递状态**(详见下方说明)
|
||||
- **立即发送后必须调用 `send_status` 确认投递状态**;定时发送(`--send-time`)在预定发送时间后再查询,取消定时发送用 `cancel_scheduled_send`(详见下方说明)
|
||||
|
||||
> **定时发送注意事项**:`--send-time` 必须与 `--confirm-send` 配合使用,不能单独使用。`send_time` 为 Unix 时间戳(秒),需至少为当前时间 + 5 分钟。
|
||||
|
||||
### 使用公共邮箱或别名(send_as)发信
|
||||
|
||||
@@ -151,7 +153,7 @@ lark-cli mail +send --mailbox me --from alias@example.com \
|
||||
|
||||
### 发送后确认投递状态
|
||||
|
||||
邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告:
|
||||
**立即发送(无 `--send-time`)**:邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}'
|
||||
@@ -159,6 +161,14 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
返回每个收件人的投递状态(`status`):1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告结果,如有异常状态(退信/审批拒绝)需重点提示。
|
||||
|
||||
**定时发送(指定了 `--send-time`)**:定时发送不会立即产生 `message_id`,`send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。如需取消定时发送:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
### 撤回邮件
|
||||
|
||||
发送成功后,若响应中包含 `recall_available: true`,说明该邮件支持撤回(24 小时内已投递的邮件)。
|
||||
|
||||
29
skill-template/domains/wiki.md
Normal file
29
skill-template/domains/wiki.md
Normal file
@@ -0,0 +1,29 @@
|
||||
> **成员管理硬限制:**
|
||||
> - 如果目标是“部门”,先判断身份,再决定是否继续。
|
||||
> - `--as bot` 对应 `tenant_access_token`。官方限制:这种身份下不能使用部门 ID (`opendepartmentid`) 添加知识空间成员。
|
||||
> - 遇到“部门 + --as bot”时,禁止先调用 `lark-cli wiki members create` 试错;直接说明该路径不可行。
|
||||
> - 如果用户明确要求“以 bot 身份运行”,且目标是部门,必须停下说明 bot 路径无法完成,不要静默切到 `--as user`。
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。
|
||||
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`。
|
||||
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门”三类之一,再决定 `member_type`,不要先调 `wiki members create` 再根据报错反推类型。
|
||||
- 用户说“部门 + bot”:这是已知不支持路径。不要继续尝试 `wiki members create --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
|
||||
- 用户说“用户 / 群 + 添加成员”:先解析对应 ID,再执行 `wiki members create`。
|
||||
|
||||
## 成员添加流程
|
||||
|
||||
- 调用 `lark-cli wiki members create` 前,先把自然语言里的“人 / 群 / 部门”解析成正确的 `member_id`,不要猜格式。
|
||||
- 用户场景默认优先 `member_type=openid`:用 `lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --format json` 获取 `open_id`。
|
||||
- 群组场景使用 `member_type=openchat`:用 `lark-cli im +chat-search --query "<群名关键词>" --format json` 获取 `chat_id`。
|
||||
- `userid` / `unionid` 只在下游明确要求时才使用;先拿到 `open_id`,再调用 `lark-cli api GET /open-apis/contact/v3/users/<open_id> --params '{"user_id_type":"open_id"}' --format json` 读取 `user_id` / `union_id`。
|
||||
- 部门场景使用 `member_type=opendepartmentid`:当前 CLI 没有 shortcut,需调用 `lark-cli api POST /open-apis/contact/v3/departments/search --as user --params '{"department_id_type":"open_department_id"}' --data '{"query":"<部门名>"}'` 获取 `open_department_id`。
|
||||
- 只有在目标类型和身份都已确认可行后,才调用 `lark-cli wiki members create`。对于部门场景,这意味着必须是 `--as user`。
|
||||
|
||||
## 目标语义约束
|
||||
|
||||
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
|
||||
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move`、`wiki +node-create` 或其他 Wiki 写操作
|
||||
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
|
||||
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`,才进入 Drive 域处理
|
||||
@@ -26,9 +26,11 @@ lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
- `get` — 获取单个审批实例详情
|
||||
- `cancel` — 撤回审批实例
|
||||
- `cc` — 抄送审批实例
|
||||
- `initiated` — 查询用户的已发起列表
|
||||
|
||||
### tasks
|
||||
|
||||
- `remind` — 催办审批人
|
||||
- `approve` — 同意审批任务
|
||||
- `reject` — 拒绝审批任务
|
||||
- `transfer` — 转交审批任务
|
||||
@@ -41,6 +43,8 @@ lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
| `instances.get` | `approval:instance:read` |
|
||||
| `instances.cancel` | `approval:instance:write` |
|
||||
| `instances.cc` | `approval:instance:write` |
|
||||
| `instances.initiated` | `approval:instance:read` |
|
||||
| `tasks.remind` | `approval:instance:write` |
|
||||
| `tasks.approve` | `approval:task:write` |
|
||||
| `tasks.reject` | `approval:task:write` |
|
||||
| `tasks.transfer` | `approval:task:write` |
|
||||
|
||||
@@ -250,11 +250,31 @@ metadata:
|
||||
| `slides` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
| `mindnote` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
|
||||
### 3.5 执行身份与人员字段
|
||||
### 3.5 身份选择与权限降级策略
|
||||
|
||||
多维表格通常属于用户的个人或团队资源。**默认应优先使用 `--as user`(用户身份)执行所有 Base 操作**,始终显式指定身份。
|
||||
|
||||
- **`--as user`(推荐)**:以当前登录用户身份操作其有权访问的 Base。执行前先完成用户授权:
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain base
|
||||
```
|
||||
|
||||
- **`--as bot`(降级)**:仅当 user 身份权限不足、且 bot 身份确实拥有目标 Base 的访问权限时,才降级使用。bot 看不到用户私有资源,行为以应用身份执行。
|
||||
|
||||
**执行规则**:
|
||||
|
||||
1. 所有操作默认先用 `--as user`。
|
||||
2. 若 user 身份返回权限错误,先判断是否为**不可重试错误码**(如 `91403`)。若是,**立即停止**,不做任何重试或降级,直接按 `lark-shared` 权限不足处理流程引导用户解决。
|
||||
3. 非不可重试错误码时,检查错误响应中是否包含 `permission_violations` / `hint` 等提权引导信息:
|
||||
- **有提权引导**:按 `lark-shared` 权限不足处理流程,先引导用户完成 user 身份提权(`auth login --scope`);确认提权成功后,以 `--as user` 重试。
|
||||
- **无提权引导**(如资源级无访问权限、非 scope 不足):切换到 `--as bot` 重试**一次**。
|
||||
4. 若 bot 身份仍然返回权限错误,**立即停止重试**,根据错误响应按 `lark-shared` 流程引导用户解决(引导去开发者后台开通 scope 或确认资源访问权限)。
|
||||
5. 只有在用户明确要求"用应用身份 / bot 身份操作",才跳过 user 直接使用 `--as bot`。
|
||||
|
||||
**补充说明**:
|
||||
|
||||
- 人员字段 / 用户字段:注意 `user_id_type` 与执行身份(user / bot)差异。
|
||||
- bot 身份:bot 看不到用户私有资源;行为以应用身份执行。
|
||||
- user 身份:依赖用户授权和 scope;更适合操作用户资源。
|
||||
|
||||
## 4. 执行规则
|
||||
|
||||
@@ -296,7 +316,7 @@ metadata:
|
||||
- 删除记录 / 字段 / 表时,如果用户已经明确说要删除,且目标明确,`+record-delete / +field-delete / +table-delete` 可直接执行,并带 `--yes`。
|
||||
- 删除目标仍有歧义时,先用 `+record-get / +field-get / +table-get` 或相应 list 命令确认。
|
||||
- `+base-create / +base-copy` 成功后,回复中必须主动返回新 Base 的标识信息;若结果带可访问链接,也应一并返回。
|
||||
- 若 Base 由 bot 身份创建且当前 CLI 存在可用 user 身份,优先继续补授当前 user 为 `full_access`;owner 转移必须单独确认,禁止擅自执行。
|
||||
- 若 Base 由 bot 身份创建或复制,shortcut 会自动尝试为当前 CLI 用户补授 `full_access`,并在输出中返回 `permission_grant`;agent 不需要再手动编排单独授权。owner 转移必须单独确认,禁止擅自执行。
|
||||
|
||||
## 5. 常见错误与恢复
|
||||
|
||||
@@ -313,3 +333,4 @@ metadata:
|
||||
| 系统字段 / 公式字段写入失败 | 只读字段被当成可写字段 | 改为写存储字段,计算结果交给 formula / lookup / 系统字段自动产出 |
|
||||
| `1254104` | 批量超 200 条 | 分批调用 |
|
||||
| `1254291` | 并发写冲突 | 串行写入 + 批次间延迟 |
|
||||
| `91403` | 无权限访问该 Base | **不要重试**。按 `lark-shared` 权限不足处理流程引导用户解决权限问题 |
|
||||
|
||||
@@ -47,19 +47,14 @@ POST /open-apis/base/v3/bases/:base_token/copy
|
||||
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果 Base 是**以应用身份(bot)复制**出来的,agent 在复制成功后应**默认继续使用 bot 身份**,为当前可用的 user 身份添加该 Base 的 `full_access`(管理员)权限。
|
||||
> 推荐流程:
|
||||
> 1. 先用 `lark-cli contact +get-user` 获取当前用户信息,并从返回结果中读取该用户的 `open_id`
|
||||
> 2. 再切回 bot 身份,使用这个 `open_id` 给该用户授权该 Base 的 `full_access`(管理员)权限
|
||||
> 如果 Base 是**以应用身份(bot)复制**出来的,shortcut 会在复制成功后自动尝试为当前 CLI 用户添加该 Base 的 `full_access`(管理员)权限,并在输出中附带 `permission_grant` 字段。
|
||||
>
|
||||
> 如果 `lark-cli contact +get-user` 无法执行,或者本地没有可用的 user 身份、拿不到当前用户的 `open_id`,则应视为“本地没有可用的 user 身份”,明确说明因此未完成授权。
|
||||
> `permission_grant.status` 语义如下:
|
||||
> - `granted`:当前 CLI 用户已获得该 Base 的管理员权限
|
||||
> - `skipped`:Base 已复制成功,但没有可授权的当前 CLI 用户,或复制结果缺少可授权 token
|
||||
> - `failed`:Base 已复制成功,但自动授权失败;结果中会包含失败原因,用户可稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 回复复制结果时,除 `base token` 和可访问链接外,还必须明确告知用户授权结果:
|
||||
> - 如果授权成功:直接说明当前 user 已获得该 Base 的管理员权限
|
||||
> - 如果本地没有可用的 user 身份:明确说明因此未完成授权
|
||||
> - 如果授权失败:明确说明 Base 已复制成功,但授权失败,并透出失败原因;同时提示用户可以稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 如果授权未完成,应继续给出后续引导:用户可以稍后重试授权,也可以继续使用应用身份(bot)处理该 Base;如果希望后续改由自己管理,也可将 Base owner 转移给该用户。
|
||||
> 回复复制结果时,除 `base token` 和可访问链接外,还必须明确告知用户 `permission_grant` 的结果。
|
||||
>
|
||||
> **仍然不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
|
||||
@@ -42,19 +42,14 @@ POST /open-apis/base/v3/bases
|
||||
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果 Base 是**以应用身份(bot)创建**的,agent 在创建成功后应**默认继续使用 bot 身份**,为当前可用的 user 身份添加该 Base 的 `full_access`(管理员)权限。
|
||||
> 推荐流程:
|
||||
> 1. 先用 `lark-cli contact +get-user` 获取当前用户信息,并从返回结果中读取该用户的 `open_id`
|
||||
> 2. 再切回 bot 身份,使用这个 `open_id` 给该用户授权该 Base 的 `full_access`(管理员)权限
|
||||
> 如果 Base 是**以应用身份(bot)创建**的,shortcut 会在创建成功后自动尝试为当前 CLI 用户添加该 Base 的 `full_access`(管理员)权限,并在输出中附带 `permission_grant` 字段。
|
||||
>
|
||||
> 如果 `lark-cli contact +get-user` 无法执行,或者本地没有可用的 user 身份、拿不到当前用户的 `open_id`,则应视为“本地没有可用的 user 身份”,明确说明因此未完成授权。
|
||||
> `permission_grant.status` 语义如下:
|
||||
> - `granted`:当前 CLI 用户已获得该 Base 的管理员权限
|
||||
> - `skipped`:Base 已创建成功,但没有可授权的当前 CLI 用户,或创建结果缺少可授权 token
|
||||
> - `failed`:Base 已创建成功,但自动授权失败;结果中会包含失败原因,用户可稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 回复创建结果时,除 `base token` 和可访问链接外,还必须明确告知用户授权结果:
|
||||
> - 如果授权成功:直接说明当前 user 已获得该 Base 的管理员权限
|
||||
> - 如果本地没有可用的 user 身份:明确说明因此未完成授权
|
||||
> - 如果授权失败:明确说明 Base 已创建成功,但授权失败,并透出失败原因;同时提示用户可以稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 如果授权未完成,应继续给出后续引导:用户可以稍后重试授权,也可以继续使用应用身份(bot)处理该 Base;如果希望后续改由自己管理,也可将 Base owner 转移给该用户。
|
||||
> 回复创建结果时,除 `base token` 和可访问链接外,还必须明确告知用户 `permission_grant` 的结果。
|
||||
>
|
||||
> **仍然不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|----------------------------|
|
||||
| `allow_edit` | bool | 可新增、删除、修改视图,未提及默认为 `false` |
|
||||
| `allow_edit` | bool | 可新增、删除、修改视图;表权限为 `edit` 时默认为 `true`,表权限为 `read_only` 或用户明确限制时为 `false` |
|
||||
| `visibility` | object | 可见的视图配置 |
|
||||
| `visibility.all_visible` | bool | 是否全部可见 |
|
||||
| `visibility.visible_views` | []string | 可见视图名称 列表 |
|
||||
@@ -203,7 +203,7 @@
|
||||
输出 `view_rule` 时,**必须**使用以下完整结构,根据场景选择对应模板:
|
||||
|
||||
```json
|
||||
// 情况 A:用户要求可编辑/新增/删除视图 → allow_edit 设为 true
|
||||
// 情况 A:表权限为 edit 且用户未明确限制 → allow_edit 默认为 true,全部可见
|
||||
{
|
||||
"view_rule": {
|
||||
"allow_edit": true,
|
||||
@@ -213,7 +213,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 情况 B:用户未提及具体视图,未要求编辑视图 → 全部可见、不可编辑
|
||||
// 情况 B:表权限为 read_only,或用户明确说不可编辑视图 → 全部可见、不可编辑
|
||||
{
|
||||
"view_rule": {
|
||||
"allow_edit": false,
|
||||
@@ -223,10 +223,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 情况 C:用户提及了具体视图 → 仅指定视图可见
|
||||
// 情况 C:用户提及了具体视图 → 仅指定视图可见(allow_edit 仍按 A/B 规则判断)
|
||||
{
|
||||
"view_rule": {
|
||||
"allow_edit": false,
|
||||
"allow_edit": true,
|
||||
"visibility": {
|
||||
"all_visible": false,
|
||||
"visible_views": ["表格视图", "看板视图"]
|
||||
@@ -415,7 +415,15 @@
|
||||
| 仪表盘访问 | 不配置 | 用户明确提及该仪表盘 |
|
||||
| `base_rule_map.copy` | `false` | 用户明确要求"允许复制" |
|
||||
| `base_rule_map.download` | `false` | 用户明确要求"允许下载/打印/副本" |
|
||||
| `record_operations` 中的 `delete` | 不包含 | 用户明确说"允许删除"或使用强语义("完全管理""可删改") |
|
||||
|
||||
### 默认开启项(条件性)
|
||||
|
||||
以下能力在特定条件下**默认开启**,用户明确限制时才排除:
|
||||
|
||||
| 能力 | 默认值 | 排除条件 |
|
||||
|------|--------|----------|
|
||||
| `record_operations` 中的 `delete` | **包含**(`perm = edit` 时) | 用户明确限制时才排除 |
|
||||
| `view_rule.allow_edit` | **`true`**(`perm = edit` 时) | 用户明确限制"不可编辑视图"或 `perm = read_only` 时设为 `false` |
|
||||
|
||||
---
|
||||
|
||||
@@ -436,7 +444,7 @@
|
||||
### 记录操作默认策略
|
||||
|
||||
**注意**:
|
||||
- 用户未提及时,默认包含 `add`,默认不包含 `delete`
|
||||
- 用户未提及时,表权限为 `edit` 时默认同时包含 `add` 和 `delete`,默认不包含 `delete` 的情况仅适用于用户明确限制操作的场景
|
||||
- 阅读范围默认对齐编辑范围:用户仅描述可编辑范围、未说明阅读范围时,可阅读范围与可编辑范围保持一致,不主动扩大
|
||||
- 当可读范围与可编辑范围一致时,**不得**生成 `read_filter_rule_group`;应设置 `other_record_all_read = false` 且 `read_filter_rule_group = null`
|
||||
|
||||
@@ -475,7 +483,7 @@
|
||||
1. **先判断用户是否提及了具体视图名称**(如"看板视图可见""甘特图不可编辑"等)
|
||||
- **是** → `all_visible = false`,`visible_views` 仅包含用户明确提及为"可见"的视图名称(非 viewID);未提及的视图视为不可见
|
||||
- **否**(用户完全未提及任何视图)→ `all_visible = true`
|
||||
2. `allow_edit` 默认为 `false`,仅当用户明确要求"可编辑视图""可新增/删除视图""可管理视图"时才设为 `true`。设为 `true` 时仍**必须**包含 `visibility` 字段(参考视图权限 情况 A)
|
||||
2. `allow_edit` 在表权限为 `edit` 时**默认为 `true`**;仅当用户明确限制"不可编辑视图"时才设为 `false`。设为 `true` 时仍**必须**包含 `visibility` 字段(参考视图权限 情况 A)
|
||||
3. `all_visible` 为 `false` 时,`visible_views` **不可为空**,必须至少包含一个视图
|
||||
|
||||
**❌ 常见错误 — 缺少 `visibility` 字段:**
|
||||
|
||||
@@ -97,31 +97,19 @@ Drive Folder (云空间文件夹)
|
||||
└── file_token (直接使用)
|
||||
```
|
||||
|
||||
## 重要说明:画板编辑
|
||||
> **⚠️ lark-doc skill 不能直接编辑已有画板内容,但 `docs +update` 可以新建空白画板**
|
||||
### 场景 1:已通过 docs +fetch 获取到文档内容和画板 token
|
||||
如果用户已经通过 `docs +fetch` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户:
|
||||
1. 记录画板的 token
|
||||
2. 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容
|
||||
### 场景 2:刚创建画板,需要编辑
|
||||
如果用户刚通过 `docs +update` 创建了空白画板,需要编辑时:
|
||||
**步骤 1:按空白画板语法创建**
|
||||
- 在 `--markdown` 中直接传 `<whiteboard type="blank"></whiteboard>`
|
||||
- 需要多个空白画板时,在同一个 `--markdown` 里重复多个 whiteboard 标签
|
||||
**步骤 2:从响应中记录 token**
|
||||
- `docs +update` 成功后,读取响应字段 `data.board_tokens`
|
||||
- `data.board_tokens` 是新建画板的 token 列表,后续编辑直接使用这里的 token
|
||||
**步骤 3:引导编辑**
|
||||
- 记录需要编辑的画板 token
|
||||
- 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容
|
||||
### 注意事项
|
||||
- 已有画板内容无法通过 lark-doc 的 `docs +update` 直接编辑
|
||||
- 编辑画板需要使用专门的 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md)
|
||||
## 绘图需求识别与挖掘
|
||||
|
||||
## 文档可视化建议
|
||||
> **💡 在撰写文档时,当需要表达较为复杂的时序、架构层次、逻辑关系、数据流程等内容时,建议使用画板绘制可视化图表以显著提升文档的可阅读性。**
|
||||
>
|
||||
> 请参考 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何绘制画板内容。
|
||||
用户很少主动提"画板"——**默认**使用飞书画板承载图表,命中以下任一信号即触发:
|
||||
- 用户提到图表类型:架构图、流程图、时序图、组织图、路线图、对比图、鱼骨图、飞轮图、思维导图等
|
||||
- 用户表达可视化意图:画一下、梳理关系、画个流程、给我一个图、方便汇报等
|
||||
- 文档主题涉及结构关系、流程走向、时间线、数据对比
|
||||
|
||||
以下场景不加图:用户明确拒绝、合同/法律条款/合规声明等严谨连续文本、原样转录任务。
|
||||
|
||||
> [!CAUTION]
|
||||
> 命中后,**MUST** 先读取 [`references/lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 并**严格按其流程执行**。
|
||||
>
|
||||
> **绝对禁止**用 `whiteboard-cli` 渲染 PNG 后通过 `docs +media-insert` 插入文档——图表必须通过 `lark-cli whiteboard +update` 写入画板 block,这是唯一合法路径。
|
||||
|
||||
## 快速决策
|
||||
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。
|
||||
@@ -132,45 +120,6 @@ Drive Folder (云空间文件夹)
|
||||
- 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。
|
||||
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”,**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
|
||||
|
||||
## 画板需求挖掘(主动识别)
|
||||
|
||||
> **用户很少主动提"画板"。创建文档时应主动识别适合可视化的内容,用画板呈现。**
|
||||
|
||||
### 🔴 关键要求(必须遵守)
|
||||
|
||||
**创建空白画板 ≠ 完成任务**。创建空白画板后,**必须继续使用 lark-whiteboard 技能填充实际内容**。
|
||||
|
||||
### 语义与画板类型映射
|
||||
|
||||
创建/编辑文档时,文档主题涉及以下语义,应**主动**创建画板,无需用户指定:
|
||||
|
||||
| 语义 | 画板类型 | 参考指南 |
|
||||
|---------------|-------|---------------------------------------------------------------------------------------------|
|
||||
| 架构/分层/技术方案 | 架构图 | [lark-whiteboard-cli/scenes/architecture.md](../lark-whiteboard-cli/scenes/architecture.md) |
|
||||
| 流程/审批/部署/业务流转 | 流程图 | [lark-whiteboard-cli/scenes/flowchart.md](../lark-whiteboard-cli/scenes/flowchart.md) |
|
||||
| 组织/层级/汇报关系 | 组织架构图 | [lark-whiteboard-cli/scenes/organization.md](../lark-whiteboard-cli/scenes/organization.md) |
|
||||
| 时间线/里程碑/版本规划 | 里程碑图 | [lark-whiteboard-cli/scenes/milestone.md](../lark-whiteboard-cli/scenes/milestone.md) |
|
||||
| 因果/复盘/根因分析 | 鱼骨图 | [lark-whiteboard-cli/scenes/fishbone.md](../lark-whiteboard-cli/scenes/fishbone.md) |
|
||||
| 方案对比/技术选型 | 对比图 | [lark-whiteboard-cli/scenes/comparison.md](../lark-whiteboard-cli/scenes/comparison.md) |
|
||||
| 循环/飞轮/闭环 | 飞轮图 | [lark-whiteboard-cli/scenes/flywheel.md](../lark-whiteboard-cli/scenes/flywheel.md) |
|
||||
| 层级占比/能力模型 | 金字塔图 | [lark-whiteboard-cli/scenes/pyramid.md](../lark-whiteboard-cli/scenes/pyramid.md) |
|
||||
| 模块依赖/调用关系 | 架构图 | [lark-whiteboard-cli/scenes/architecture.md](../lark-whiteboard-cli/scenes/architecture.md) |
|
||||
| 分类梳理/知识体系 | 思维导图 | [lark-whiteboard-cli/scenes/mermaid.md](../lark-whiteboard-cli/scenes/mermaid.md) |
|
||||
| 数据分布/占比 | 饼图 | [lark-whiteboard-cli/scenes/mermaid.md](../lark-whiteboard-cli/scenes/mermaid.md) |
|
||||
|
||||
创建画板前,务必先阅读 [`lark-whiteboard-cli`](../lark-whiteboard-cli/SKILL.md) 和 [`lark-whiteboard`](../lark-whiteboard/SKILL.md) 这两个 Skill,了解画板的创建流程。
|
||||
|
||||
### 完整执行流程(必须完整执行)
|
||||
|
||||
1. **创建空白画板占位**:创建场景用 `docs +create`、编辑场景用 `docs +update` 插入空白画板
|
||||
2. **获取画板 token**:从 `docs +update` 响应的 `data.board_tokens` 获取画板 token 列表
|
||||
3. **填充画板内容**:切换到 [`lark-whiteboard-cli`](../lark-whiteboard-cli/SKILL.md) 创建画板内容,并填入画板
|
||||
4. **验证完成**:确认所有画板都有实际内容,不是空白
|
||||
|
||||
**不适用**:纯文字记录(日志/备忘)、数据密集型内容(用表格)、用户明确只要文字。
|
||||
|
||||
> ⚠️ **警告**:如果只创建空白画板而不填充内容,任务将被视为未完成!
|
||||
|
||||
## 补充说明
|
||||
`docs +search` 除了搜索文档 / Wiki,也承担“先定位云空间对象,再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
|
||||
|
||||
@@ -186,4 +135,4 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback) |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+whiteboard-update`](references/lark-doc-whiteboard-update.md) | Update an existing whiteboard in lark document with whiteboard dsl. Such DSL input from stdin. refer to lark-whiteboard skill for more details. |
|
||||
| [`+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. |
|
||||
|
||||
@@ -57,9 +57,8 @@ lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown
|
||||
如果文档中包含空白画板(`<whiteboard type="blank"></whiteboard>`),**必须继续以下步骤**:
|
||||
|
||||
1. 从返回值的 `data.board_tokens` 字段记录所有新建画板的 token
|
||||
2. 立即切换到 [`lark-whiteboard`](../lark-whiteboard/SKILL.md) 技能
|
||||
3. 使用 `whiteboard +update` 命令为每个画板填充实际内容(Mermaid/PlantUML/DSL)
|
||||
4. 确认所有画板都有实际内容后,任务才算完成
|
||||
2. 读取 `../../lark-whiteboard/SKILL.md`,跳至"渲染 & 写入画板"章节,为每个 board_token 生成并写入实际内容
|
||||
3. 确认所有画板都有实际内容后,任务才算完成
|
||||
|
||||
**仅创建空白画板是不够的!** 如果只创建空白画板而不填充内容,任务将被视为未完成。
|
||||
|
||||
@@ -85,7 +84,7 @@ lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown
|
||||
- **视觉节奏**:用分割线、分栏、表格打破大段纯文字
|
||||
- **图文交融**:流程、架构或草图需要可视化时,优先使用图片、表格或空白画板
|
||||
- **克制留白**:Callout 不过度、加粗只强调核心词
|
||||
- **主动画板**:文档涉及架构、流程、组织、时间线、因果等逻辑关系时,主动插入空白画板,后续用 lark-whiteboard 填充;但若用户明确要求仅文本或内容更适合表格,则不插入。详见 [画板需求挖掘](../SKILL.md#画板需求挖掘主动识别)
|
||||
- **主动画板**:文档涉及架构、流程、组织、时间线、因果等逻辑关系时,**必须**在 markdown 对应章节的文字内容之后插入 `<whiteboard type="blank"></whiteboard>` 占位,每个图表对应一个标签。**禁止**用 `whiteboard-cli` 渲染的 PNG/SVG 图片替代画板。创建完成后从返回值 `data.board_tokens` 取 token,读取 `../../lark-whiteboard/SKILL.md` 的"渲染 & 写入画板"章节为每个 token 写入图表内容。例:文档含"系统整体架构""分层架构""部署架构"各需插入一个画板,"类图"也需插入一个画板(走 Mermaid 路由)。
|
||||
|
||||
当用户有明确的样式、风格需求时,应当以用户的需求为准!
|
||||
|
||||
@@ -450,7 +449,7 @@ lark-cli docs +create --title "空白画板示例" --markdown '<whiteboard type=
|
||||
**重要说明**:
|
||||
- 创建空白画板时,直接使用 `<whiteboard type="blank"></whiteboard>`
|
||||
- 读取时只能获取 token,可通过 media-download 查看内容,无法直接读出画板内部内容
|
||||
- 画板编辑:详见 [SKILL.md](../SKILL.md#重要说明画板编辑)
|
||||
- 画板编辑:详见 [../../lark-whiteboard/SKILL.md](../../lark-whiteboard/SKILL.md)
|
||||
|
||||
### 多维表格(Base)
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
|
||||
# docs +whiteboard-update(更新飞书画板)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 shortcut 仅为兼容历史调用保留,具体使用方式请参考 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md)
|
||||
66
skills/lark-doc/references/lark-doc-whiteboard.md
Normal file
66
skills/lark-doc/references/lark-doc-whiteboard.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# lark-doc 画板处理指南
|
||||
|
||||
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
## 两个 Skill 的职责边界
|
||||
|
||||
| Skill | 核心职责 | 约束 |
|
||||
|------|------|------|
|
||||
| `lark-doc` | 文档内容读取/更新、插入空白画板占位、获取 board_token | 不能直接编辑画板内容;`docs +update` 的画板能力仅限插入空白占位 |
|
||||
| `lark-whiteboard` | 查询/导出画板(+query);图表内容生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入画板(+update) | 图表内容生成由此 skill 完整执行,不依赖外部调度 |
|
||||
|
||||
## 文档与画板协同流程
|
||||
|
||||
### 步骤 1:判断场景
|
||||
|
||||
| 场景 | 入口 |
|
||||
|------|------|
|
||||
| 文档中需要插入新画板 | 继续步骤 2 |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch` 获取 `board_token`,跳至步骤 3 |
|
||||
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
|
||||
|
||||
### 步骤 2:在文档中创建空白画板
|
||||
|
||||
- 创建场景:`docs +create`;编辑场景:`docs +update`
|
||||
- markdown 中使用 `<whiteboard type="blank"></whiteboard>`(不要转义)
|
||||
- 多个画板时,在相应的地方插入各自的 whiteboard 标签
|
||||
- 从响应的 `data.board_tokens` 中读取 token 列表
|
||||
|
||||
### 步骤 3:生成并写入画板内容
|
||||
|
||||
读取 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md),跳至"渲染 & 写入画板"章节,按其完整流程为每个 board_token 生成并写入图表内容。
|
||||
|
||||
多个画板时依次处理,每个画板完成后再处理下一个。
|
||||
|
||||
### 步骤 4:完成校验
|
||||
|
||||
- 确认每个 token 对应的画板都已填充真实内容
|
||||
- 不保留空白占位画板;只有空白画板而无内容视为任务未完成
|
||||
|
||||
---
|
||||
|
||||
## 语义与画板类型映射
|
||||
|
||||
| 语义 | 画板类型 |
|
||||
|------|------|
|
||||
| 架构/分层/技术方案/模块依赖/调用关系 | 架构图 |
|
||||
| 流程/审批/部署/业务流转/状态机 | 流程图 |
|
||||
| 跨角色流程/跨系统交互/端到端链路 | 泳道图 |
|
||||
| 组织/层级/汇报关系 | 组织架构图 |
|
||||
| 时间线/里程碑/版本规划 | 里程碑图 |
|
||||
| 因果/复盘/根因分析 | 鱼骨图 |
|
||||
| 方案对比/技术选型/功能矩阵 | 对比图 |
|
||||
| 循环/飞轮/闭环/增长链路 | 飞轮图 |
|
||||
| 层级占比/能力模型/需求层次 | 金字塔图 |
|
||||
| 矩形树图/层级面积占比 | 树状图 |
|
||||
| 转化漏斗/销售漏斗 | 漏斗图 |
|
||||
| 分类梳理/知识体系/思维导图/时序图/类图 | Mermaid |
|
||||
| 数据分布/占比/饼图 | Mermaid |
|
||||
| 柱状图/条形图/数据对比 | 柱状图 |
|
||||
| 折线图/趋势图/时序数据 | 折线图 |
|
||||
|
||||
---
|
||||
|
||||
## 关联参考
|
||||
|
||||
- 画板查询/创作/修改/渲染写入:[`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md)
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
给文档添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments`(`create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--selection-with-ellipsis` 或 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL(仅全文评论),也支持传最终可解析为 doc/docx 的 wiki URL。
|
||||
给文档或电子表格添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments`(`create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--selection-with-ellipsis` 或 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL(仅全文评论)、sheet URL,也支持传最终可解析为 doc/docx/sheet 的 wiki URL。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -42,6 +42,28 @@ lark-cli drive +add-comment \
|
||||
--selection-with-ellipsis "流程" \
|
||||
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 处理,参考 "},{"type":"link","text":"https://example.com"}]'
|
||||
|
||||
# 给电子表格单元格添加评论(--block-id 格式为 <sheetId>!<cell>)
|
||||
lark-cli drive +add-comment \
|
||||
--doc "https://example.larksuite.com/sheets/<SHEET_TOKEN>" \
|
||||
--block-id "<SHEET_ID>!D6" \
|
||||
--content '[{"type":"text","text":"请检查此单元格数据"}]'
|
||||
|
||||
# wiki 链接指向的 sheet 也支持
|
||||
lark-cli drive +add-comment \
|
||||
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
|
||||
--block-id "<SHEET_ID>!A1" \
|
||||
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 确认"}]'
|
||||
|
||||
# 传裸 token 时需要 --type 指定文档类型
|
||||
lark-cli drive +add-comment \
|
||||
--doc "<SHEET_TOKEN>" --type sheet \
|
||||
--block-id "<SHEET_ID>!D6" \
|
||||
--content '[{"type":"text","text":"请检查"}]'
|
||||
|
||||
lark-cli drive +add-comment \
|
||||
--doc "<DOCX_TOKEN>" --type docx \
|
||||
--content '[{"type":"text","text":"全文评论"}]'
|
||||
|
||||
# 已知 block_id 时可跳过 MCP 直接创建局部评论
|
||||
lark-cli drive +add-comment \
|
||||
--doc "<DOCX_TOKEN>" \
|
||||
@@ -67,14 +89,16 @@ lark-cli drive +add-comment \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--doc` | 是 | 文档 URL / token,或可解析到 `doc`/`docx` 的 wiki URL。原始 token 默认按 `docx` 处理;旧版 `doc` 请传 URL 或 wiki 链接 |
|
||||
| `--doc` | 是 | 文档 URL / token、sheet URL,或可解析到 `doc`/`docx`/`sheet` 的 wiki URL |
|
||||
| `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`sheet`。URL 输入时自动识别,无需传 |
|
||||
| `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` |
|
||||
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--selection-with-ellipsis` / `--block-id` 时也会默认走全文评论 |
|
||||
| `--selection-with-ellipsis` | 局部评论时二选一 | 目标文本定位表达式,支持纯文本或 `开头...结尾`;与 `--block-id` 互斥 |
|
||||
| `--block-id` | 局部评论时二选一 | 已知目标块 ID 时直接使用;与 `--selection-with-ellipsis` 互斥 |
|
||||
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--selection-with-ellipsis` / `--block-id` 时也会默认走全文评论(不适用于 sheet) |
|
||||
| `--selection-with-ellipsis` | 局部评论时二选一 | 目标文本定位表达式,支持纯文本或 `开头...结尾`;与 `--block-id` 互斥(不适用于 sheet) |
|
||||
| `--block-id` | 局部评论时二选一 | 已知目标块 ID 时直接使用;与 `--selection-with-ellipsis` 互斥。**Sheet 评论**:格式为 `<sheetId>!<cell>`(如 `a281f9!D6`) |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "<sheetId>!<cell>"` 指定单元格(如 `a281f9!D6`)。此时 `--full-comment` 和 `--selection-with-ellipsis` 不可用。
|
||||
- **无需预先获取文档内容**:使用 `--selection-with-ellipsis` 时,shortcut 内部会自动调用 `locate-doc` 定位目标文本,不需要先调用 `docs +fetch` 获取文档。
|
||||
- 未传 `--selection-with-ellipsis` / `--block-id` 时,shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。
|
||||
- 全文评论支持 `docx`、旧版 `doc` URL,以及最终可解析为 `doc`/`docx` 的 wiki URL。
|
||||
|
||||
@@ -56,7 +56,7 @@ metadata:
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **确认投递** — 发送后用 `send_status` 查询投递状态,向用户报告结果
|
||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
|
||||
### CRITICAL — 首次使用任何命令前先查 `-h`
|
||||
@@ -118,15 +118,17 @@ lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
|
||||
|
||||
### 命令选择:先判断邮件类型,再决定草稿还是发送
|
||||
|
||||
| 邮件类型 | 存草稿(不发送) | 直接发送 |
|
||||
|----------|-----------------|---------|
|
||||
| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` |
|
||||
| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` 或 `+reply-all --confirm-send` |
|
||||
| **转发** | `+forward` | `+forward --confirm-send` |
|
||||
| 邮件类型 | 存草稿(不发送) | 直接发送 | 定时发送 |
|
||||
|----------|-----------------|---------|----------|
|
||||
| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` | `+send --confirm-send --send-time <unix_timestamp>` |
|
||||
| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` 或 `+reply-all --confirm-send` | `+reply --confirm-send --send-time <unix_timestamp>` 或 `+reply-all --confirm-send --send-time <unix_timestamp>` |
|
||||
| **转发** | `+forward` | `+forward --confirm-send` | `+forward --confirm-send --send-time <unix_timestamp>` |
|
||||
|
||||
- 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`**
|
||||
- **发送前必须向用户确认收件人和内容,用户明确同意后才可加 `--confirm-send`**
|
||||
- **发送后必须调用 `send_status` 确认投递状态**(详见下方说明)
|
||||
- **立即发送后必须调用 `send_status` 确认投递状态**;定时发送(`--send-time`)在预定发送时间后再查询,取消定时发送用 `cancel_scheduled_send`(详见下方说明)
|
||||
|
||||
> **定时发送注意事项**:`--send-time` 必须与 `--confirm-send` 配合使用,不能单独使用。`send_time` 为 Unix 时间戳(秒),需至少为当前时间 + 5 分钟。
|
||||
|
||||
### 使用公共邮箱或别名(send_as)发信
|
||||
|
||||
@@ -165,7 +167,7 @@ lark-cli mail +send --mailbox me --from alias@example.com \
|
||||
|
||||
### 发送后确认投递状态
|
||||
|
||||
邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告:
|
||||
**立即发送(无 `--send-time`)**:邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}'
|
||||
@@ -173,6 +175,14 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
返回每个收件人的投递状态(`status`):1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告结果,如有异常状态(退信/审批拒绝)需重点提示。
|
||||
|
||||
**定时发送(指定了 `--send-time`)**:定时发送不会立即产生 `message_id`,`send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。如需取消定时发送:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
### 撤回邮件
|
||||
|
||||
发送成功后,若响应中包含 `recall_available: true`,说明该邮件支持撤回(24 小时内已投递的邮件)。
|
||||
@@ -335,6 +345,7 @@ lark-cli mail <resource> <method> [flags] # 调用 API
|
||||
|
||||
### user_mailbox.drafts
|
||||
|
||||
- `cancel_scheduled_send` — 取消定时发送
|
||||
- `create` — 创建草稿
|
||||
- `delete` — 删除指定邮箱账户下的单份邮件草稿。注意:对于草稿状态的邮件,只能使用本接口删除,禁止使用 trash_message;被删除的草稿数据无法恢复,请谨慎使用。
|
||||
- `get` — 获取草稿详情
|
||||
@@ -420,6 +431,7 @@ lark-cli mail <resource> <method> [flags] # 调用 API
|
||||
| `user_mailboxes.accessible_mailboxes` | `mail:user_mailbox:readonly` |
|
||||
| `user_mailboxes.profile` | `mail:user_mailbox:readonly` |
|
||||
| `user_mailboxes.search` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.drafts.cancel_scheduled_send` | `mail:user_mailbox.message:send` |
|
||||
| `user_mailbox.drafts.create` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.drafts.delete` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.drafts.get` | `mail:user_mailbox.message:readonly` |
|
||||
|
||||
@@ -52,6 +52,7 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te
|
||||
| `--attach <paths>` | 否 | 普通附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--format <mode>` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ lark-cli mail +draft-edit --draft-id <draft-id> --set-subject '测试' --dry-run
|
||||
| `--set-to <emails>` | 否 | 用此处提供的地址替换整个 To 收件人列表 |
|
||||
| `--set-cc <emails>` | 否 | 用此处提供的地址替换整个 Cc 抄送列表 |
|
||||
| `--set-bcc <emails>` | 否 | 用此处提供的地址替换整个 Bcc 密送列表 |
|
||||
| `--set-priority <level>` | 否 | 设置邮件优先级:`high`、`normal`、`low`。设为 `normal` 会清除已有优先级 |
|
||||
| `--patch-file <path>` | 否 | 所有正文编辑、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。相对路径。先运行 `--print-patch-template` 查看 JSON 结构 |
|
||||
| `--print-patch-template` | 否 | 打印 `--patch-file` 的 JSON 模板和支持的操作。建议在生成补丁文件前先运行此命令。不会读取或写入草稿 |
|
||||
| `--inspect` | 否 | 查看草稿但不修改。返回包含 `has_quoted_content`(是否有引用区)、`attachments_summary`(含每个附件的 `part_id`、`cid`、`filename`)和 `inline_summary` 的草稿投影 |
|
||||
|
||||
@@ -67,7 +67,9 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 |
|
||||
| `--send-time <timestamp>` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
## 返回值
|
||||
@@ -114,6 +116,25 @@ lark-cli mail +forward --message-id <邮件ID> --to bob@example.com --body '<p>F
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
### 场景 3:用户说"下午 3 点转发给 Bob"(定时发送)
|
||||
```bash
|
||||
# Step 1: 创建转发草稿
|
||||
lark-cli mail +forward --message-id <邮件ID> --to bob@example.com --body '<p>FYI,请查收。</p>'
|
||||
# → 返回 draft_id
|
||||
|
||||
# Step 2: 向用户确认 "转发草稿已创建:收件人 bob@example.com,定时 <目标时间> 发送。确认吗?"
|
||||
|
||||
# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟)
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'
|
||||
```
|
||||
|
||||
### 场景 4:用户说"等等,先不转发了"(取消定时发送)
|
||||
```bash
|
||||
# 取消定时发送(取消后邮件变回草稿)
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。
|
||||
|
||||
## 转发整个会话
|
||||
|
||||
`+forward` 操作的是单封邮件(`--message-id`),但转发整个会话时应 forward **会话中最后一条消息**,因为邮件客户端会将完整的回复链嵌套在最新一条中。典型流程:
|
||||
@@ -139,7 +160,9 @@ lark-cli mail +forward --message-id <最后一条的message_id> --to recipient@e
|
||||
|
||||
转发发送成功后:
|
||||
|
||||
**1. 确认投递状态**(必须)— 用返回的 `message_id` 查询投递状态:
|
||||
**1. 确认投递状态**(仅立即发送 — 无 `--send-time` 时必须)
|
||||
|
||||
用返回的 `message_id` 查询投递状态:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}'
|
||||
@@ -147,6 +170,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告投递结果,异常状态需重点提示。
|
||||
|
||||
**1b. 定时发送(指定了 `--send-time`)**
|
||||
|
||||
定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。
|
||||
|
||||
如需取消定时发送:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -71,7 +71,9 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
|
||||
| `--send-time <timestamp>` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
## 返回值
|
||||
@@ -118,6 +120,25 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '<p>已确认。</p>'
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
### 场景 3:用户说"下午 3 点回复全部说已确认"(定时发送)
|
||||
```bash
|
||||
# Step 1: 创建回复全部草稿
|
||||
lark-cli mail +reply-all --message-id <邮件ID> --body '<p>已确认。</p>'
|
||||
# → 返回 draft_id
|
||||
|
||||
# Step 2: 向用户确认 "回复全部草稿已创建:收件人 alice@, bob@, carol@,内容「已确认。」定时 <目标时间> 发送。确认吗?"
|
||||
|
||||
# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟)
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'
|
||||
```
|
||||
|
||||
### 场景 4:用户说"等等,先不回复了"(取消定时发送)
|
||||
```bash
|
||||
# 取消定时发送(取消后邮件变回草稿)
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。
|
||||
|
||||
## 实现说明
|
||||
|
||||
- 自动收件人规则:原发件人优先进入 To,原 To/Cc 进入 Cc。
|
||||
@@ -129,7 +150,9 @@ lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_
|
||||
|
||||
回复发送成功后:
|
||||
|
||||
**1. 确认投递状态**(必须)— 用返回的 `message_id` 查询投递状态:
|
||||
**1. 确认投递状态**(仅立即发送 — 无 `--send-time` 时必须)
|
||||
|
||||
用返回的 `message_id` 查询投递状态:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}'
|
||||
@@ -137,6 +160,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告投递结果,异常状态需重点提示。
|
||||
|
||||
**1b. 定时发送(指定了 `--send-time`)**
|
||||
|
||||
定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。
|
||||
|
||||
如需取消定时发送:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -74,7 +74,9 @@ lark-cli mail +reply --message-id <邮件ID> --body '<p>测试</p>' --dry-run
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
|
||||
| `--send-time <timestamp>` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
## 返回值
|
||||
@@ -121,6 +123,25 @@ lark-cli mail +reply --message-id <邮件ID> --body '<p>已处理,谢谢。</p
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
### 场景 3:用户说"下午 3 点回复这封邮件说已处理"(定时发送)
|
||||
```bash
|
||||
# Step 1: 创建回复草稿
|
||||
lark-cli mail +reply --message-id <邮件ID> --body '<p>已处理,谢谢。</p>'
|
||||
# → 返回 draft_id
|
||||
|
||||
# Step 2: 向用户确认 "回复草稿已创建:回复给 alice@example.com,内容「已处理,谢谢。」定时 <目标时间> 发送。确认吗?"
|
||||
|
||||
# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟)
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'
|
||||
```
|
||||
|
||||
### 场景 4:用户说"等等,先不回复了"(取消定时发送)
|
||||
```bash
|
||||
# 取消定时发送(取消后邮件变回草稿)
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。
|
||||
|
||||
## 实现说明
|
||||
|
||||
### 会话维护
|
||||
@@ -144,7 +165,9 @@ References: <原邮件references + smtp_message_id>
|
||||
|
||||
回复发送成功后:
|
||||
|
||||
**1. 确认投递状态**(必须)— 用返回的 `message_id` 查询投递状态:
|
||||
**1. 确认投递状态**(仅立即发送 — 无 `--send-time` 时必须)
|
||||
|
||||
用返回的 `message_id` 查询投递状态:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}'
|
||||
@@ -152,6 +175,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告投递结果,异常状态需重点提示。
|
||||
|
||||
**1b. 定时发送(指定了 `--send-time`)**
|
||||
|
||||
定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。
|
||||
|
||||
如需取消定时发送:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -71,7 +71,9 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '<p>test</p
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--confirm-send` | 否 | 确认发送邮件(默认只保存草稿)。仅在用户明确确认收件人和内容后使用 |
|
||||
| `--send-time <timestamp>` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
## 返回值
|
||||
@@ -120,8 +122,29 @@ lark-cli mail +send --to alice@example.com --subject '收到' --body '<p>已收
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
### 场景 3:用户说"下午 3 点给 Alice 发一封周报"(定时发送)
|
||||
```bash
|
||||
# Step 1: 创建草稿(定时发送也走草稿流程)
|
||||
lark-cli mail +send --to alice@example.com --subject '周报' --body '<p>本周进展如下...</p>'
|
||||
# → 返回 draft_id
|
||||
|
||||
# Step 2: 向用户确认 "邮件草稿已创建:收件人 alice@example.com,主题「周报」,定时 <目标时间> 发送。确认吗?"
|
||||
|
||||
# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟)
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'
|
||||
```
|
||||
|
||||
### 场景 4:用户说"等等,先不发那封邮件了"(取消定时发送)
|
||||
```bash
|
||||
# 取消定时发送(取消后邮件变回草稿)
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。
|
||||
|
||||
## 发送后跟进
|
||||
|
||||
### 立即发送(无 `--send-time`)
|
||||
|
||||
邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` 查询投递状态:
|
||||
|
||||
```bash
|
||||
@@ -130,6 +153,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告各收件人投递结果,异常状态需重点提示。
|
||||
|
||||
### 定时发送(指定了 `--send-time`)
|
||||
|
||||
定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询投递状态。
|
||||
|
||||
如需取消定时发送,可在预定时间前调用取消接口:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
## 实现说明
|
||||
|
||||
- 使用 EML 构建器生成完整 MIME 邮件并 base64url 编码后发送。
|
||||
|
||||
127
skills/lark-okr/SKILL.md
Normal file
127
skills/lark-okr/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: lark-okr
|
||||
version: 1.0.0
|
||||
description: "飞书 OKR:管理目标与关键结果。查看和编辑 OKR 周期、目标(Objective)、关键结果(Key Result)、对齐关系、量化指标。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: [ "lark-cli" ]
|
||||
cliHelp: "lark-cli okr --help"
|
||||
---
|
||||
|
||||
# okr (v2)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|--------------------------------------------------------|--------------------------|
|
||||
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
|
||||
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
|
||||
|
||||
## 格式说明
|
||||
|
||||
- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Notes 字段使用的富文本格式说明
|
||||
- [`OKR 业务实体`](references/lark-okr-entities.md) 获取 OKR 实体结构,定义和关系,帮助你更好的使用 OKR 功能
|
||||
- **强烈建议** 在操作 OKR 前,阅读[`OKR 业务实体`](references/lark-okr-entities.md)以了解基础概念
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema okr.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli okr <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,**必须**先运行 `schema` 查看 `--data` / `--params` 参数结构,**不要**猜测字段格式!
|
||||
|
||||
### alignments
|
||||
|
||||
- `delete` — 删除对齐关系
|
||||
- `get` — 获取对齐关系
|
||||
|
||||
### categories
|
||||
|
||||
- `list` — 批量获取分类
|
||||
|
||||
### cycles
|
||||
|
||||
- `list` — 批量获取用户周期
|
||||
- `objectives_position` — 更新用户周期下全部目标的位置
|
||||
- 请求中必须同时修改对应周期下全部目标的位置,且不允许位置重叠,否则会参数校验失败。
|
||||
- `objectives_weight` — 更新用户周期下全部目标的权重
|
||||
- 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
|
||||
|
||||
### cycle.objectives
|
||||
|
||||
- `create` — 创建目标
|
||||
- `list` — 批量获取用户周期下的目标
|
||||
|
||||
### indicators
|
||||
|
||||
- `patch` — 更新量化指标
|
||||
|
||||
### key_results
|
||||
|
||||
- `delete` — 删除关键结果
|
||||
- `get` — 获取关键结果
|
||||
- `patch` — 更新关键结果
|
||||
|
||||
### key_result.indicators
|
||||
|
||||
- `list` — 获取关键结果的量化指标
|
||||
|
||||
### objectives
|
||||
|
||||
- `delete` — 删除目标
|
||||
- `get` — 获取目标
|
||||
- `key_results_position` — 更新全部关键结果的位置
|
||||
- 请求中必须同时修改对应目标下全部关键结果的位置,且不允许位置重叠,否则会参数校验失败。
|
||||
- `key_results_weight` — 更新全部关键结果的权重
|
||||
- 请求中必须同时修改对应目标下全部关键结果的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
|
||||
- `patch` — 更新目标
|
||||
|
||||
### objective.alignments
|
||||
|
||||
- `create` — 创建对齐关系
|
||||
- 对齐不允许对齐自己的目标,且发起对齐的目标和被对齐的目标所在周期时间上必须有重叠,否则会参数校验失败。
|
||||
- `list` — 批量获取目标下的对齐关系
|
||||
|
||||
### objective.indicators
|
||||
|
||||
- `list` — 获取目标的量化指标
|
||||
|
||||
### objective.key_results
|
||||
|
||||
- `create` — 创建关键结果
|
||||
- `list` — 批量获取目标下的关键结果
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|-----------------------------------|-----------------------------|
|
||||
| `alignments.delete` | `okr:okr.content:writeonly` |
|
||||
| `alignments.get` | `okr:okr.content:readonly` |
|
||||
| `categories.list` | `okr:okr.setting:read` |
|
||||
| `cycles.list` | `okr:okr.period:readonly` |
|
||||
| `cycles.objectives_position` | `okr:okr.content:writeonly` |
|
||||
| `cycles.objectives_weight` | `okr:okr.content:writeonly` |
|
||||
| `cycle.objectives.create` | `okr:okr.content:writeonly` |
|
||||
| `cycle.objectives.list` | `okr:okr.content:readonly` |
|
||||
| `indicators.patch` | `okr:okr.content:writeonly` |
|
||||
| `key_results.delete` | `okr:okr.content:writeonly` |
|
||||
| `key_results.get` | `okr:okr.content:readonly` |
|
||||
| `key_results.patch` | `okr:okr.content:writeonly` |
|
||||
| `key_result.indicators.list` | `okr:okr.content:readonly` |
|
||||
| `objectives.delete` | `okr:okr.content:writeonly` |
|
||||
| `objectives.get` | `okr:okr.content:readonly` |
|
||||
| `objectives.key_results_position` | `okr:okr.content:writeonly` |
|
||||
| `objectives.key_results_weight` | `okr:okr.content:writeonly` |
|
||||
| `objectives.patch` | `okr:okr.content:writeonly` |
|
||||
| `objective.alignments.create` | `okr:okr.content:writeonly` |
|
||||
| `objective.alignments.list` | `okr:okr.content:readonly` |
|
||||
| `objective.indicators.list` | `okr:okr.content:readonly` |
|
||||
| `objective.key_results.create` | `okr:okr.content:writeonly` |
|
||||
| `objective.key_results.list` | `okr:okr.content:readonly` |
|
||||
|
||||
313
skills/lark-okr/references/lark-okr-contentblock.md
Normal file
313
skills/lark-okr/references/lark-okr-contentblock.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# OKR ContentBlock 富文本格式
|
||||
|
||||
OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock` 富文本格式。本文档描述其结构和使用方式。
|
||||
|
||||
## ContentBlock 结构概览
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"style": {
|
||||
"list": {
|
||||
"list_type": "bullet",
|
||||
"indent_level": 0,
|
||||
"number": 1
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "Hello World",
|
||||
"style": {
|
||||
"bold": true,
|
||||
"strike_through": false,
|
||||
"back_color": {
|
||||
"red": 255,
|
||||
"green": 0,
|
||||
"blue": 0,
|
||||
"alpha": 1
|
||||
},
|
||||
"text_color": {
|
||||
"red": 0,
|
||||
"green": 255,
|
||||
"blue": 0,
|
||||
"alpha": 1
|
||||
},
|
||||
"link": {
|
||||
"url": "https://example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph_element_type": "docsLink",
|
||||
"docs_link": {
|
||||
"url": "https://larkoffice.com/docx/xxx",
|
||||
"title": "Lark Document"
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph_element_type": "mention",
|
||||
"mention": {
|
||||
"user_id": "ou_xxx"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"block_element_type": "gallery",
|
||||
"gallery": {
|
||||
"images": [
|
||||
{
|
||||
"file_token": "file_xxx",
|
||||
"src": "https://...",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 类型定义
|
||||
|
||||
### ContentBlock
|
||||
|
||||
根级别内容块。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------|-------------------------|---------|
|
||||
| `blocks` | `ContentBlockElement[]` | 内容块元素数组 |
|
||||
|
||||
### ContentBlockElement
|
||||
|
||||
内容块元素,支持段落或图库。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------------------|--------------------|--------------------------------------------|
|
||||
| `block_element_type` | `BlockElementType` | 块类型:`paragraph` \| `gallery` |
|
||||
| `paragraph` | `ContentParagraph` | 段落内容(当 `block_element_type="paragraph"` 时) |
|
||||
| `gallery` | `ContentGallery` | 图库内容(当 `block_element_type="gallery"` 时) |
|
||||
|
||||
### ContentParagraph
|
||||
|
||||
段落内容。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------------|-----------------------------|-------------|
|
||||
| `style` | `ContentParagraphStyle` | 段落样式(列表类型等) |
|
||||
| `elements` | `ContentParagraphElement[]` | 段落内元素数组 |
|
||||
|
||||
### ContentParagraphElement
|
||||
|
||||
段落内元素,支持文本、文档链接、提及。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------------------------|------------------------|-------------------------------------------|
|
||||
| `paragraph_element_type` | `ParagraphElementType` | 元素类型:`textRun` \| `docsLink` \| `mention` |
|
||||
| `text_run` | `ContentTextRun` | 文本内容 |
|
||||
| `docs_link` | `ContentDocsLink` | 飞书文档链接 |
|
||||
| `mention` | `ContentMention` | 用户提及 |
|
||||
|
||||
### ContentTextRun
|
||||
|
||||
文本块。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------|--------------------|------|
|
||||
| `text` | `string` | 文本内容 |
|
||||
| `style` | `ContentTextStyle` | 文本样式 |
|
||||
|
||||
### ContentTextStyle
|
||||
|
||||
文本样式。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------------------|----------------|-------|
|
||||
| `bold` | `boolean` | 是否粗体 |
|
||||
| `strike_through` | `boolean` | 是否删除线 |
|
||||
| `back_color` | `ContentColor` | 背景颜色 |
|
||||
| `text_color` | `ContentColor` | 文字颜色 |
|
||||
| `link` | `ContentLink` | 链接 |
|
||||
|
||||
### ContentColor
|
||||
|
||||
颜色。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------|-----------|--------------|
|
||||
| `red` | `int32` | 红色通道 (0-255) |
|
||||
| `green` | `int32` | 绿色通道 (0-255) |
|
||||
| `blue` | `int32` | 蓝色通道 (0-255) |
|
||||
| `alpha` | `float64` | 透明度 (0-1) |
|
||||
|
||||
### ContentParagraphStyle
|
||||
|
||||
段落样式。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------|---------------|------|
|
||||
| `list` | `ContentList` | 列表样式 |
|
||||
|
||||
### ContentList
|
||||
|
||||
列表样式。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------------|------------|---------------------------------------------------------------------|
|
||||
| `list_type` | `ListType` | 列表类型:`bullet` \| `number` \| `checkBox` \| `checkedBox` \| `indent` |
|
||||
| `indent_level` | `int32` | 缩进层级 |
|
||||
| `number` | `int32` | 序号(当 `list_type="number"` 时) |
|
||||
|
||||
### ContentGallery
|
||||
|
||||
图库。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------|----------------------|-------|
|
||||
| `images` | `ContentImageItem[]` | 图片项数组 |
|
||||
|
||||
### ContentImageItem
|
||||
|
||||
图片项。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------------|-----------|----------|
|
||||
| `file_token` | `string` | 文件 token |
|
||||
| `src` | `string` | 图片 URL |
|
||||
| `width` | `float64` | 宽度 |
|
||||
| `height` | `float64` | 高度 |
|
||||
|
||||
### ContentDocsLink
|
||||
|
||||
飞书文档链接。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------|----------|--------|
|
||||
| `url` | `string` | 链接 URL |
|
||||
| `title` | `string` | 链接标题 |
|
||||
|
||||
### ContentMention
|
||||
|
||||
提及。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----------|----------|-------|
|
||||
| `user_id` | `string` | 用户 ID |
|
||||
|
||||
### ContentLink
|
||||
|
||||
链接。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-------|----------|--------|
|
||||
| `url` | `string` | 链接 URL |
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 示例 1:简单文本段落
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "提升用户满意度"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 2:带格式的文本段落
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "Q2 目标",
|
||||
"style": {
|
||||
"bold": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": " - 提升产品质量"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 3:带列表的段落
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"style": {
|
||||
"list": {
|
||||
"list_type": "bullet",
|
||||
"indent_level": 0
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "完成功能开发"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"style": {
|
||||
"list": {
|
||||
"list_type": "bullet",
|
||||
"indent_level": 0
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "进行用户测试"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user