Compare commits

..

19 Commits

Author SHA1 Message Date
liangshuo-1
d126ea2f92 chore(release): v1.0.44 (#1176) 2026-05-29 19:43:31 +08:00
max
1ba107da2e fix(vc): correct --minute-token to --minute-tokens in recording reference (#1170)
Fix 3 occurrences of --minute-token (singular) to --minute-tokens
(plural) in lark-vc-recording.md to match the actual CLI flag
definition in minutes_download.go.
2026-05-29 16:42:54 +08:00
yballul-bytedance
0e6274d947 feat(base): add dashboard block data shortcut and workflow docs (#1067)
Change-Id: I52c471886bdb2d4b7be021ce86c34bbb78385017
2026-05-29 16:35:32 +08:00
lhfer
e18ea9a2e8 fix(im): correct 64-bit MP4 box size handling to prevent panic on crafted media (#1165)
The size==1 (64-bit "largesize") branch of all three MP4 box walkers
(findMP4Box, readMp4DurationBytes, readMp4Duration) set boxEnd to the raw
largesize instead of offset+largesize — even though the 32-bit branch right
below correctly uses offset+size. Two consequences:

- Correctness: for any MP4 that carries a 64-bit box size at a non-zero
  offset, the box walk is computed from the wrong end, so the moov/mvhd
  lookup is truncated and the media duration is silently lost.

- Robustness/security (CWE-190): the unguarded uint64->int(64) conversion of
  a largesize with the high bit set yields a negative boxEnd. The in-memory
  walkers then assign it to offset and feed it back as a slice index
  (data[offset:]), panicking with "slice bounds out of range" and crashing
  the CLI on a crafted or corrupt MP4. This is reachable via URL-sourced IM
  media, whose bytes the caller does not control.

Fix: compute boxEnd as offset+largesize (matching the 32-bit branch) and
reject largesize values smaller than the 16-byte header or larger than the
remaining input. Malformed media now honours the parsers' best-effort
contract by returning 0/-1 instead of panicking, and the bounds guarantee
the conversion can no longer overflow.

Add regression tests covering both the overflow (must not panic) and a
64-bit box at a non-zero offset (must walk correctly).
2026-05-29 16:04:21 +08:00
shifengjuan-dev
365e0a2880 feat(im/chat-list): support --types flag for listing p2p single chats (#1077)
Add a new --types flag (string_slice; values from {group, p2p}) to
+chat-list, backed by the new GET /open-apis/im/v1/chats `types` query
parameter. Accepts CSV (--types group,p2p) and repeated-flag forms
(--types group --types p2p).

Defaults to groups-only (backward compatible). Under user identity,
p2p single chats appear with chat_mode="p2p" plus p2p_target_type /
p2p_target_id fields. Under bot identity:

  - --types=p2p alone is rejected at validation
  - --types=p2p,group is silently downgraded to types=group (no runtime
    notice; skill docs document this contract)

Updates Shortcut.Description, lark-im SKILL.md (frontmatter trigger
+ shortcut table row), and the chat-list reference doc with command
examples, the new parameter, output field documentation, and a
dedicated "Bot identity and p2p" section.

Change-Id: I637ce23b3c6ce4ec350f0ac26dbac8120761bb71
2026-05-29 15:29:37 +08:00
syh-cpdsss
0a2c3202cb fix: whiteboard skill (#1166)
Change-Id: Ib1da37c1520d7697eaee7146555185ffbc749217
2026-05-29 14:23:11 +08:00
JackZhao10086
176d452cc1 feat: add agent header support (#1158)
* feat: add agent header support
2026-05-29 13:44:15 +08:00
liangshuo-1
a2cc5e124e fix(install): detect curl version before using --ssl-revoke-best-effort (#1124)
* fix(install): detect curl version before using --ssl-revoke-best-effort

(cherry picked from commit da14737702)

* test(install): cover curl version gate and refactor for testability

Extract the version comparison out of curlSupportsSslRevokeBestEffort()
into a pure isCurlVersionSupported(output), so the >= 7.70.0 logic is unit
testable without spawning curl. Add cases for 7.55.1 / 7.69.0 / 7.70.0 /
8.x plus the unparseable and libcurl-token edge cases (the regex must read
the leading "curl X.Y.Z", not the trailing "libcurl/X.Y.Z").

Memoize the `curl --version` probe: curl's version is invariant for the
install's lifetime while download() runs once per mirror URL, so probe at
most once instead of re-spawning curl on every attempt.

---------

Co-authored-by: EllienTang <146210093+Ellien-Tang@users.noreply.github.com>
Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-05-28 22:51:16 +08:00
liangshuo-1
a2dde84158 chore(release): v1.0.43 (#1161) 2026-05-28 21:46:02 +08:00
hugang-lark
21998b9ca8 feat: support note generated event (#1159) 2026-05-28 21:10:14 +08:00
liangshuo-1
ce2abff8ae fix(config): propagate Lang across credential boundary; respect CurrentApp in priorLang (#1157)
Two issues caught in review of #1132 that the existing tests missed because
they constructed RuntimeContext/CliConfig directly, bypassing the credential
edge where the bug lives.

P1 — Lang dropped at credential boundary
  credential.Account had no Lang field, so AccountFromCliConfig and
  ToCliConfig silently dropped cfg.Lang. The production Factory builds
  CliConfig via acct.ToCliConfig() (factory_default.go Phase 3), which
  meant RuntimeContext.Lang() always returned "" in production and
  shortcuts/mail/mail_signature.go always fell back to zh_cn — defeating
  the whole point of persisting --lang.

  Fix: add Lang i18n.Lang to Account and copy it in both directions.

  Regression test: TestFullChain_LangSurvivesProductionPath walks the
  real path (SaveMultiAppConfig -> DefaultAccountProvider.ResolveAccount
  -> ToCliConfig) and asserts Lang survives, so any future field added
  to CliConfig forces the same audit.

P2 — priorLang ignored CurrentApp in multi-profile workspaces
  priorLang scanned all Apps and returned the first non-empty Lang. If a
  user had multiple profiles and the active one disagreed with Apps[0],
  a re-bind without --lang would silently inherit the wrong profile's
  preference.

  Fix: read multi.CurrentAppConfig("").Lang instead.

  Regression tests cover CurrentApp wins over Apps[0], single-app
  fallback, and malformed bytes.

Change-Id: If7a276605f84f398cec329c2c942b471b4c32749
2026-05-28 20:53:15 +08:00
sammi-bytedance
893555a1b1 perf(im): parallelize reactions, thread_replies, and merge_forward fetches (#1146)
Follow-up to #1095. The reactions auto-enrichment shipped, but on busy chats the strictly-serial per-resource fetches in EnrichReactions, ExpandThreadReplies, and merge_forward expansion stretched the command's wall time above 14s — enough that wrapper agents (30–60s wall-clock budgets) saw timeouts even though the CLI itself never errored. This PR parallelizes all three with the same bounded-concurrency pattern, batches the follow-up contact-API sender resolution so it doesn't fan back out into a serial stall, and fixes two correctness bugs that surfaced during review. Scoped to convert_lib/{reactions,thread,merge,content_convert}.go + tests + the 4 shortcut Execute hooks + the reference doc.

Change-Id: I0206d10ad204382170bd42aec67f82578923736e
2026-05-28 19:25:11 +08:00
YangJunzhou-01
8d496b8a48 docs: update IM skill urgent APIs (#1153)
Add support for IM urgent messages.
Change-Id: Ide2416af6d3d47d35cfd4c60b31e2137889081c6
2026-05-28 19:22:41 +08:00
HanShaoshuai-k
01fe71d7db fix(config): allow lark-channel bind source override (#1154)
Change-Id: I406ea13e372e6bdd5f3d9d6210b04ebdf0354182
2026-05-28 18:56:36 +08:00
luozhixiong01
3b770558e5 feat: decouple --lang preference from TUI display language (#1132) 2026-05-28 18:55:40 +08:00
Kyalpha
3cd84fca90 test(drive): drop redundant CONFIG_DIR isolation in inspect Execute tests (#1121)
The six TestDriveInspectExecute_* tests set
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) but build the CLI via
cmdutil.TestFactory(t, cfg), which provides an in-memory config closure
(func() (*core.CliConfig, error) { return config, nil }) and never reads the
filesystem. Per the repo learning from PR #343, this env var should only be
set for tests exercising the real NewDefault() factory path. None of these
tests use NewDefault(), so the calls are dead and removed.

No behavior change; all TestDriveInspect* tests still pass.

Co-authored-by: kyalpha313 <kyalpha313@users.noreply.github.com>
2026-05-28 17:32:31 +08:00
YangJunzhou-01
c2e737434c fix(im): clarify messages-send dry-run chat membership (#1150)
clarify messages-send dry-run chat membership
2026-05-28 16:39:00 +08:00
zgz2048
b91f6a23f3 fix: include log_id in base attachment media errors (#1133) 2026-05-28 11:54:18 +08:00
bubbmon233
bbef3cbfb1 feat(mail): HTML lint library + Larksuite-native autofix + lark-mail … (#1019)
* feat(mail): HTML lint library + Larksuite-native autofix + lark-mail skill

为 lark-cli mail 域写信链路引入 HTML lint 能力,提升邮件 HTML 的兼容性、
安全性与 Larksuite-native 格式适配。

lint 库(shortcuts/mail/lint/):
- 四档分类:pass / native-autofix / warn-autofix / error-strip
- 安全规则覆盖 script / iframe / on* 事件处理器 / javascript: 及其它
  危险 URL scheme 等 XSS 向量,未知 scheme 一律删除并归 error
- Larksuite-native 格式自动修复:双层 div 段落、原生多级列表结构、
  灰边引用、Larksuite 蓝链接
- cleaned_html 输出确定性稳定(位置索引派生 data-ol-id),便于
  golden-file 测试与缓存

+lint-html 独立预检 shortcut:
- 只读、不调 API、不建草稿,供 AI / 用户 / CI 在写信前预览 lint 结果

写入路径内置 lint(6 个 compose shortcut):
- +send / +draft-create / +draft-edit / +reply / +reply-all / +forward
  在 emlbuilder 之前强制 lint 净化 HTML
- 默认 envelope 对 lint 改动透明(无 lint 字段),保持小巧供 AI 消费;
  --show-lint-details 显式取证返回 lint_applied[] / original_blocked[]
- --body-file 支持从文件读取 body(32MB 上限),与 --body 互斥

预制 HTML 邮件模板(skills/lark-mail/assets/templates/):
- 资讯周报 / 个人周报 / 团队周报 / 调研报告 / 求职简历 5 套
- 按 Larksuite mail-editor 原生格式编写,含正确的多级列表嵌套结构

lark-mail skill 文档:
- references/lark-mail-html.md:邮件 HTML 写法指南(24 个格式 section
  + 颜色调色盘 + URL scheme + 官方模板套用流程)
- references/lark-mail-lint-html.md:+lint-html 用法
- SKILL.md 顶部 CRITICAL 引导

* fix(mail): remove unused readAttr func and apply gofmt

Drop the unused `readAttr` helper in shortcuts/mail/lint/linter.go
that was flagged by golangci-lint (unused linter). Apply gofmt to
linter.go and rules.go which had minor formatting issues.

* fix(mail): address compose lint and guidance
2026-05-27 22:23:32 +08:00
113 changed files with 10366 additions and 376 deletions

View File

@@ -2,6 +2,44 @@
All notable changes to this project will be documented in this file.
## [v1.0.44] - 2026-05-29
### Features
- **base**: Add dashboard block data shortcut and workflow docs (#1067)
- **im**: Support `--types` flag for listing p2p single chats in `chat-list` (#1077)
- **agent**: Add agent header support (#1158)
### Bug Fixes
- **im**: Correct 64-bit MP4 box size handling to prevent panic on crafted media (#1165)
- **install**: Detect curl version before using `--ssl-revoke-best-effort` (#1124)
- **vc**: Correct `--minute-token` to `--minute-tokens` in recording reference (#1170)
- **whiteboard**: Fix whiteboard skill (#1166)
## [v1.0.43] - 2026-05-28
### Features
- **event**: Support `note` generated event (#1159)
- **config**: Decouple `--lang` preference from TUI display language (#1132)
- **mail**: Add HTML lint library with Larksuite-native autofix for `lark-mail` (#1019)
### Bug Fixes
- **config**: Propagate `Lang` across credential boundary; respect `CurrentApp` in priorLang (#1157)
- **config**: Allow lark-channel bind source override (#1154)
- **im**: Clarify `messages-send` dry-run chat membership (#1150)
- **base**: Include `log_id` in attachment media errors (#1133)
### Performance
- **im**: Parallelize reactions, thread_replies, and merge_forward fetches (#1146)
### Documentation
- **im**: Update IM skill urgent APIs (#1153)
## [v1.0.42] - 2026-05-27
### Features
@@ -910,6 +948,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40

View File

@@ -16,6 +16,7 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -121,7 +122,7 @@ func authLoginRun(opts *LoginOptions) error {
}
// Determine UI language from saved config
lang := "zh"
var lang i18n.Lang
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
if app := multi.FindApp(config.ProfileName); app != nil {
lang = app.Lang
@@ -177,7 +178,7 @@ func authLoginRun(opts *LoginOptions) error {
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
if err != nil {
return err
}

View File

@@ -3,6 +3,8 @@
package auth
import "github.com/larksuite/cli/internal/i18n"
type loginMsg struct {
// Interactive UI (login_interactive.go)
SelectDomains string
@@ -115,8 +117,8 @@ var loginMsgEn = &loginMsg{
}
// getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
func getLoginMsg(lang i18n.Lang) *loginMsg {
if lang.IsEnglish() {
return loginMsgEn
}
return loginMsgZh

View File

@@ -8,6 +8,8 @@ import (
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetLoginMsg_Zh(t *testing.T) {
@@ -31,7 +33,7 @@ func TestGetLoginMsg_En(t *testing.T) {
}
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} {
msg := getLoginMsg(lang)
if msg != loginMsgZh {
t.Errorf("getLoginMsg(%q) should default to zh", lang)
@@ -61,7 +63,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string)
}
func TestLoginMsg_FormatStrings(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
msg := getLoginMsg(lang)
// LoginSuccess should contain two %s placeholders (userName, openId)
@@ -102,10 +104,10 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
// after presenting the URL instead of blocking in the same turn.
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
hint := getLoginMsg(lang).AgentTimeoutHint
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
if lang == "zh" && want == "turn" {
if lang == i18n.LangZhCN && want == "turn" {
want = "本轮"
}
if !strings.Contains(hint, want) {

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -37,8 +38,10 @@ type BindOptions struct {
// this flag because its own prompts already require human confirmation.
Force bool
Lang string
langExplicit bool // true when --lang was explicitly passed
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags
langExplicit bool // true when --lang was explicitly passed
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
// the account being bound. Populated after resolveAccount; TUI stages
@@ -55,7 +58,7 @@ type BindOptions struct {
// NewCmdConfigBind creates the config bind subcommand.
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
opts := &BindOptions{Factory: f}
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
cmd := &cobra.Command{
Use: "bind",
@@ -102,7 +105,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -147,7 +150,7 @@ func configBindRun(opts *BindOptions) error {
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
return err
}
applyPreferences(appConfig, opts)
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
@@ -202,16 +205,18 @@ func finalizeSource(opts *BindOptions) (string, error) {
// TUI: prompt for language before any downstream prompts. The source
// selection itself may still be skipped entirely if --source or the
// env already pinned it.
// env already pinned it. Picker offers 2 options (中文 / English) and
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection("")
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", err
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = lang
opts.Lang = string(lang)
opts.UILang = lang
}
if explicit != "" {
@@ -245,7 +250,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
return existingBinding{}, err
}
if action == "cancel" {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
return existingBinding{Cancelled: true}, nil
}
@@ -329,7 +334,7 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
if !hasStrictBotLock(previousConfigBytes) {
return nil
}
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
return output.ErrWithHint(output.ExitValidation, "bind",
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
}
@@ -347,14 +352,23 @@ func noticeUserDefaultRisk(opts *BindOptions) {
if opts.IsTUI || opts.Identity != "user-default" {
return
}
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
}
// applyPreferences expands the chosen identity preset into the underlying
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
// profile's intent survives later changes to global strict-mode settings.
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
// preferredLang resolves the language to persist: the requested value when set,
// otherwise the prior one — so an unset --lang never clears a stored preference.
func preferredLang(requested, prior i18n.Lang) i18n.Lang {
if requested != "" {
return requested
}
return prior
}
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
switch opts.Identity {
case "bot-only":
sm := core.StrictModeBot
@@ -365,9 +379,23 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsUser
}
if opts.Lang != "" {
appConfig.Lang = opts.Lang
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
}
// priorLang returns the language preference recorded in a previous config, or
// "" if there is none / the bytes don't parse. Reads from CurrentApp (or Apps[0]
// fallback) — scanning all apps for the first non-empty Lang would leak the
// wrong profile's preference into a re-bind when the workspace holds multiple
// named profiles and the active one disagrees with Apps[0].
func priorLang(previousConfigBytes []byte) i18n.Lang {
var multi core.MultiAppConfig
if json.Unmarshal(previousConfigBytes, &multi) != nil {
return ""
}
if app := multi.CurrentAppConfig(""); app != nil {
return app.Lang
}
return ""
}
// commitBinding finalizes the bind: atomic write of the new workspace config,
@@ -393,7 +421,10 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
}
replaced := previousConfigBytes != nil
msg := getBindMsg(opts.Lang)
// uiMsg renders human-facing TUI text (stderr success banner). Follows
// opts.UILang — zh by default; picker can flip it to en. --lang does
// not influence the TUI language.
uiMsg := getBindMsg(opts.UILang)
display := sourceDisplayName(source)
if replaced {
@@ -401,7 +432,11 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
}
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice)
if opts.langExplicit && opts.Lang != "" {
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang))
}
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
// stderr is enough and a machine-readable JSON dump on stdout is just
@@ -419,12 +454,17 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
"replaced": replaced,
"identity": opts.Identity,
}
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
// JSON "message" follows the effective preference on disk (appConfig.Lang),
// not the raw --lang value: when --lang is omitted on re-bind, preferredLang
// has already inherited the prior preference into appConfig.Lang, and the
// message should respect that inherited choice. stderr above follows UILang.
prefMsg := getBindMsg(appConfig.Lang)
brand := brandDisplay(string(appConfig.Brand), appConfig.Lang)
switch opts.Identity {
case "bot-only":
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
case "user-default":
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
}
resultJSON, _ := json.Marshal(envelope)
@@ -461,7 +501,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core
// tuiSelectSource prompts user to choose bind source.
func tuiSelectSource(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
var source string
// Pre-select based on detected env signals
@@ -486,7 +526,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.SelectSource).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))).
Options(
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
@@ -508,7 +548,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
// tuiSelectApp prompts the user to choose from multiple account candidates.
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
options := make([]huh.Option[int], 0, len(candidates))
for i, c := range candidates {
label := c.AppID
@@ -522,7 +562,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))).
Options(options...).
Value(&selected),
),
@@ -539,7 +579,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
msg := getBindMsg(opts.Lang)
msg := getBindMsg(opts.UILang)
// Build existing binding summary
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
@@ -591,6 +631,11 @@ func validateBindFlags(opts *BindOptions) error {
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
}
}
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
@@ -606,8 +651,8 @@ func validateBindFlags(opts *BindOptions) error {
// DescriptionFunc approach breaks here because a longer description on
// hover pushes options out of the field's initial viewport.
func tuiSelectIdentity(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.Lang)
brand := brandDisplay(opts.Brand, opts.Lang)
msg := getBindMsg(opts.UILang)
brand := brandDisplay(opts.Brand, opts.UILang)
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
var value string

View File

@@ -3,6 +3,8 @@
package config
import "github.com/larksuite/cli/internal/i18n"
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
//
// Brand-aware strings use a %s slot where the UI-friendly product name
@@ -84,6 +86,11 @@ type bindMsg struct {
// require in-flow human confirmation.
IdentityEscalationMessage string
IdentityEscalationHint string
// LangPreferenceSet is printed to stderr after a successful bind when the
// user explicitly passed --lang. Format: language code. Not printed when
// --lang was not explicit (i.e., the cobra default zh stayed in effect).
LangPreferenceSet string
}
var bindMsgZh = &bindMsg{
@@ -116,6 +123,8 @@ var bindMsgZh = &bindMsg{
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "语言偏好已设置:%s",
}
var bindMsgEn = &bindMsg{
@@ -150,10 +159,13 @@ var bindMsgEn = &bindMsg{
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "Language preference set to: %s",
}
func getBindMsg(lang string) *bindMsg {
if lang == "en" {
// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getBindMsg(lang i18n.Lang) *bindMsg {
if lang.IsEnglish() {
return bindMsgEn
}
return bindMsgZh
@@ -164,11 +176,11 @@ func getBindMsg(lang string) *bindMsg {
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
// this is the safe default when the brand hasn't been resolved yet (for
// example, on the pre-binding source-selection screen).
func brandDisplay(brand, lang string) string {
func brandDisplay(brand string, lang i18n.Lang) string {
if brand == "lark" || brand == "Lark" || brand == "LARK" {
return "Lark"
}
if lang == "en" {
if lang.IsEnglish() {
return "Feishu"
}
return "飞书"

View File

@@ -16,6 +16,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
@@ -120,14 +121,229 @@ func TestConfigBindCmd_LangDefault(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "zh" {
t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh")
if gotOpts.Lang != "" {
t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "")
}
if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang not passed")
}
}
// TestConfigBindRun_InvalidLang verifies a non-empty --lang is strictly
// validated: wrong case, typos, and removed codes all exit with
// ExitValidation (code 2) and a message identifying the offending value.
// (Empty is not invalid — see TestConfigBindRun_EmptyLangIsNoOp.)
func TestConfigBindRun_InvalidLang(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
cases := []struct {
name string
lang string
}{
{"wrong case ZH", "ZH"},
{"typo frr", "frr"},
{"removed code ar", "ar"},
{"unknown xx", "xx"},
{"hyphen form zh-CN", "zh-CN"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Lang: tc.lang,
langExplicit: true,
})
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
}
// TestConfigBindRun_EmptyLangIsNoOp verifies that an empty --lang (omitted or
// explicit "") is unset: it neither errors nor persists a language, while a
// non-empty short code or Feishu locale both canonicalize to the same locale.
func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
cases := []struct {
name string
lang string
explicit bool
wantLang i18n.Lang
}{
{"omitted", "", false, ""},
{"explicit empty", "", true, ""},
{"short code", "ja", true, i18n.LangJaJP},
{"feishu locale", "ja_jp", true, i18n.LangJaJP},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Lang: tc.lang,
langExplicit: tc.explicit,
}); err != nil {
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
app := multi.CurrentAppConfig("")
if app == nil {
t.Fatal("no app persisted")
}
if app.Lang != tc.wantLang {
t.Errorf("persisted Lang = %q, want %q", app.Lang, tc.wantLang)
}
})
}
}
// TestConfigBindRun_OmitLangPreservesPrior guards against a re-bind without
// --lang silently dropping a previously stored preference (appConfig is rebuilt
// fresh, so commitBinding must inherit the prior Lang).
func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f1, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "ja", langExplicit: true}); err != nil {
t.Fatalf("first bind (--lang ja): %v", err)
}
f2, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
t.Fatalf("re-bind (no --lang): %v", err)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if app := multi.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("Lang after re-bind = %v, want %q (preserved)", app, i18n.LangJaJP)
}
}
// TestPriorLang_RespectsCurrentApp guards against priorLang scanning all apps
// and silently returning a non-current profile's Lang. In a multi-profile
// workspace (set up via `profile add` before a re-bind), the active profile's
// Lang must win over a sibling profile that happens to sit earlier in the slice.
func TestPriorLang_RespectsCurrentApp(t *testing.T) {
multi := core.MultiAppConfig{
CurrentApp: "active",
Apps: []core.AppConfig{
{Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP},
{Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS},
},
}
bytes, err := json.Marshal(multi)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got := priorLang(bytes); got != i18n.LangEnUS {
t.Errorf("priorLang = %q, want %q (must follow CurrentApp, not Apps[0])", got, i18n.LangEnUS)
}
}
// TestPriorLang_FallsBackToFirstAppWhenCurrentUnset covers the legacy
// single-app shape (no CurrentApp): CurrentAppConfig falls back to Apps[0],
// so a bind-written config (which always has exactly one app and no
// CurrentApp field) still inherits its Lang.
func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) {
multi := core.MultiAppConfig{
Apps: []core.AppConfig{
{AppId: "cli_only", Lang: i18n.LangJaJP},
},
}
bytes, err := json.Marshal(multi)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got := priorLang(bytes); got != i18n.LangJaJP {
t.Errorf("priorLang = %q, want %q", got, i18n.LangJaJP)
}
}
// TestPriorLang_MalformedReturnsEmpty exercises the unparseable-bytes branch.
func TestPriorLang_MalformedReturnsEmpty(t *testing.T) {
if got := priorLang([]byte("not json")); got != "" {
t.Errorf("priorLang(malformed) = %q, want \"\"", got)
}
}
// TestConfigBindRun_EnvelopeMessageFollowsInheritedLang guards the JSON envelope
// "message" field against regressing to opts.Lang: when --lang is omitted on
// re-bind, the inherited preference (appConfig.Lang) must drive the message
// language and the embedded brand display — otherwise an AI agent that set
// English on first bind sees Chinese in every subsequent re-bind envelope.
func TestConfigBindRun_EnvelopeMessageFollowsInheritedLang(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f1, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "en", langExplicit: true}); err != nil {
t.Fatalf("first bind (--lang en): %v", err)
}
f2, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
t.Fatalf("re-bind (no --lang): %v", err)
}
envelope := map[string]any{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
msg, _ := envelope["message"].(string)
enMsg := getBindMsg(i18n.LangEnUS)
wantMsg := fmt.Sprintf(enMsg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", i18n.LangEnUS))
if msg != wantMsg {
t.Errorf("envelope.message = %q,\nwant %q (must follow inherited appConfig.Lang=en_us, not raw opts.Lang)", msg, wantMsg)
}
}
// ── Run function tests (aligned with TestConfigShowRun pattern) ──
func TestConfigBindRun_InvalidSource(t *testing.T) {
@@ -1474,10 +1690,14 @@ func TestGetBindMsg_En(t *testing.T) {
}
}
func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) {
msg := getBindMsg("fr")
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want)
func TestGetBindMsg_NonEnLang_FallsBackToZh(t *testing.T) {
// Only zh and en TUI bundles exist; any non-English language (canonical
// locale, short code, or unrecognized value) falls back to zh.
for _, lang := range []i18n.Lang{"fr_fr", "ja_jp", "ko", "unknown", ""} {
msg := getBindMsg(lang)
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
t.Errorf("getBindMsg(%q) SelectSource = %q, want %q (zh fallback)", lang, msg.SelectSource, want)
}
}
}
@@ -1640,3 +1860,36 @@ func TestHasStrictBotLock(t *testing.T) {
})
}
}
// TestConfigBindRun_LangExplicit_PrintsConfirmation covers the flag-mode
// confirmation line: when --lang is explicit, bind prints "language preference
// set" to stderr (rendered in the TUI language, embedding the preference value).
func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Identity: "bot-only",
Lang: "en",
langExplicit: true,
})
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}
// The short --lang en is canonicalized to en_us before the confirmation
// echoes it back; the TUI language stays zh (flag mode, no picker).
want := fmt.Sprintf(getBindMsg(i18n.LangZhCN).LangPreferenceSet, "en_us")
if got := stderr.String(); !strings.Contains(got, want) {
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
}
}

View File

@@ -389,10 +389,12 @@ func resolveHermesEnvPath() string {
}
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
// config.json. Mirrors the bridge's src/config/paths.ts which hardcodes
// ~/.lark-channel/config.json with no env override — multi-instance is not
// a supported scenario today.
// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected
// single-account config without changing lark-cli's target config directory.
func resolveLarkChannelConfigPath() string {
if p := os.Getenv("LARK_CHANNEL_CONFIG"); strings.TrimSpace(p) != "" {
return expandHome(p)
}
home, err := vfs.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)

View File

@@ -4,6 +4,7 @@
package config
import (
"path/filepath"
"reflect"
"testing"
@@ -173,3 +174,27 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
}
assertCandidate(t, got, Candidate{AppID: "cli_b"})
}
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, ".lark-channel", "config.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}
func TestResolveLarkChannelConfigPath_EnvOverride(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "~/bridge/projection.json")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, "bridge", "projection.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -151,8 +152,9 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "en" {
t.Errorf("expected Lang en, got %s", gotOpts.Lang)
// --lang en is canonicalized to en_us in RunE before runF captures opts.
if gotOpts.Lang != string(i18n.LangEnUS) {
t.Errorf("expected Lang en_us, got %s", gotOpts.Lang)
}
if !gotOpts.langExplicit {
t.Error("expected langExplicit=true when --lang is passed")
@@ -173,14 +175,82 @@ func TestConfigInitCmd_LangDefault(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "zh" {
t.Errorf("expected default Lang zh, got %s", gotOpts.Lang)
if gotOpts.Lang != "" {
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang)
}
if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang is not passed")
}
}
// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path:
// re-running init without --lang must inherit the prior preference, not clear it.
func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
}}
if err := core.SaveMultiAppConfig(existing); err != nil {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP)
}
}
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
// strictly validated the same way bind validates: wrong-case / typo / removed
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
func TestConfigInitCmd_InvalidLang(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
lang string
}{
{"wrong case ZH", "ZH"},
{"typo frr", "frr"},
{"removed code ar", "ar"},
{"unknown xx", "xx"},
{"hyphen form zh-CN", "zh-CN"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigInit(f, nil)
f.IOStreams.In = strings.NewReader("sec\n")
cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"})
err := cmd.Execute()
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
}
func TestHasAnyNonInteractiveFlag(t *testing.T) {
tests := []struct {
name string
@@ -412,3 +482,59 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
})
}
}
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
// the same locale; an unrecognized value errors.
func TestValidateInitLang(t *testing.T) {
t.Run("empty is a no-op", func(t *testing.T) {
for _, explicit := range []bool{false, true} {
opts := &ConfigInitOptions{Lang: "", langExplicit: explicit}
if err := validateInitLang(opts); err != nil {
t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err)
}
if opts.Lang != "" {
t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang)
}
}
})
t.Run("short and locale canonicalize alike", func(t *testing.T) {
for _, in := range []string{"ja", "ja_jp"} {
opts := &ConfigInitOptions{Lang: in, langExplicit: true}
if err := validateInitLang(opts); err != nil {
t.Fatalf("--lang %q: unexpected error %v", in, err)
}
if opts.Lang != string(i18n.LangJaJP) {
t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP)
}
}
})
}
// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints
// to stderr only when --lang explicitly set a non-empty preference.
func TestPrintLangPreferenceConfirmation(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("explicit non-empty prints confirmation", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true})
got := stderr.String()
if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") {
t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got)
}
})
t.Run("implicit prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is implicit", got)
}
})
t.Run("explicit empty prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is empty", got)
}
})
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -31,9 +32,13 @@ type ConfigInitOptions struct {
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
Lang string
langExplicit bool // true when --lang was explicitly passed
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
@@ -45,7 +50,7 @@ type ConfigInitOptions struct {
// NewCmdConfigInit creates the config init subcommand.
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f}
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
cmd := &cobra.Command{
Use: "init",
@@ -63,6 +68,9 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
if err := validateInitLang(opts); err != nil {
return err
}
if err := guardAgentWorkspace(opts); err != nil {
return err
}
@@ -77,7 +85,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmdutil.SetRisk(cmd, "write")
@@ -85,6 +93,25 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
return cmd
}
// printLangPreferenceConfirmation echoes the set preference to stderr, only
// when --lang explicitly set a non-empty value.
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
if !opts.langExplicit || opts.Lang == "" {
return
}
msg := getInitMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, opts.Lang))
}
func validateInitLang(opts *ConfigInitOptions) error {
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
// Hermes Agent context, because the Agent has already provisioned an app
// and 'config bind' is the right tool for hooking lark-cli into it.
@@ -132,7 +159,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
}},
}
return core.SaveMultiAppConfig(config)
@@ -146,7 +173,13 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
cleanupOldConfig(existing, f, appId)
return saveAsOnlyApp(appId, secret, brand, lang)
var prior i18n.Lang
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
prior = app.Lang
}
}
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// saveAsProfile appends or updates a named profile in the config.
@@ -167,11 +200,10 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
}
multi.Apps[idx].Users = []core.AppUser{}
}
// Update existing profile
multi.Apps[idx].AppId = appId
multi.Apps[idx].AppSecret = secret
multi.Apps[idx].Brand = brand
multi.Apps[idx].Lang = lang
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
@@ -182,7 +214,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: lang,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
})
}
@@ -238,7 +270,7 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
app.AppId = appID
app.Brand = brand
app.Lang = lang
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
return core.SaveMultiAppConfig(existing)
}
@@ -283,29 +315,27 @@ func configInitRun(opts *ConfigInitOptions) error {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
return nil
}
// For interactive modes, prompt language selection if --lang was not explicitly set
// For interactive modes, prompt language selection if --lang was not explicitly set.
// Picker offers 2 options (中文 / English) and drives BOTH opts.Lang
// (preference) and opts.UILang (TUI rendering).
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
savedLang := ""
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
savedLang = app.Lang
}
}
lang, err := promptLangSelection(savedLang)
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return err
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = lang
opts.Lang = string(lang)
opts.UILang = lang
}
msg := getInitMsg(opts.Lang)
msg := getInitMsg(opts.UILang)
// Mode 3: Create new app directly (--new)
if opts.New {
@@ -324,6 +354,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
return nil
}
@@ -366,6 +397,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if result.Mode == "existing" {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
return nil
}
@@ -452,5 +484,6 @@ func configInitRun(opts *ConfigInitOptions) error {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
)
type initMsg struct {
@@ -26,6 +27,10 @@ type initMsg struct {
DetectedLarkTenant string
AppCreated string
ConfigSaved string
// LangPreferenceSet is printed to stderr after a successful init when the
// user explicitly passed --lang. Format: language code.
LangPreferenceSet string
}
var initMsgZh = &initMsg{
@@ -43,6 +48,7 @@ var initMsgZh = &initMsg{
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
LangPreferenceSet: "语言偏好已设置:%s",
}
var initMsgEn = &initMsg{
@@ -60,29 +66,27 @@ var initMsgEn = &initMsg{
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
LangPreferenceSet: "Language preference set to: %s",
}
func getInitMsg(lang string) *initMsg {
if lang == "en" {
// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getInitMsg(lang i18n.Lang) *initMsg {
if lang.IsEnglish() {
return initMsgEn
}
return initMsgZh
}
// promptLangSelection shows an interactive language picker and returns the chosen lang code.
// savedLang is used as the pre-selected default (from existing config).
func promptLangSelection(savedLang string) (string, error) {
lang := savedLang
if lang != "en" {
lang = "zh"
}
// promptLangSelection shows the 中文/English picker and returns the chosen locale.
func promptLangSelection() (i18n.Lang, error) {
lang := i18n.LangZhCN
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
huh.NewSelect[i18n.Lang]().
Title("Language / 语言").
Options(
huh.NewOption("中文", "zh"),
huh.NewOption("English", "en"),
huh.NewOption("中文", i18n.LangZhCN),
huh.NewOption("English", i18n.LangEnUS),
).
Value(&lang),
),

View File

@@ -6,6 +6,8 @@ package config
import (
"fmt"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetInitMsg_Zh(t *testing.T) {
@@ -29,7 +31,7 @@ func TestGetInitMsg_En(t *testing.T) {
}
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} {
msg := getInitMsg(lang)
if msg != initMsgZh {
t.Errorf("getInitMsg(%q) should default to zh", lang)
@@ -62,6 +64,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
"LangPreferenceSet": msg.LangPreferenceSet,
}
for name, val := range fields {
if val == "" {
@@ -71,7 +74,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
}
func TestInitMsg_FormatStrings(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
msg := getInitMsg(lang)
// AppCreated and ConfigSaved should contain %s for App ID
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
@@ -84,3 +87,37 @@ func TestInitMsg_FormatStrings(t *testing.T) {
}
}
}
func TestGetInitMsg_BilingualCollapse(t *testing.T) {
// The TUI is bilingual (zh + en). Only English-bucket languages return the
// English struct — by canonical locale ("en_us") or legacy short ("en").
// Everything else (zh, the other codes, invalid, "") returns Chinese.
tests := []struct {
lang i18n.Lang
shouldBeEn bool
}{
{i18n.LangZhCN, false},
{i18n.LangEnUS, true},
{"en", true}, // legacy short value
{i18n.LangJaJP, false},
{"fr_fr", false},
{"invalid", false},
{"", false},
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
msg := getInitMsg(tt.lang)
if msg == nil {
t.Fatal("getInitMsg returned nil")
}
want := initMsgZh
if tt.shouldBeEn {
want = initMsgEn
}
if msg != want {
t.Errorf("getInitMsg(%q) returned wrong struct", tt.lang)
}
})
}
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
@@ -40,7 +41,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
_ = cmd.MarkFlagRequired("name")
@@ -55,6 +56,12 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
return output.ErrValidation("%v", err)
}
langPref, err := cmdutil.ParseLangFlag(lang)
if err != nil {
return err
}
lang = string(langPref)
// Read secret from stdin
if !appSecretStdin {
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
@@ -115,7 +122,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
AppId: appID,
AppSecret: secret,
Brand: parsedBrand,
Lang: lang,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
})

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs"
)
@@ -51,6 +52,56 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
}
}
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
// short codes and Feishu locales both canonicalize to the same stored locale,
// empty stores no preference, and an unrecognized value errors.
func TestProfileAddRun_Lang(t *testing.T) {
t.Run("short and locale canonicalize and persist alike", func(t *testing.T) {
for _, in := range []string{"ja", "ja_jp"} {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil {
t.Fatalf("--lang %q: profileAddRun() error = %v", in, err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if app := saved.FindApp("p"); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("--lang %q: stored Lang = %v, want %q", in, app, i18n.LangJaJP)
}
}
})
t.Run("empty stores no preference", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil {
t.Fatalf("profileAddRun() error = %v", err)
}
saved, _ := core.LoadMultiAppConfig()
if app := saved.FindApp("p"); app == nil || app.Lang != "" {
t.Errorf("stored Lang = %v, want \"\" (unset)", app)
}
})
t.Run("invalid lang errors", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "p", "app-p", true, "feishu", "ZH", false)
if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil")
}
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
}
})
}
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{

156
events/vc/note_generated.go Normal file
View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
const (
vcNoteArtifactTypeNote = 1
vcNoteArtifactTypeVerbatim = 2
vcNoteDetailRetryDelay = 500 * time.Millisecond
vcNoteDetailMaxRetries = 2
vcNoteDetailNotFoundCode = 121004
)
// VCNoteSourceOutput is the flattened note source payload.
type VCNoteSourceOutput struct {
SourceType string `json:"source_type,omitempty" desc:"Note source type"`
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
}
// VCNoteGeneratedOutput is the flattened shape for vc.note.generated_v1.
type VCNoteGeneratedOutput struct {
Type string `json:"type" desc:"Event type; always vc.note.generated_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
NoteID string `json:"note_id,omitempty" desc:"Note ID"`
NoteToken string `json:"note_token,omitempty" desc:"Generated note document token"`
VerbatimToken string `json:"verbatim_token,omitempty" desc:"Generated verbatim document token"`
NoteSource *VCNoteSourceOutput `json:"note_source,omitempty" desc:"Note source metadata"`
}
func processVCNoteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
NoteID string `json:"note_id"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &VCNoteGeneratedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
NoteID: envelope.Event.NoteID,
}
if out.Type == "" {
out.Type = raw.EventType
}
if rt != nil && out.NoteID != "" {
fillVCNoteGeneratedDetails(ctx, rt, out)
}
return json.Marshal(out)
}
func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VCNoteGeneratedOutput) {
if rt == nil || out == nil || out.NoteID == "" {
return
}
path := fmt.Sprintf(pathNoteDetailFmt, validate.EncodePathSegment(out.NoteID))
type noteDetailResp struct {
Data struct {
Note struct {
Artifacts []struct {
ArtifactType int `json:"artifact_type"`
DocToken string `json:"doc_token"`
} `json:"artifacts"`
NoteSource struct {
SourceEntityID string `json:"source_entity_id"`
SourceType string `json:"source_type"`
} `json:"note_source"`
} `json:"note"`
} `json:"data"`
}
for attempt := 0; attempt <= vcNoteDetailMaxRetries; attempt++ {
if attempt > 0 {
time.Sleep(vcNoteDetailRetryDelay)
}
raw, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
if isLarkCode(err, vcNoteDetailNotFoundCode) {
continue
}
return
}
var resp noteDetailResp
if err := json.Unmarshal(raw, &resp); err != nil {
continue
}
var noteToken, verbatimToken string
for _, artifact := range resp.Data.Note.Artifacts {
switch artifact.ArtifactType {
case vcNoteArtifactTypeNote:
if noteToken == "" {
noteToken = artifact.DocToken
}
case vcNoteArtifactTypeVerbatim:
if verbatimToken == "" {
verbatimToken = artifact.DocToken
}
}
}
if noteToken == "" && verbatimToken == "" {
continue
}
if noteToken != "" {
out.NoteToken = noteToken
}
if verbatimToken != "" {
out.VerbatimToken = verbatimToken
}
if src := resp.Data.Note.NoteSource; src.SourceType != "" || src.SourceEntityID != "" {
out.NoteSource = &VCNoteSourceOutput{
SourceType: src.SourceType,
SourceEntityID: src.SourceEntityID,
}
}
return
}
}
func isLarkCode(err error, code int) bool {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return exitErr.Detail.Code == code
}
return false
}

View File

@@ -0,0 +1,328 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_ProcessedNoteGeneratedRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeNoteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:note:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
}
func TestProcessVCNoteGenerated(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
var gotMethod, gotPath string
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
gotMethod = method
gotPath = path
if body != nil {
t.Fatalf("GET detail body = %#v, want nil", body)
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [
{"artifact_type": 1, "doc_token": "note_doc_token"},
{"artifact_type": 2, "doc_token": "verbatim_doc_token"}
],
"note_source": {
"source_type": "meeting",
"source_entity_id": "6911188411934433028"
}
}
}
}`), nil
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_001",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989000"
},
"event": {
"note_id": "6943848821689040898"
}
}`)
if gotMethod != "GET" {
t.Errorf("detail method = %q, want GET", gotMethod)
}
if gotPath != "/open-apis/vc/v1/notes/6943848821689040898" {
t.Errorf("detail path = %q", gotPath)
}
if out.Type != eventTypeNoteGenerated {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_vc_note_001" || out.Timestamp != "1608725989000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.NoteID != "6943848821689040898" {
t.Errorf("NoteID = %q", out.NoteID)
}
if out.NoteToken != "note_doc_token" {
t.Errorf("NoteToken = %q", out.NoteToken)
}
if out.VerbatimToken != "verbatim_doc_token" {
t.Errorf("VerbatimToken = %q", out.VerbatimToken)
}
if out.NoteSource == nil {
t.Fatal("NoteSource should not be nil")
}
if out.NoteSource.SourceType != "meeting" || out.NoteSource.SourceEntityID != "6911188411934433028" {
t.Errorf("NoteSource = %+v", out.NoteSource)
}
}
func TestVCNoteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeNoteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathNoteSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventTypeNoteGenerated)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathNoteUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventTypeNoteGenerated)
}
func TestProcessVCNoteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
called++
return nil, context.DeadlineExceeded
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_002",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989001"
},
"event": {
"note_id": "6943848821689040999"
}
}`)
if called != 1 {
t.Fatalf("detail API called %d times, want 1", called)
}
if out.NoteID != "6943848821689040999" {
t.Errorf("NoteID = %q", out.NoteID)
}
if out.NoteToken != "" || out.VerbatimToken != "" {
t.Errorf("NoteToken/VerbatimToken = %q/%q, want empty", out.NoteToken, out.VerbatimToken)
}
if out.NoteSource != nil {
t.Errorf("NoteSource = %+v, want nil", out.NoteSource)
}
}
func TestProcessVCNoteGenerated_EmptyTokensRetriesAndSucceeds(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
if called <= 1 {
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [],
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
}
}
}`), nil
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [
{"artifact_type": 1, "doc_token": "delayed_note_token"},
{"artifact_type": 2, "doc_token": "delayed_verbatim_token"}
],
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
}
}
}`), nil
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_empty_retry",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989000"
},
"event": {
"note_id": "6943848821689040empty"
}
}`)
if called != 2 {
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
}
if out.NoteToken != "delayed_note_token" {
t.Errorf("NoteToken = %q, want delayed_note_token", out.NoteToken)
}
if out.VerbatimToken != "delayed_verbatim_token" {
t.Errorf("VerbatimToken = %q, want delayed_verbatim_token", out.VerbatimToken)
}
}
func TestProcessVCNoteGenerated_EmptyTokensExhaustsRetries(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"note": {
"artifacts": [],
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
}
}
}`), nil
},
}
out := runNoteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_note_empty_exhaust",
"event_type": "vc.note.generated_v1",
"create_time": "1608725989000"
},
"event": {
"note_id": "6943848821689040emptyex"
}
}`)
wantCalls := 1 + vcNoteDetailMaxRetries
if called != wantCalls {
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
}
if out.NoteToken != "" || out.VerbatimToken != "" {
t.Errorf("NoteToken/VerbatimToken = %q/%q, want empty after exhausted retries", out.NoteToken, out.VerbatimToken)
}
}
func TestProcessVCNoteGenerated_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
raw := &event.RawEvent{
EventType: eventTypeNoteGenerated,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processVCNoteGenerated(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func runNoteGenerated(t *testing.T, rt event.APIClient, payload string) VCNoteGeneratedOutput {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeNoteGenerated,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processVCNoteGenerated(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out VCNoteGeneratedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid VCNoteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}

View File

@@ -18,6 +18,8 @@ const (
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
)
// Keys returns all VC-domain EventKey definitions.
@@ -39,5 +41,21 @@ func Keys() []event.KeyDefinition {
},
RequiredConsoleEvents: []string{eventTypeMeetingEnded},
},
{
Key: eventTypeNoteGenerated,
DisplayName: "Note generated",
Description: "Triggered when a note has been generated",
EventType: eventTypeNoteGenerated,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCNoteGeneratedOutput{})},
},
Process: processVCNoteGenerated,
PreConsume: subscriptionPreConsume(eventTypeNoteGenerated, pathNoteSubscribe, pathNoteUnsubscribe),
Scopes: []string{"vc:note:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
},
}
}

View File

@@ -205,14 +205,32 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := strings.TrimSpace(string(errBody))
if msg != "" {
return nil, output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
err := output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
attachStreamLogID(err, resp.Header)
return nil, err
}
return nil, output.ErrNetwork("HTTP %d", resp.StatusCode)
err := output.ErrNetwork("HTTP %d", resp.StatusCode)
attachStreamLogID(err, resp.Header)
return nil, err
}
return resp, nil
}
func attachStreamLogID(err *output.ExitError, header http.Header) {
if err == nil || err.Detail == nil {
return
}
logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId))
if logID == "" {
logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId))
}
if logID == "" {
return
}
err.Detail.Detail = map[string]any{"log_id": logID}
}
type cancelOnCloseBody struct {
io.ReadCloser
cancel context.CancelFunc

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client_test
import (
"context"
"errors"
"net/http"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
factory, _, _, reg := cmdutil.TestFactory(t, config)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/drive/v1/medias/file_token/download",
Status: http.StatusForbidden,
RawBody: []byte("forbidden"),
Headers: http.Header{
larkcore.HttpHeaderKeyLogId: []string{"202605270003"},
},
})
client, err := factory.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
_, err = client.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
}, core.AsBot)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
if detail["log_id"] != "202605270003" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
}

27
internal/cmdutil/lang.go Normal file
View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"strings"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
// ParseLangFlag validates and canonicalizes a --lang value, shared by config
// and profile so every entry point honors one contract. Empty is unset (no-op);
// a non-empty value must resolve via i18n.Parse or it errors.
func ParseLangFlag(raw string) (i18n.Lang, error) {
if raw == "" {
return "", nil
}
lang, ok := i18n.Parse(raw)
if !ok {
return "", output.ErrValidation(
"invalid --lang %q; valid values: %s",
raw, strings.Join(i18n.Codes(), ", "))
}
return lang, nil
}

View File

@@ -6,15 +6,18 @@ package cmdutil
import (
"context"
"net/http"
"os"
"reflect"
"runtime/debug"
"strings"
"sync"
"unicode"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/envvars"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
@@ -24,6 +27,7 @@ const (
HeaderBuild = "X-Cli-Build"
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
SourceValue = "lark-cli"
@@ -36,6 +40,8 @@ const (
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 256
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -43,6 +49,25 @@ func UserAgentValue() string {
return SourceValue + "/" + build.Version
}
// AgentTraceValue returns a header-safe value from the
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
// surrounding whitespace, rejects values containing any Unicode
// control character or exceeding agentTraceMaxLen, and returns ""
// for any invalid or empty value. Callers can use the result
// directly in HTTP headers without further sanitisation.
func AgentTraceValue() string {
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
if v == "" || len(v) > agentTraceMaxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}
// BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header {
h := make(http.Header)
@@ -50,6 +75,9 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}
return h
}

View File

@@ -6,10 +6,12 @@ package cmdutil
import (
"context"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
envcred "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -260,3 +262,134 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
}
}
// ---------------------------------------------------------------------------
// AgentTraceValue / HeaderAgentTrace
// ---------------------------------------------------------------------------
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
}
}
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
if got := AgentTraceValue(); got != "trace-abc-123" {
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
if got := AgentTraceValue(); got != "trace-trim" {
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTraceValue_RejectsLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
}
}
func TestAgentTraceValue_RejectsTab(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
}
}
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
}
}
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
}
}
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(envvars.CliAgentTrace, longVal)
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(envvars.CliAgentTrace, val)
if got := AgentTraceValue(); got != val {
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_IncludesAgentTraceHeaderWhenEnvSet(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-xyz-789")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "trace-xyz-789" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentTrace, v, "trace-xyz-789")
}
}
func TestBaseSecurityHeaders_AgentTraceTrimmedWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "trace-trim" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q (whitespace trimmed)", HeaderAgentTrace, v, "trace-trim")
}
}
func TestBaseSecurityHeaders_AgentTraceOnlyWhitespace_Skipped(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for whitespace-only value", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_AgentTraceRejectsCRLFInjection(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for CR/LF value", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_AgentTraceRejectsLFInjection(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for LF value", HeaderAgentTrace, v)
}
}

View File

@@ -11,6 +11,7 @@ import (
"strings"
"unicode/utf8"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -41,7 +42,7 @@ type AppConfig struct {
AppId string `json:"appId"`
AppSecret SecretInput `json:"appSecret"`
Brand LarkBrand `json:"brand"`
Lang string `json:"lang,omitempty"`
Lang i18n.Lang `json:"lang,omitempty"`
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
StrictMode *StrictMode `json:"strictMode,omitempty"`
Users []AppUser `json:"users"`
@@ -159,6 +160,7 @@ type CliConfig struct {
DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file)
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
}
@@ -264,6 +266,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
AppSecret: secret,
Brand: app.Brand,
DefaultAs: app.DefaultAs,
Lang: app.Lang,
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId

View File

@@ -12,6 +12,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
)
@@ -115,3 +116,45 @@ func TestFullChain_ConfigStrictMode(t *testing.T) {
t.Errorf("expected SupportsBot (%d), got %d", extcred.SupportsBot, acct.SupportedIdentities)
}
}
// TestFullChain_LangSurvivesProductionPath exercises the exact data flow the
// production Factory uses (factory_default.go Phase 3): disk → multi config →
// DefaultAccountProvider.ResolveAccount → Account → ToCliConfig. If Lang gets
// dropped at the credential boundary (as it would when Account lacks the field),
// shortcuts/common/runner.go RuntimeContext.Lang() returns "" and downstream
// consumers (mail signature, etc.) silently fall back to defaults — defeating
// the whole point of persisting --lang.
func TestFullChain_LangSurvivesProductionPath(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "cfg_app",
AppSecret: core.PlainSecret("cfg_secret"),
Brand: core.BrandFeishu,
Lang: i18n.LangJaJP,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "")
acct, err := defaultAcct.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount: %v", err)
}
if acct.Lang != i18n.LangJaJP {
t.Errorf("Account.Lang = %q, want %q (DefaultAccountProvider must propagate Lang from config)", acct.Lang, i18n.LangJaJP)
}
cfg := acct.ToCliConfig()
if cfg == nil {
t.Fatal("ToCliConfig() = nil")
}
if cfg.Lang != i18n.LangJaJP {
t.Errorf("CliConfig.Lang = %q, want %q (this is the value RuntimeContext.Lang() reads in production)", cfg.Lang, i18n.LangJaJP)
}
}

View File

@@ -10,6 +10,7 @@ import (
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
// Account is the credential-layer view of the active runtime account.
@@ -23,6 +24,7 @@ type Account struct {
DefaultAs core.Identity
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8
}
@@ -65,6 +67,7 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account {
DefaultAs: cfg.DefaultAs,
UserOpenId: cfg.UserOpenId,
UserName: cfg.UserName,
Lang: cfg.Lang,
SupportedIdentities: cfg.SupportedIdentities,
}
}
@@ -82,6 +85,7 @@ func (a *Account) ToCliConfig() *core.CliConfig {
DefaultAs: a.DefaultAs,
UserOpenId: a.UserOpenId,
UserName: a.UserName,
Lang: a.Lang,
SupportedIdentities: a.SupportedIdentities,
}
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
func TestTokenTypeString(t *testing.T) {
@@ -53,6 +54,7 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
DefaultAs: "user",
UserOpenId: "ou_123",
UserName: "alice",
Lang: i18n.LangJaJP,
SupportedIdentities: 3,
}
@@ -63,6 +65,9 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName {
t.Fatalf("AccountFromCliConfig() = %#v, want copied fields from %#v", acct, cfg)
}
if acct.Lang != cfg.Lang {
t.Fatalf("AccountFromCliConfig().Lang = %q, want %q", acct.Lang, cfg.Lang)
}
roundtrip := acct.ToCliConfig()
if roundtrip == nil {
@@ -71,6 +76,9 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName {
t.Fatalf("ToCliConfig() = %#v, want copied fields from %#v", roundtrip, cfg)
}
if roundtrip.Lang != cfg.Lang {
t.Fatalf("ToCliConfig().Lang = %q, want %q (production Factory path reads Lang via this conversion)", roundtrip.Lang, cfg.Lang)
}
roundtrip.AppID = "mutated-cli"
acct.AppID = "mutated-account"

View File

@@ -18,4 +18,6 @@ const (
// Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
)

76
internal/i18n/lang.go Normal file
View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package i18n
// Lang is a Feishu locale (e.g. "zh_cn"); "" means unset.
type Lang string
const (
LangZhCN Lang = "zh_cn"
LangEnUS Lang = "en_us"
LangJaJP Lang = "ja_jp"
LangKoKR Lang = "ko_kr"
LangFrFR Lang = "fr_fr"
LangDeDE Lang = "de_de"
LangEsES Lang = "es_es"
LangItIT Lang = "it_it"
LangRuRU Lang = "ru_ru"
LangPtBR Lang = "pt_br"
LangThTH Lang = "th_th"
LangViVN Lang = "vi_vn"
LangIdID Lang = "id_id"
LangMsMY Lang = "ms_my"
)
type langEntry struct {
Code Lang // canonical Feishu locale
Short string // ISO 639-1 code, also accepted as input shorthand
}
// catalog is the single source of truth; order drives --help and error listing.
var catalog = []langEntry{
{LangZhCN, "zh"}, {LangEnUS, "en"}, {LangJaJP, "ja"}, {LangKoKR, "ko"},
{LangFrFR, "fr"}, {LangDeDE, "de"}, {LangEsES, "es"}, {LangItIT, "it"},
{LangRuRU, "ru"}, {LangPtBR, "pt"}, {LangThTH, "th"}, {LangViVN, "vi"},
{LangIdID, "id"}, {LangMsMY, "ms"},
}
// find matches a short code or Feishu locale against the catalog (case-sensitive).
func find(s string) (langEntry, bool) {
for _, e := range catalog {
if string(e.Code) == s || e.Short == s {
return e, true
}
}
return langEntry{}, false
}
// Parse resolves a short code or Feishu locale to its canonical Lang.
// "" and unrecognized values return ("", false).
func Parse(s string) (Lang, bool) {
e, ok := find(s)
return e.Code, ok
}
// IsEnglish reports whether l uses the English TUI bundle (robust to "en_us"
// and legacy "en").
func (l Lang) IsEnglish() bool {
e, _ := find(string(l))
return e.Code == LangEnUS
}
// Base returns the ISO 639-1 short code ("en_us" → "en"), or "" if unknown.
func (l Lang) Base() string {
e, _ := find(string(l))
return e.Short
}
// Codes lists the canonical locales, for --help and error messages.
func Codes() []string {
out := make([]string, len(catalog))
for i, e := range catalog {
out[i] = string(e.Code)
}
return out
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package i18n
import "testing"
func TestParse(t *testing.T) {
tests := []struct {
in string
want Lang
wantOK bool
}{
{"zh", LangZhCN, true}, // short code
{"zh_cn", LangZhCN, true}, // canonical locale
{"en", LangEnUS, true}, // short code
{"en_us", LangEnUS, true}, // canonical locale
{"ja", LangJaJP, true}, // short code
{"pt", LangPtBR, true}, // pt → pt_br, not pt_pt
{"ms", LangMsMY, true}, // ms → ms_my
{"", "", false}, // unset
{"ZH", "", false}, // case-sensitive
{"zh-CN", "", false}, // hyphen form not accepted
{"zh_CN", "", false}, // case-sensitive region
{"ar", "", false}, // not in the supported set
{"xx", "", false}, // unknown
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
got, ok := Parse(tt.in)
if got != tt.want || ok != tt.wantOK {
t.Errorf("Parse(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.wantOK)
}
})
}
}
func TestIsEnglish(t *testing.T) {
tests := []struct {
lang Lang
want bool
}{
{LangEnUS, true},
{Lang("en"), true}, // legacy short value on disk stays robust
{LangZhCN, false},
{LangJaJP, false},
{Lang("zh"), false},
{Lang(""), false}, // unset → not English (zh bundle)
{Lang("garbage"), false},
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
if got := tt.lang.IsEnglish(); got != tt.want {
t.Errorf("Lang(%q).IsEnglish() = %v, want %v", tt.lang, got, tt.want)
}
})
}
}
func TestBase(t *testing.T) {
tests := []struct {
lang Lang
want string
}{
{LangEnUS, "en"},
{LangZhCN, "zh"},
{LangJaJP, "ja"},
{Lang("en"), "en"}, // legacy short value
{Lang("zh"), "zh"},
{Lang(""), ""}, // unset
{Lang("garbage"), ""}, // unknown
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
if got := tt.lang.Base(); got != tt.want {
t.Errorf("Lang(%q).Base() = %q, want %q", tt.lang, got, tt.want)
}
})
}
}
func TestCodes(t *testing.T) {
codes := Codes()
if len(codes) != 14 {
t.Fatalf("len(Codes()) = %d, want 14", len(codes))
}
if codes[0] != "zh_cn" {
t.Errorf("Codes()[0] = %q, want %q (catalog order)", codes[0], "zh_cn")
}
// Every code must round-trip through Parse to itself (canonical).
for _, c := range codes {
if got, ok := Parse(c); !ok || string(got) != c {
t.Errorf("Parse(%q) = (%q, %v), want (%q, true)", c, got, ok, c)
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.42",
"version": "1.0.44",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -110,6 +110,52 @@ function getMirrorUrls(env) {
return urls;
}
/**
* Decide from a `curl --version` output whether curl is >= 7.70.0 — the
* release (2020-04-29) that introduced --ssl-revoke-best-effort. Kept pure
* (no I/O) so the version-comparison logic can be unit tested without
* spawning a process. Reads the leading "curl X.Y.Z" token, ignoring the
* trailing "libcurl/X.Y.Z" that may report a different version.
*
* @param {string} versionOutput raw stdout of `curl --version`
* @returns {boolean} true when the parsed version is >= 7.70.0
*/
function isCurlVersionSupported(versionOutput) {
const match = String(versionOutput).match(/^\s*curl\s+(\d+)\.(\d+)\.(\d+)/i);
if (!match) return false;
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
return major > 7 || (major === 7 && minor >= 70);
}
// Memoized probe result. curl's version is invariant for the lifetime of the
// install, while download() runs once per mirror URL — so probe at most once.
let _curlSupportsSslRevokeBestEffort;
/**
* Detect whether the system curl supports --ssl-revoke-best-effort. Older
* versions (notably the curl 7.55.1 shipped with older Windows 10 builds)
* exit with "unknown option" if the flag is passed.
*
* @returns {boolean} true when curl >= 7.70.0 is available
*/
function curlSupportsSslRevokeBestEffort() {
if (_curlSupportsSslRevokeBestEffort !== undefined) {
return _curlSupportsSslRevokeBestEffort;
}
try {
const output = execFileSync("curl", ["--version"], {
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
timeout: 5000,
});
_curlSupportsSslRevokeBestEffort = isCurlVersionSupported(output);
} catch (_) {
_curlSupportsSslRevokeBestEffort = false;
}
return _curlSupportsSslRevokeBestEffort;
}
function download(url, destPath) {
assertAllowedHost(url);
const args = [
@@ -119,8 +165,11 @@ function download(url, destPath) {
"--output", destPath,
];
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
// errors when the certificate revocation list server is unreachable
if (isWindows) args.unshift("--ssl-revoke-best-effort");
// errors when the certificate revocation list server is unreachable.
// Only use it when the system curl is new enough (>= 7.70.0).
if (isWindows && curlSupportsSslRevokeBestEffort()) {
args.unshift("--ssl-revoke-best-effort");
}
args.push(url);
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
}
@@ -294,4 +343,4 @@ if (require.main === module) {
}
}
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls };
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, curlSupportsSslRevokeBestEffort, isCurlVersionSupported };

View File

@@ -9,7 +9,7 @@ const os = require("os");
const crypto = require("crypto");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls } = require("./install.js");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, isCurlVersionSupported } = require("./install.js");
describe("getExpectedChecksum", () => {
function makeTmpChecksums(content) {
@@ -278,3 +278,55 @@ describe("resolveMirrorUrls", () => {
);
});
});
describe("isCurlVersionSupported", () => {
// --ssl-revoke-best-effort was introduced in curl 7.70.0; below that the
// flag is unknown and `curl` exits non-zero (see issue #1099).
it("returns false for curl 7.55.1 (older Windows 10, flag unknown)", () => {
assert.equal(
isCurlVersionSupported("curl 7.55.1 (x86_64-pc-win32) libcurl/7.55.1"),
false
);
});
it("returns false for curl 7.69.0 (just below the 7.70.0 threshold)", () => {
assert.equal(
isCurlVersionSupported("curl 7.69.0 (x86_64-pc-win32) libcurl/7.69.0"),
false
);
});
it("returns true for curl 7.70.0 (flag introduced here)", () => {
assert.equal(
isCurlVersionSupported("curl 7.70.0 (x86_64-pc-win32) libcurl/7.70.0"),
true
);
});
it("returns true for a future major (curl 8.x)", () => {
assert.equal(
isCurlVersionSupported("curl 8.5.0 (x86_64-apple-darwin) libcurl/8.5.0"),
true
);
});
it("returns false when no version can be parsed", () => {
assert.equal(isCurlVersionSupported("not a curl version string"), false);
assert.equal(isCurlVersionSupported(""), false);
});
it("reads the leading 'curl X.Y.Z', not the trailing libcurl/X.Y.Z", () => {
// Guards the regex against latching onto "libcurl/7.55.1" when the
// curl binary itself is new enough.
assert.equal(
isCurlVersionSupported("curl 8.0.0 (x86_64) libcurl/7.55.1"),
true
);
});
it("does not match a 'libcurl X.Y.Z' token (anchored to leading curl)", () => {
// "libcurl 8.0.0" contains the substring "curl 8.0.0"; the leading
// anchor keeps it from being mistaken for a real curl version line.
assert.equal(isCurlVersionSupported("libcurl 8.0.0"), false);
});
});

View File

@@ -268,6 +268,39 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) {
})
}
// TestBaseDashboardBlockExecuteGetData tests the +dashboard-block-get-data command.
func TestBaseDashboardBlockExecuteGetData(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_chart/data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"dimensions": []interface{}{
map[string]interface{}{"field_name": "文本", "alias": "dim_text"},
},
"measures": []interface{}{
map[string]interface{}{"field_name": "Bitable_Dashboard_Count", "aggregation": "count_all", "alias": "me_count"},
},
"main_data": []interface{}{
map[string]interface{}{
"dim_text": map[string]interface{}{"value": "A"},
"me_count": map[string]interface{}{"value": 3},
},
},
},
},
})
if err := runShortcut(t, BaseDashboardBlockGetData, []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_chart"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"dimensions"`) || !strings.Contains(got, `"main_data"`) || !strings.Contains(got, `"dim_text"`) {
t.Fatalf("stdout=%s", got)
}
}
// TestBaseDashboardBlockExecuteCreate tests the +dashboard-block-create command.
func TestBaseDashboardBlockExecuteCreate(t *testing.T) {
t.Run("with data-config", func(t *testing.T) {
@@ -537,6 +570,19 @@ func TestBaseDashboardBlockDryRun_Get(t *testing.T) {
}
}
// TestBaseDashboardBlockDryRun_GetData tests the +dashboard-block-get-data --dry-run flag.
func TestBaseDashboardBlockDryRun_GetData(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_a", "--dry-run", "--format", "pretty"}
if err := runShortcut(t, BaseDashboardBlockGetData, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/blocks/blk_a/data") || !strings.Contains(got, "blk_a") {
t.Fatalf("stdout=%s", got)
}
}
// TestBaseDashboardBlockDryRun_Create tests the +dashboard-block-create --dry-run flag.
func TestBaseDashboardBlockDryRun_Create(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)

View File

@@ -11,6 +11,7 @@ import (
"image"
"image/color"
"image/png"
"net/http"
"net/url"
"os"
"path/filepath"
@@ -2200,7 +2201,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("download reports progress when later attachment fails", func(t *testing.T) {
t.Run("download reports progress and log_id when later attachment fails", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -2228,8 +2229,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_b/download",
Status: 500,
Status: 403,
RawBody: []byte("server error"),
Headers: http.Header{"X-Tt-Logid": []string{"202605270001"}},
})
tmpDir := t.TempDir()
@@ -2258,6 +2260,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
}
if detail["log_id"] != "202605270001" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
t.Fatalf("expected first file to remain: %v", err)
}

View File

@@ -146,7 +146,7 @@ func TestShortcutsCatalog(t *testing.T) {
"+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list",
"+form-submit",
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange",
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-get-data", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
}
if len(shortcuts) != len(want) {
t.Fatalf("len(shortcuts)=%d want=%d", len(shortcuts), len(want))

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseDashboardBlockGetData = common.Shortcut{
Service: "base",
Command: "+dashboard-block-get-data",
Description: "Get computed data for a dashboard chart block",
Risk: "read",
Scopes: []string{"base:dashboard:read"},
AuthTypes: authTypes(),
HasFormat: true,
Flags: []common.Flag{
baseTokenFlag(true),
blockIDFlag(true),
},
Tips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
"Use +dashboard-block-get first when you need block metadata like name, type, or data_config.",
"This command returns computed chart protocol JSON directly, not wrapped block metadata.",
"Text blocks do not have computed chart data; this shortcut is for chart/statistics blocks.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return dryRunDashboardBlockGetData(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeDashboardBlockGetData(runtime)
},
}

View File

@@ -104,6 +104,14 @@ func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext)
Params(params)
}
// dryRunDashboardBlockGetData returns a DryRunAPI for getting computed data for a dashboard block.
func dryRunDashboardBlockGetData(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/dashboards/blocks/:block_id/data").
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
// dryRunDashboardBlockCreate returns a DryRunAPI for creating a dashboard block.
func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
@@ -261,6 +269,16 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error {
return nil
}
// executeDashboardBlockGetData retrieves computed data for a dashboard chart block.
func executeDashboardBlockGetData(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", "blocks", runtime.Str("block-id"), "data"), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
// executeDashboardBlockCreate creates a new dashboard block.
func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)

View File

@@ -787,7 +787,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
QueryParams: query,
})
if err != nil {
return nil, output.ErrNetwork("download failed: %v", err)
return nil, err
}
defer resp.Body.Close()
@@ -835,6 +835,13 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
detail := map[string]interface{}{
"downloaded": downloaded,
"failed": failed,
}
if logID := baseAttachmentDownloadLogID(err); logID != "" {
detail["log_id"] = logID
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
@@ -842,10 +849,7 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
Code: exitErr.Detail.Code,
Message: msg,
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
Detail: map[string]interface{}{
"downloaded": downloaded,
"failed": failed,
},
Detail: detail,
},
Err: err,
}
@@ -865,6 +869,19 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
}
}
func baseAttachmentDownloadLogID(err error) string {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return ""
}
detail, ok := exitErr.Detail.Detail.(map[string]interface{})
if !ok {
return ""
}
logID, _ := detail["log_id"].(string)
return strings.TrimSpace(logID)
}
func outputPathLooksDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, string(filepath.Separator)) {
return true

View File

@@ -84,6 +84,7 @@ func Shortcuts() []common.Shortcut {
BaseDashboardArrange,
BaseDashboardBlockList,
BaseDashboardBlockGet,
BaseDashboardBlockGetData,
BaseDashboardBlockCreate,
BaseDashboardBlockUpdate,
BaseDashboardBlockDelete,

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -170,13 +171,34 @@ func ParseDriveMediaUploadResponse(apiResp *larkcore.ApiResp, action string) (ma
if larkCode := int(GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"])
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), driveMediaUploadErrorDetail(apiResp, result["error"]))
}
data, _ := result["data"].(map[string]interface{})
return data, nil
}
func driveMediaUploadErrorDetail(apiResp *larkcore.ApiResp, detail interface{}) interface{} {
logID := ""
if apiResp != nil {
logID = strings.TrimSpace(apiResp.LogId())
}
if logID == "" {
return detail
}
detailMap, ok := detail.(map[string]interface{})
if !ok {
if detail == nil {
return map[string]interface{}{"log_id": logID}
}
return map[string]interface{}{"error": detail, "log_id": logID}
}
if _, exists := detailMap["log_id"]; !exists {
detailMap["log_id"] = logID
}
return detailMap
}
func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string) (string, error) {
fileToken := GetString(data, "file_token")
if fileToken == "" {

View File

@@ -7,10 +7,12 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"strings"
"sync/atomic"
@@ -21,6 +23,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
var commonDriveMediaUploadTestSeq atomic.Int64
@@ -459,6 +462,24 @@ func TestParseDriveMediaUploadResponseErrors(t *testing.T) {
t.Fatalf("expected API error, got %v", err)
}
})
t.Run("api code error includes log_id", func(t *testing.T) {
t.Parallel()
resp := &larkcore.ApiResp{
RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`),
Header: http.Header{"X-Tt-Logid": []string{"202605270002"}},
}
_, err := ParseDriveMediaUploadResponse(resp, "upload media failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["log_id"] != "202605270002" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
})
}
func TestExtractDriveMediaUploadFileTokenRequiresToken(t *testing.T) {

View File

@@ -25,6 +25,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -72,6 +73,13 @@ func (ctx *RuntimeContext) IsBot() bool {
// UserOpenId returns the current user's open_id from config.
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
// Lang returns the user's preference as a canonical locale, or "" if unset or
// unrecognized; callers choose their own fallback.
func (ctx *RuntimeContext) Lang() i18n.Lang {
lang, _ := i18n.Parse(string(ctx.Config.Lang))
return lang
}
// BotInfo holds bot identity metadata fetched lazily from /bot/v3/info.
type BotInfo struct {
OpenID string

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
func TestRuntimeContext_Lang(t *testing.T) {
tests := []struct {
name string
stored i18n.Lang
want i18n.Lang
}{
{"canonical locale", i18n.LangJaJP, i18n.LangJaJP},
{"legacy short value normalizes", "ja", i18n.LangJaJP},
{"legacy short zh normalizes", "zh", i18n.LangZhCN},
{"unset stays empty", "", ""},
{"unrecognized stays empty", "klingon", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &RuntimeContext{Config: &core.CliConfig{Lang: tt.stored}}
if got := ctx.Lang(); got != tt.want {
t.Errorf("Lang() with stored %q = %q, want %q", tt.stored, got, tt.want)
}
})
}
}

View File

@@ -353,7 +353,6 @@ func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) {
// --- Execute tests ---
func TestDriveInspectExecute_DocxURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -395,7 +394,6 @@ func TestDriveInspectExecute_DocxURL(t *testing.T) {
}
func TestDriveInspectExecute_WikiURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -458,7 +456,6 @@ func TestDriveInspectExecute_WikiURL(t *testing.T) {
}
func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -487,7 +484,6 @@ func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
}
func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -524,7 +520,6 @@ func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
}
func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -548,7 +543,6 @@ func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
}
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)

View File

@@ -729,6 +729,18 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImMessagesSend dry run warns chat membership is not verified", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"text": "hello",
}, nil)
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, "Bot/user membership in the target chat is not verified") ||
!strings.Contains(got, "Bot/User can NOT be out of the chat") {
t.Fatalf("ImMessagesSend.DryRun() missing membership warning: %s", got)
}
})
t.Run("ImMessagesSend dry run uses placeholder media key for url input", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
@@ -742,6 +754,19 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImMessagesSend dry run preserves media and membership descriptions", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"image": "https://example.com/a.png",
}, nil)
mediaDesc := `"description":"dry-run uses placeholder media keys for --image URL input; execution uploads it before sending"`
membershipDesc := `"desc":"NOTE: dry-run validates request shape only. Bot/user membership in the target chat is not verified; the real send may fail with ` + "`Bot/User can NOT be out of the chat`" + `."`
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, mediaDesc) || !strings.Contains(got, membershipDesc) {
t.Fatalf("ImMessagesSend.DryRun() should preserve both descriptions: %s", got)
}
})
t.Run("ImMessagesMGet dry run expands message ids", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-ids": "om_1,om_2",

View File

@@ -32,6 +32,14 @@ type ConvertContext struct {
// SenderNames is a shared cache of open_id -> display name, accumulated across messages
// to avoid redundant contact API calls. May be nil.
SenderNames map[string]string
// MergeForwardSubItems is an optional pre-fetched cache of merge_forward
// sub-message lists, keyed by merge_forward message_id. When set, the
// merge_forward converter uses the cached entry instead of issuing its
// own GET; populated by callers via PrefetchMergeForwardSubItems before
// the FormatMessageItem loop. nil means "no prefetch — fall back to the
// per-message inline GET", which keeps non-shortcut callers (events,
// ad-hoc tests) working unchanged.
MergeForwardSubItems map[string][]map[string]interface{}
}
// converters maps message types to their ContentConverter implementations.
@@ -119,6 +127,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
if len(senderNames) > 0 {
nameCache = senderNames[0]
}
return formatMessageItem(m, runtime, nameCache, nil)
}
// FormatMessageItemWithMergePrefetch is like FormatMessageItem but threads a
// pre-fetched merge_forward sub-message map (typically built via
// PrefetchMergeForwardSubItems) through to the merge_forward converter so it
// can skip its own per-message GET. Shortcuts that iterate a page of raw
// items should pre-fetch once and call this variant in the loop to avoid the
// N × ~1s serial-merge_forward stall in the original code path.
func FormatMessageItemWithMergePrefetch(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} {
return formatMessageItem(m, runtime, nameCache, mergePrefetch)
}
func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} {
msgType, _ := m["msg_type"].(string)
messageId, _ := m["message_id"].(string)
mentions, _ := m["mentions"].([]interface{})
@@ -129,11 +151,12 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
if body, ok := m["body"].(map[string]interface{}); ok {
rawContent, _ := body["content"].(string)
content = ConvertBodyContent(msgType, &ConvertContext{
RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions),
MessageID: messageId,
Runtime: runtime,
SenderNames: nameCache,
RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions),
MessageID: messageId,
Runtime: runtime,
SenderNames: nameCache,
MergeForwardSubItems: mergePrefetch,
})
}

View File

@@ -4,11 +4,11 @@
package convertlib
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/larksuite/cli/internal/validate"
@@ -16,28 +16,53 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// mergeForwardPrefetchConcurrency caps in-flight merge_forward sub-message
// fetches when a shortcut pre-scans a page for merge_forward messages and
// prefetches their children concurrently. Each call is one ~700ms-1s
// GET /open-apis/im/v1/messages/{id} per merge_forward — strictly serial in
// FormatMessageItem before this change, which turned page-size 50 + 5
// merge_forward messages into ~8.5s of stall (measured on a real chat).
// GET /open-apis/im/v1/messages/{id} has no published per-app rate-limit at
// these levels, so we set this higher than the reactions batch_query cap
// (which sits at 4 to stay well under the gateway-layer 50/s + 1000/min
// explicit ceiling on the reactions endpoint).
const mergeForwardPrefetchConcurrency = 8
type mergeForwardConverter struct{}
// Convert expands merge_forward sub-messages into a tree when runtime is available,
// otherwise falls back to a summary string.
// Convert expands merge_forward sub-messages into a tree when runtime is
// available (or a pre-fetched cache was supplied), otherwise falls back to a
// summary string.
//
// When ctx.MergeForwardSubItems is non-nil (set by callers that pre-fetched
// the page's merge_forward children concurrently via
// PrefetchMergeForwardSubItems), Convert uses the cached items and skips the
// HTTP fetch entirely — this is how the shortcut layer turns N serial
// per-merge_forward GETs into one bounded-concurrency fan-out before the
// FormatMessageItem loop runs.
func (mergeForwardConverter) Convert(ctx *ConvertContext) string {
// When runtime is available, fetch sub-messages via API and expand into a tree.
// merge_forward body.content is typically a plain-text placeholder (e.g. "Merged and Forwarded Message"),
// not JSON with create_message_ids, so we must rely on the API to get actual sub-messages.
// Fast path: caller pre-fetched this merge_forward's sub-tree.
if ctx.MergeForwardSubItems != nil && ctx.MessageID != "" {
if cached, ok := ctx.MergeForwardSubItems[ctx.MessageID]; ok {
return renderMergeForwardTree(ctx, cached)
}
}
// Slow path: no pre-fetch; fall back to a per-merge_forward GET. Kept so
// callers that don't pre-fetch (e.g. event subscribers, ad-hoc Convert
// invocations in tests) still produce correct output, just serially.
// merge_forward body.content is typically a plain-text placeholder, not
// JSON with create_message_ids, so we must rely on the API to get actual
// sub-messages.
if ctx.Runtime != nil && ctx.MessageID != "" {
subItems, err := fetchMergeForwardSubMessages(ctx.MessageID, ctx.Runtime)
if err != nil {
return fmt.Sprintf("[Merged forward: fetch failed: %s]", err)
}
if len(subItems) > 0 {
// Resolve sender names using shared cache to avoid redundant API calls across merge_forward messages
nameMap := ResolveSenderNames(ctx.Runtime, subItems, ctx.SenderNames)
AttachSenderNames(subItems, nameMap)
childrenMap := BuildMergeForwardChildrenMap(subItems, ctx.MessageID)
return FormatMergeForwardSubTree(ctx.MessageID, childrenMap)
return renderMergeForwardTree(ctx, subItems)
}
}
// Fallback: try to extract message IDs from content (some older formats include them)
// Final fallback: try to extract message IDs from content (some older formats include them)
ids := ParseMergeForwardIDs(ctx.RawContent)
if len(ids) > 0 {
return fmt.Sprintf("[Merged forward: %d messages]", len(ids))
@@ -45,31 +70,158 @@ func (mergeForwardConverter) Convert(ctx *ConvertContext) string {
return "[Merged forward]"
}
// fetchMergeForwardSubMessages fetches all sub-messages in a merge_forward container
// via a single API call. Returns a flat list of raw message items with upper_message_id
// for tree reconstruction.
// renderMergeForwardTree resolves sender names for the supplied sub-items and
// produces the formatted forwarded-messages tree. Shared by the prefetch fast
// path and the inline fetch fallback so both produce identical output.
func renderMergeForwardTree(ctx *ConvertContext, subItems []map[string]interface{}) string {
nameMap := ResolveSenderNames(ctx.Runtime, subItems, ctx.SenderNames)
AttachSenderNames(subItems, nameMap)
childrenMap := BuildMergeForwardChildrenMap(subItems, ctx.MessageID)
return FormatMergeForwardSubTree(ctx.MessageID, childrenMap)
}
// PrefetchMergeForwardSubItems scans rawItems for merge_forward messages,
// concurrently fetches each one's flat sub-message list, and returns a map
// keyed by the merge_forward message_id. Callers thread the returned map
// through FormatMessageItemWithMergePrefetch (or directly into a
// ConvertContext.MergeForwardSubItems) so the per-item conversion loop can
// reuse cached sub-trees instead of issuing its own serial GET.
//
// Each fetch is independent (different message_id, different sub-tree), so
// concurrent goroutines never contend on shared mutable state — the result
// map is written under a mutex purely to make the map safe for concurrent
// inserts.
//
// On fetch failure: emit a stderr warning and intentionally do NOT insert
// the failed id into the result map. The downstream
// mergeForwardConverter.Convert path keys off "is this id present in the
// prefetch?" — by leaving the key absent on failure, Convert falls through
// to its inline-fetch slow path, which (a) gets a second attempt at the
// GET, and (b) if that ALSO fails, surfaces the real "[Merged forward:
// fetch failed: ...]" string the user used to see in stdout. Inserting nil
// would have silently produced an empty <forwarded_messages> tree instead,
// dropping the failure signal from the user-visible output.
//
// When nameCache is non-nil, this function also runs one batched
// ResolveSenderNames across every sub-item it fetched, populating the cache
// before returning. Without this step, each per-merge_forward render in the
// caller's loop would issue its own contact API request for any uncached
// sender, re-introducing an N × ~400ms serial stall (measured at 5
// merge_forwards × ~400ms = ~2s in production traces). Pre-populating the
// cache makes those per-render ResolveSenderNames calls effective no-ops.
func PrefetchMergeForwardSubItems(runtime *common.RuntimeContext, rawItems []interface{}, nameCache map[string]string) map[string][]map[string]interface{} {
if runtime == nil || len(rawItems) == 0 {
return nil
}
var ids []string
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
if m == nil {
continue
}
if mt, _ := m["msg_type"].(string); mt != "merge_forward" {
continue
}
id, _ := m["message_id"].(string)
if id == "" {
continue
}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil
}
result := make(map[string][]map[string]interface{}, len(ids))
if len(ids) == 1 {
// Single-message fast path: no goroutine overhead. Matches the
// pre-existing serial behavior bit-for-bit when only one
// merge_forward is present.
items, err := fetchMergeForwardSubMessages(ids[0], runtime)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: merge_forward_prefetch_failed: %s: %v\n", ids[0], err)
// Leave the key absent so Convert falls back to its inline GET
// path and surfaces "[Merged forward: fetch failed: ...]" if
// the retry also fails. See function godoc.
} else {
result[ids[0]] = items
}
batchResolveMergeForwardSenders(runtime, result, nameCache)
return result
}
var mu sync.Mutex
sem := make(chan struct{}, mergeForwardPrefetchConcurrency)
var wg sync.WaitGroup
for _, id := range ids {
// Add before the semaphore acquire — sync.WaitGroup godoc
// recommends Add precede the goroutine-spawning event.
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
items, err := fetchMergeForwardSubMessages(id, runtime)
mu.Lock()
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: merge_forward_prefetch_failed: %s: %v\n", id, err)
// Leave the key absent — see fast-path comment above.
} else {
result[id] = items
}
mu.Unlock()
}()
}
wg.Wait()
batchResolveMergeForwardSenders(runtime, result, nameCache)
return result
}
// batchResolveMergeForwardSenders gathers every sub-item across every
// prefetched merge_forward and runs a single ResolveSenderNames call against
// nameCache. No-op when nameCache is nil (callers that pre-fetched without
// caring about sender resolution, e.g. event subscribers that render on the
// fly) or when nothing was fetched.
func batchResolveMergeForwardSenders(runtime *common.RuntimeContext, prefetch map[string][]map[string]interface{}, nameCache map[string]string) {
if nameCache == nil || len(prefetch) == 0 {
return
}
var allSubItems []map[string]interface{}
for _, items := range prefetch {
allSubItems = append(allSubItems, items...)
}
if len(allSubItems) == 0 {
return
}
ResolveSenderNames(runtime, allSubItems, nameCache)
}
// fetchMergeForwardSubMessages fetches all sub-messages in a merge_forward
// container via a single API call. Returns a flat list of raw message items
// with upper_message_id for tree reconstruction.
//
// Uses DoAPIJSON so the response envelope's code/msg are checked and surfaced
// — earlier this used the low-level DoAPI and reported every non-zero code
// as a generic "empty data" error, hiding the real failure (e.g. a server
// "code: 2200 Internal Error" with its log_id would show up as just "empty
// data" in the output).
func fetchMergeForwardSubMessages(messageID string, runtime *common.RuntimeContext) ([]map[string]interface{}, error) {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: mergeForwardMessagesPath(messageID),
QueryParams: larkcore.QueryParams{
"user_id_type": []string{"open_id"},
"card_msg_content_type": []string{"raw_card_content"},
},
})
data, err := runtime.DoAPIJSON(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
"user_id_type": []string{"open_id"},
"card_msg_content_type": []string{"raw_card_content"},
}, nil)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return nil, fmt.Errorf("invalid response: %w", err)
}
data, _ := result["data"].(map[string]interface{})
// DoAPIJSON returns the envelope's `data` field; when the server's JSON
// has `code: 0` but omits `data` entirely, that field comes back as nil.
// Reading from a nil map in Go is safe (returns the zero value, never
// panics), but guarding explicitly makes the "successful empty
// response" path obvious and keeps a future signature change from
// silently introducing nil-deref hazards.
if data == nil {
return nil, fmt.Errorf("empty data")
return []map[string]interface{}{}, nil
}
rawItems, _ := data["items"].([]interface{})
items := make([]map[string]interface{}, 0, len(rawItems))
for _, raw := range rawItems {

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"testing"
"time"
)
@@ -86,7 +87,14 @@ func TestFetchMergeForwardSubMessages(t *testing.T) {
}
})
t.Run("empty data", func(t *testing.T) {
t.Run("empty data treated as no children", func(t *testing.T) {
// `code: 0` with no data field is a successful "no children" response
// after the switch to DoAPIJSON (which checks the response envelope's
// code/msg directly). Previously this was reported as a generic
// "empty data" error — which also masked real failures like a
// non-zero code with data: null — so a successful empty payload now
// returns (nil, nil) and lets Convert fall through to its summary
// fallback string.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_bad"):
@@ -96,11 +104,193 @@ func TestFetchMergeForwardSubMessages(t *testing.T) {
}
}))
_, err := fetchMergeForwardSubMessages("om_bad", runtime)
if err == nil || !strings.Contains(err.Error(), "empty data") {
t.Fatalf("fetchMergeForwardSubMessages() error = %v", err)
items, err := fetchMergeForwardSubMessages("om_bad", runtime)
if err != nil {
t.Fatalf("fetchMergeForwardSubMessages(success-but-empty) err = %v, want nil", err)
}
if len(items) != 0 {
t.Fatalf("fetchMergeForwardSubMessages(success-but-empty) items = %#v, want empty", items)
}
})
t.Run("non-zero code surfaces real error", func(t *testing.T) {
// Regression coverage for the bug that motivated the DoAPIJSON
// switch: a server response with code != 0 (here: 2200 Internal
// Error, observed in production for some merge_forward IDs) used to
// be silently reported as the generic "empty data" string, hiding
// the real code/msg/log_id. With DoAPIJSON the envelope's code is
// checked and surfaced as an ErrAPI containing the real message.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return convertlibJSONResponse(200, map[string]interface{}{
"code": 2200,
"msg": "Internal Error",
}), nil
}))
_, err := fetchMergeForwardSubMessages("om_err", runtime)
if err == nil {
t.Fatal("fetchMergeForwardSubMessages(code=2200) err = nil, want non-nil")
}
if !strings.Contains(err.Error(), "Internal Error") {
t.Fatalf("fetchMergeForwardSubMessages(code=2200) err = %q, want it to contain the real msg", err)
}
})
}
// TestPrefetchMergeForwardSubItems exercises the bounded-concurrency prefetch
// path: each merge_forward in the input gets its own GET fetched in
// parallel, and the returned map keys items by their merge_forward
// message_id. A goroutine cross-contamination bug would manifest as
// mis-keyed entries.
func TestPrefetchMergeForwardSubItems(t *testing.T) {
var (
mu sync.Mutex
callCount int
)
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// Each merge_forward's path ends with its message_id; key the
// returned child off that so the test can detect mis-attachment.
path := req.URL.Path
// The path looks like /open-apis/im/v1/messages/<encoded-id>; take
// the last segment.
lastSlash := strings.LastIndex(path, "/")
if lastSlash < 0 {
return nil, fmt.Errorf("unexpected path: %s", path)
}
hostID := path[lastSlash+1:]
mu.Lock()
callCount++
mu.Unlock()
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"message_id": "om_child_of_" + hostID,
"create_time": "1710500000000",
},
},
},
}), nil
}))
// Mix of merge_forward and non-merge_forward messages — only the former
// should be fetched. 5 merge_forwards is enough to exercise the
// bounded fan-out (cap = 4) rather than fall into a single-message fast
// path.
rawItems := []interface{}{
map[string]interface{}{"message_id": "om_mf_1", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_text_a", "msg_type": "text"},
map[string]interface{}{"message_id": "om_mf_2", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_3", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_image", "msg_type": "image"},
map[string]interface{}{"message_id": "om_mf_4", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_5", "msg_type": "merge_forward"},
}
got := PrefetchMergeForwardSubItems(runtime, rawItems, nil)
if callCount != 5 {
t.Fatalf("expected 5 merge_forward fetches, got %d", callCount)
}
wantIDs := []string{"om_mf_1", "om_mf_2", "om_mf_3", "om_mf_4", "om_mf_5"}
for _, id := range wantIDs {
children, ok := got[id]
if !ok {
t.Fatalf("prefetch map missing key %q (cross-thread contamination?)", id)
}
if len(children) != 1 {
t.Fatalf("prefetch[%s] children len = %d, want 1", id, len(children))
}
want := "om_child_of_" + id
if children[0]["message_id"] != want {
t.Fatalf("prefetch[%s] child id = %v, want %q — mis-attributed result", id, children[0]["message_id"], want)
}
}
for _, missing := range []string{"om_text_a", "om_image"} {
if _, ok := got[missing]; ok {
t.Fatalf("prefetch map should not contain non-merge_forward key %q", missing)
}
}
}
// TestPrefetchMergeForwardSubItemsHTTPError covers the transport-level
// failure path: server replies with a non-2xx status (e.g. 503). DoAPIJSON
// surfaces this as a network error, the prefetch goroutine emits a stderr
// warning, and — critically — does NOT insert the failed id into the
// result map, so Convert falls back to inline retry (same contract as
// envelope-level errors, exercised by
// TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch).
func TestPrefetchMergeForwardSubItemsHTTPError(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// 503 Service Unavailable with no body — purely a transport-layer
// error. DoAPIJSON's `resp.StatusCode >= 400` branch handles this
// before it ever tries to parse an envelope, which is the path the
// envelope-error test doesn't reach.
return convertlibJSONResponse(503, map[string]interface{}{}), nil
}))
rawItems := []interface{}{
map[string]interface{}{"message_id": "om_mf_a", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_b", "msg_type": "merge_forward"},
}
got := PrefetchMergeForwardSubItems(runtime, rawItems, nil)
for _, id := range []string{"om_mf_a", "om_mf_b"} {
if _, ok := got[id]; ok {
t.Fatalf("prefetch map contains transport-error id %q — Convert would render an empty tree instead of falling back to the inline retry path", id)
}
}
}
// TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch is a
// regression test for the silent-empty-tree bug: when a prefetch fails, the
// failed id MUST be absent from the returned map (not present-with-nil).
// Otherwise Convert's "if cached, ok := m[id]; ok { renderTree(cached) }"
// path hits `ok=true, cached=nil`, renders an empty <forwarded_messages>
// tree, and the user-visible "[Merged forward: fetch failed: ...]" string
// that the inline path produced disappears.
func TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch(t *testing.T) {
// Mock: every fetch returns an API error.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return convertlibJSONResponse(200, map[string]interface{}{
"code": 2200,
"msg": "Internal Error",
}), nil
}))
// Multiple ids so we hit the concurrent path (the single-id fast path
// has its own dedicated branch; covering the concurrent branch is more
// stringent since the bug originally hid inside its mu.Lock section).
rawItems := []interface{}{
map[string]interface{}{"message_id": "om_mf_1", "msg_type": "merge_forward"},
map[string]interface{}{"message_id": "om_mf_2", "msg_type": "merge_forward"},
}
got := PrefetchMergeForwardSubItems(runtime, rawItems, nil)
// Every failed id MUST be absent from the map (not present-with-nil).
for _, id := range []string{"om_mf_1", "om_mf_2"} {
if _, ok := got[id]; ok {
t.Fatalf("prefetch map contains failed id %q — this would cause Convert to render an empty <forwarded_messages> tree instead of falling back to the inline-fetch error path", id)
}
}
// And as the downstream effect: invoking the converter on the failed id
// with the (now-cleanly-absent-key) prefetch map must produce the
// inline-path error string, not an empty tree. The mocked inline fetch
// also errors with the same 2200 / Internal Error, so the rendered
// content should contain "Merged forward: fetch failed".
out := (mergeForwardConverter{}).Convert(&ConvertContext{
MessageID: "om_mf_1",
Runtime: runtime,
SenderNames: map[string]string{},
MergeForwardSubItems: got,
})
if !strings.Contains(out, "Merged forward: fetch failed") {
t.Fatalf("Convert output after prefetch failure = %q, want it to contain \"Merged forward: fetch failed\" — failure signal lost", out)
}
}
func TestMergeForwardConverterWithRuntime(t *testing.T) {

View File

@@ -5,7 +5,9 @@ package convertlib
import (
"fmt"
"io"
"net/http"
"sync"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -15,6 +17,16 @@ import (
// larkim/message/members/facade_reaction/service: batchListReactionsMaxMessageIDs).
const reactionsBatchQueryMaxQueries = 20
// reactionsBatchQueryConcurrency caps in-flight batch_query requests. A single
// batch_query call is observed at ~700ms RTT regardless of payload size, so a
// fully serial loop turns N=550 (page-size 50 + 500 expanded thread_replies)
// into ~20s of latency and lets outer wrappers (agents, shells with a wall
// clock) time the whole command out. Bounded concurrency cuts that to ~5s
// without risking the server's gateway-layer 50/s + 1000/min ceiling: even at
// the worst sustained pattern (28 batches at 4-way fan-out finishing every
// ~700ms) the effective rate stays well under 6/s.
const reactionsBatchQueryConcurrency = 4
// EnrichReactions enriches messages with their reactions by calling the
// im.reactions.batch_query API. Messages are modified in place: each message
// that the server returns reactions for gets a "reactions" map attached.
@@ -57,13 +69,51 @@ func EnrichReactions(runtime *common.RuntimeContext, messages []map[string]inter
return
}
// Slice the id list into batches of <= reactionsBatchQueryMaxQueries.
var batches [][]string
for i := 0; i < len(ids); i += reactionsBatchQueryMaxQueries {
end := i + reactionsBatchQueryMaxQueries
if end > len(ids) {
end = len(ids)
}
fetchReactionsBatch(runtime, ids[i:end], idIndex)
batches = append(batches, ids[i:end])
}
// Single-batch fast path: no goroutine overhead, fully deterministic
// stderr ordering, identical behavior to the original serial loop.
if len(batches) == 1 {
fetchReactionsBatch(runtime, batches[0], idIndex, nil)
return
}
// Multi-batch path: bounded-concurrency fan-out. Safety invariant:
// collectMessageNodes dedups ids on first-seen (the `if _, seen :=
// idIndex[id]; !seen` check above), so the slice ids — and therefore
// every batch[i:end] sub-slice we hand to a goroutine — contains each
// id at most once. Different batches operate on disjoint id sets,
// which means different idIndex buckets, which means different
// message-map pointers. Goroutines never write to the same map. The
// shared mutex serializes only the stderr warning lines so they don't
// interleave between goroutines. (Race detector verifies; see
// TestEnrichReactions_DuplicateMessageID and
// TestEnrichReactions_MultiBatchCorrectness for the round-trip.)
var stderrMu sync.Mutex
sem := make(chan struct{}, reactionsBatchQueryConcurrency)
var wg sync.WaitGroup
for _, batch := range batches {
// Add(1) before the semaphore acquire — sync.WaitGroup godoc
// recommends Add precede the goroutine-spawning event, and
// putting it ahead of the blocking sem read keeps the parent
// goroutine's bookkeeping monotonic.
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
fetchReactionsBatch(runtime, batch, idIndex, &stderrMu)
}()
}
wg.Wait()
}
// collectMessageNodes walks messages (and any nested thread_replies) and
@@ -97,7 +147,10 @@ func collectMessageNodes(messages []map[string]interface{}, idIndex map[string][
// fetchReactionsBatch invokes batch_query for one batch of <= 20 message IDs
// and merges the results into idIndex. Failures are logged to stderr without
// aborting subsequent batches.
func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIndex map[string][]map[string]interface{}) {
//
// stderrMu is non-nil in the multi-batch concurrent path (serializes warning
// lines so they don't interleave) and nil in the single-batch fast path.
func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIndex map[string][]map[string]interface{}, stderrMu *sync.Mutex) {
queries := make([]map[string]interface{}, 0, len(batchIDs))
for _, id := range batchIDs {
queries = append(queries, map[string]interface{}{"message_id": id})
@@ -109,7 +162,7 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
map[string]interface{}{"queries": queries},
)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
warnReactionsf(stderrMu, runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
markReactionsError(batchIDs, idIndex)
return
}
@@ -151,7 +204,7 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
}
}
if len(failedIDs) > 0 {
fmt.Fprintf(runtime.IO().ErrOut,
warnReactionsf(stderrMu, runtime.IO().ErrOut,
"warning: reactions_partial_failed: %d message(s) failed (%v)\n",
len(failedIDs), failedIDs)
markReactionsError(failedIDs, idIndex)
@@ -159,6 +212,18 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
}
}
// warnReactionsf writes a stderr warning under the supplied mutex when one is
// provided (multi-batch concurrent path), so concurrent goroutines can't
// interleave partial lines. mu == nil means the caller is on the single-batch
// fast path where no synchronization is needed.
func warnReactionsf(mu *sync.Mutex, w io.Writer, format string, args ...interface{}) {
if mu != nil {
mu.Lock()
defer mu.Unlock()
}
fmt.Fprintf(w, format, args...)
}
// markReactionsError flags every message map indexed under the given ids with
// reactions_error=true, so downstream consumers can distinguish "fetch failed"
// from "no reactions exist" by reading stdout alone.

View File

@@ -11,6 +11,7 @@ import (
"reflect"
"sort"
"strings"
"sync"
"testing"
)
@@ -84,15 +85,19 @@ func TestEnrichReactions_Success(t *testing.T) {
}
// TestEnrichReactions_BatchSize splits queries into batches of 20 (server-side
// max for batch_query).
// max for batch_query). Multi-batch dispatch is concurrent (bounded fan-out),
// so callers must tolerate any ordering of batch arrivals at the transport.
func TestEnrichReactions_BatchSize(t *testing.T) {
var mu sync.Mutex
var observedBatchSizes []int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
mu.Lock()
observedBatchSizes = append(observedBatchSizes, len(queries))
mu.Unlock()
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
@@ -106,8 +111,61 @@ func TestEnrichReactions_BatchSize(t *testing.T) {
EnrichReactions(runtime, messages)
if want := []int{20, 5}; !reflect.DeepEqual(observedBatchSizes, want) {
t.Fatalf("batch sizes = %v, want %v", observedBatchSizes, want)
sort.Ints(observedBatchSizes)
if want := []int{5, 20}; !reflect.DeepEqual(observedBatchSizes, want) {
t.Fatalf("batch sizes (sorted) = %v, want %v", observedBatchSizes, want)
}
}
// TestEnrichReactions_MultiBatchCorrectness exercises the bounded-concurrency
// multi-batch path: every message across all batches must receive its own
// reactions block regardless of which goroutine the batch ran on. A race or a
// cross-batch index mix-up would manifest as missing or duplicated blocks.
func TestEnrichReactions_MultiBatchCorrectness(t *testing.T) {
var mu sync.Mutex
var batchCalls int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
counts := make([]interface{}, 0, len(queries))
for _, q := range queries {
qm, _ := q.(map[string]interface{})
id, _ := qm["message_id"].(string)
counts = append(counts, map[string]interface{}{
"message_id": id,
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
},
})
}
mu.Lock()
batchCalls++
mu.Unlock()
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": counts,
},
}), nil
}))
// 65 messages -> 4 batches (20+20+20+5), enough to actually exercise the
// bounded fan-out (concurrency cap = 4) rather than degenerate to 1-2 calls.
messages := make([]map[string]interface{}, 65)
for i := range messages {
messages[i] = map[string]interface{}{"message_id": fmt.Sprintf("om_%03d", i)}
}
EnrichReactions(runtime, messages)
if batchCalls != 4 {
t.Fatalf("expected 4 batched calls, got %d", batchCalls)
}
for i, m := range messages {
if _, ok := m["reactions"]; !ok {
t.Fatalf("message %d (%s) missing reactions after multi-batch run", i, m["message_id"])
}
}
}

View File

@@ -6,6 +6,7 @@ package convertlib
import (
"fmt"
"net/http"
"sync"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -17,10 +18,55 @@ const ThreadRepliesPerThread = 50
// ThreadRepliesTotalLimit is the default max total thread replies across all threads.
const ThreadRepliesTotalLimit = 500
// threadRepliesFetchConcurrency caps in-flight per-thread GET /messages calls
// when expanding multiple threads in one shortcut invocation. Each call is a
// per-thread RTT (~1s observed), so a strictly serial loop turns N=10 thread
// roots into ~10s of latency — the same multiplier that motivated the
// reactions enrichment fan-out. GET /messages has no published per-app
// rate-limit anywhere near these levels, so we set this higher than the
// reactions batch_query cap (which sits at 4 to stay well under the
// gateway-layer 50/s + 1000/min explicit ceiling on the reactions endpoint).
const threadRepliesFetchConcurrency = 8
// ExpandThreadReplies fetches and embeds thread replies for messages that contain a thread_id.
// For each unique thread_id found in messages, it fetches up to perThread replies (asc order)
// and attaches them as "thread_replies" on the message. Expansion stops once totalLimit
// cumulative replies have been fetched. nameCache is the shared open_id→name map.
// and attaches them as "thread_replies" on the first outer message that referenced that thread.
// Expansion stops once totalLimit cumulative replies have been allocated across planned fetches.
// nameCache is the shared open_id→name map.
//
// Implementation is two-phase:
//
// 1. Plan + concurrent fetch. Walk messages in order, recording every
// unique thread_id with a fetch limit of perThread (no upfront budget
// deduction — see below). Then dispatch the planned fetches with
// bounded concurrency; each goroutine writes only to its own result
// slot, no shared mutable state besides that slot.
//
// 2. Sequential attach with post-hoc budget enforcement. Walk the planned
// threads in their original first-seen order, accumulating actual
// returned reply counts against totalLimit. When a thread's actual
// replies would push the running total past totalLimit, its reply slice
// is truncated to fit the remaining budget and thread_has_more is set
// on its host so consumers know more replies exist server-side. Threads
// that arrive past a fully-exhausted budget keep their thread_id on the
// host but don't get thread_replies attached (semantically identical to
// the pre-existing serial behavior for over-budget threads). The phase
// stays single-threaded because ResolveSenderNames writes to the shared
// nameCache and FormatMessageItem may trigger merge_forward expansion
// that also touches nameCache.
//
// Budget semantics match the pre-existing serial implementation exactly:
// each thread's actual returned count is what gets deducted from the
// budget, not its planned per-thread ceiling. An earlier draft of this
// refactor allocated the budget against the planned ceiling upfront for
// implementation simplicity, but that silently dropped later threads in
// chats where many threads return well under perThread replies (e.g.
// totalLimit=500 + perThread=50 + 12 short threads of 3 replies each → old
// code attached all 12, planned-allocation code attached only 10). The
// trade-off here is a small amount of server-side over-fetching for
// threads that will end up truncated or dropped — bounded by perThread per
// thread — in exchange for preserving the original "every thread that fits
// gets its data" guarantee.
func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]interface{}, nameCache map[string]string, perThread, totalLimit int) {
if runtime == nil {
return
@@ -35,52 +81,161 @@ func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]i
totalLimit = ThreadRepliesTotalLimit
}
totalFetched := 0
// Phase 1a: enumerate every unique thread_id in first-seen order. We
// deliberately do NOT deduct anything from the totalLimit budget here —
// see the godoc above and the Phase 2 truncation step. The first outer
// message referencing a given thread_id is the host that will receive
// the thread_replies attachment, matching the pre-existing behavior
// where duplicates inherited nothing.
type plan struct {
threadID string
limit int
host map[string]interface{}
}
var plans []plan
seen := make(map[string]bool)
for _, msg := range messages {
if totalFetched >= totalLimit {
break
}
tid, _ := msg["thread_id"].(string)
if tid == "" || seen[tid] {
continue
}
seen[tid] = true
plans = append(plans, plan{threadID: tid, limit: perThread, host: msg})
}
if len(plans) == 0 {
return
}
limit := perThread
if remaining := totalLimit - totalFetched; limit > remaining {
limit = remaining
// Phase 1b: concurrent fetch. Each goroutine writes only to its own
// results[i] slot, so there is no shared mutable state besides that
// slot. The single-batch fast path skips goroutine setup for clarity
// and to keep "one thread root" behavior identical to the old code.
type result struct {
rawReplies []map[string]interface{}
hasMore bool
err error
}
results := make([]result, len(plans))
if len(plans) == 1 {
items, hasMore, err := fetchThreadReplies(runtime, plans[0].threadID, plans[0].limit)
results[0] = result{rawReplies: items, hasMore: hasMore, err: err}
} else {
sem := make(chan struct{}, threadRepliesFetchConcurrency)
var wg sync.WaitGroup
for i, p := range plans {
// Add before the semaphore acquire — sync.WaitGroup godoc
// recommends Add precede the goroutine-spawning event.
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
items, hasMore, err := fetchThreadReplies(runtime, p.threadID, p.limit)
results[i] = result{rawReplies: items, hasMore: hasMore, err: err}
}()
}
wg.Wait()
}
rawReplies, hasMore, fetchErr := fetchThreadReplies(runtime, tid, limit)
if fetchErr != nil {
// Preserve the outer message while surfacing that thread expansion failed.
msg["thread_replies_error"] = true
// Phase 2a-pre: apply the totalLimit budget against actual returned
// counts (not planned ceilings) and trim each result in place. Walking
// in original plan order matches the pre-existing serial behavior so a
// chat with budget-exceeding total replies cuts off at the same thread
// position as the old code. Threads past a fully-drained budget have
// their slice cleared to an empty (non-nil) slice — distinct from a
// fetch error's nil rawReplies — so the attach loop below leaves the
// host alone without flagging thread_replies_error. Threads whose
// actual count crosses the boundary get their slice truncated and
// hasMore flagged so consumers know more exist server-side.
remaining := totalLimit
for i := range plans {
r := &results[i]
if r.err != nil || len(r.rawReplies) == 0 {
continue
}
// Successful fetches always return a non-nil (possibly empty) slice.
// A nil slice indicates thread expansion did not complete.
if rawReplies == nil {
msg["thread_replies_error"] = true
if remaining <= 0 {
// Budget already drained by earlier threads — discard this
// thread's fetched replies. We over-fetched on the wire (one
// of the explicit trade-offs documented on the function), but
// the user-visible output remains the same as the serial
// implementation, which would never have issued this fetch.
// Empty slice (not nil) so the attach loop treats this like
// "successfully returned no replies", not "fetch failed".
r.rawReplies = r.rawReplies[:0]
continue
}
if len(rawReplies) == 0 {
continue
if len(r.rawReplies) > remaining {
r.rawReplies = r.rawReplies[:remaining]
r.hasMore = true
}
remaining -= len(r.rawReplies)
}
replies := make([]map[string]interface{}, 0, len(rawReplies))
for _, r := range rawReplies {
replies = append(replies, FormatMessageItem(r, runtime, nameCache))
// Phase 2a-merge: collect every (post-truncation) raw reply across all
// threads and pre-fetch merge_forward sub-messages for the ones that
// need it. Without this, a thread reply that is itself a merge_forward
// would trigger another serial GET inside FormatMessageItem —
// re-introducing the same N × RTT stall pattern that Phase 1b just
// removed.
var allRawReplies []interface{}
for i := range plans {
r := results[i]
if len(r.rawReplies) == 0 {
continue
}
for _, raw := range r.rawReplies {
allRawReplies = append(allRawReplies, raw)
}
}
mergePrefetch := PrefetchMergeForwardSubItems(runtime, allRawReplies, nameCache)
// Phase 2a: format every plan's replies sequentially. FormatMessageItem
// may still touch nameCache for non-merge_forward content types
// (e.g. mention resolution), so this stays single-threaded — concurrent
// writes to nameCache would race.
preparedReplies := make([][]map[string]interface{}, len(plans))
for i, p := range plans {
r := results[i]
if r.err != nil || r.rawReplies == nil {
p.host["thread_replies_error"] = true
continue
}
if len(r.rawReplies) == 0 {
continue
}
replies := make([]map[string]interface{}, 0, len(r.rawReplies))
for _, raw := range r.rawReplies {
replies = append(replies, FormatMessageItemWithMergePrefetch(raw, runtime, nameCache, mergePrefetch))
}
preparedReplies[i] = replies
}
// Phase 2b: one batched ResolveSenderNames across all replies from all
// threads. The pre-existing per-thread call pattern would issue a fresh
// contact API request for every thread that introduced a new sender,
// turning N threads into up to N serial contact RTTs even after the
// fetches themselves went parallel. Consolidating into a single call
// resolves every still-missing open_id in one request and lets the
// nameCache absorb the rest.
var combined []map[string]interface{}
for _, replies := range preparedReplies {
combined = append(combined, replies...)
}
if len(combined) > 0 {
ResolveSenderNames(runtime, combined, nameCache)
}
// Phase 2c: attach the (now name-resolved) replies to their hosts.
for i, p := range plans {
replies := preparedReplies[i]
if replies == nil {
continue
}
ResolveSenderNames(runtime, replies, nameCache)
AttachSenderNames(replies, nameCache)
msg["thread_replies"] = replies
if hasMore {
msg["thread_has_more"] = true
p.host["thread_replies"] = replies
if results[i].hasMore {
p.host["thread_has_more"] = true
}
totalFetched += len(rawReplies)
}
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"testing"
)
@@ -89,6 +90,201 @@ func TestFetchThreadRepliesError(t *testing.T) {
}
}
// TestExpandThreadRepliesMultiThreadConcurrent exercises the bounded-concurrency
// multi-thread path: every distinct thread_id gets its own GET fetched in
// parallel, and the right replies land on the right outer host (the *first*
// outer message that referenced each thread_id). A race or cross-thread
// result mix-up would manifest as missing / mis-attached replies.
func TestExpandThreadRepliesMultiThreadConcurrent(t *testing.T) {
var (
mu sync.Mutex
callCount int
)
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages") {
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
}
tid := req.URL.Query().Get("container_id")
mu.Lock()
callCount++
mu.Unlock()
// Return one synthetic reply per thread, tagged with the thread id so
// we can assert that the right replies landed on the right host.
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"message_id": "om_reply_" + tid,
"msg_type": "text",
"create_time": "1710500000",
"thread_id": tid,
"sender": map[string]interface{}{"name": "Sender"},
"body": map[string]interface{}{"content": `{"text":"reply for ` + tid + `"}`},
},
},
},
}), nil
}))
// 5 distinct thread roots → 5 planned fetches, dispatched under the
// concurrency cap. Enough to actually exercise the bounded fan-out
// rather than degenerate to the single-thread fast path.
messages := []map[string]interface{}{
{"message_id": "om_root_1", "thread_id": "omt_a"},
{"message_id": "om_root_2", "thread_id": "omt_b"},
{"message_id": "om_root_3", "thread_id": "omt_c"},
{"message_id": "om_root_4", "thread_id": "omt_d"},
{"message_id": "om_root_5", "thread_id": "omt_e"},
}
ExpandThreadReplies(runtime, messages, map[string]string{}, 10, 500)
if callCount != 5 {
t.Fatalf("expected 5 thread fetches, got %d", callCount)
}
for i, m := range messages {
tid := m["thread_id"].(string)
replies, ok := m["thread_replies"].([]map[string]interface{})
if !ok {
t.Fatalf("message %d (thread %s) missing thread_replies: %#v", i, tid, m)
}
if len(replies) != 1 {
t.Fatalf("message %d (thread %s) replies len = %d, want 1", i, tid, len(replies))
}
// Each thread's reply was tagged with its own thread_id; verify no
// goroutine cross-contamination.
gotTid, _ := replies[0]["thread_id"].(string)
if gotTid != tid {
t.Fatalf("message %d (thread %s) got reply tagged with thread_id=%q — cross-thread contamination",
i, tid, gotTid)
}
}
}
// TestExpandThreadRepliesTotalLimitUsesActualCounts is a regression test for
// the budget-allocation refactor: the new concurrent path must deduct
// totalLimit using the *actual* returned reply count per thread, not the
// planned per-thread ceiling. Otherwise chats with many low-volume threads
// (very common — most threads in a busy group have just a few replies)
// silently drop later threads when the planned ceilings sum past totalLimit
// well before the actual replies do.
func TestExpandThreadRepliesTotalLimitUsesActualCounts(t *testing.T) {
// Synthetic API: every thread returns exactly 3 replies, regardless of
// the requested page_size. This is the "short threads" scenario where
// the difference between planned-ceiling and actual-count budget
// accounting becomes visible.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
tid := req.URL.Query().Get("container_id")
items := make([]interface{}, 3)
for i := range items {
items[i] = map[string]interface{}{
"message_id": fmt.Sprintf("om_reply_%s_%d", tid, i),
"msg_type": "text",
"create_time": "1710500000",
"thread_id": tid,
"sender": map[string]interface{}{"name": "Sender"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": items,
},
}), nil
}))
// 12 distinct thread roots × 3 actual replies each = 36 total. With
// perThread=50 (the default ceiling), the old "deduct planned ceiling"
// implementation would have exhausted totalLimit=100 after just 2
// threads (2 × 50 = 100) and silently skipped the remaining 10. The
// correct behavior deducts actual counts (12 × 3 = 36 < 100), so all
// 12 threads should attach.
messages := make([]map[string]interface{}, 12)
for i := range messages {
messages[i] = map[string]interface{}{
"message_id": fmt.Sprintf("om_root_%02d", i),
"thread_id": fmt.Sprintf("omt_%02d", i),
}
}
ExpandThreadReplies(runtime, messages, map[string]string{}, 50, 100)
for i, m := range messages {
replies, ok := m["thread_replies"].([]map[string]interface{})
if !ok {
t.Fatalf("thread %d (%s) silently dropped — thread_replies missing despite actual budget headroom",
i, m["thread_id"])
}
if len(replies) != 3 {
t.Fatalf("thread %d (%s) replies len = %d, want 3", i, m["thread_id"], len(replies))
}
}
}
// TestExpandThreadRepliesTruncatesOnBudgetBoundary covers the cross-boundary
// case: a thread whose actual replies straddle the remaining budget gets
// its slice truncated to fit and thread_has_more flagged so consumers know
// more exist server-side.
func TestExpandThreadRepliesTruncatesOnBudgetBoundary(t *testing.T) {
// Every thread returns exactly 4 replies.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
tid := req.URL.Query().Get("container_id")
items := make([]interface{}, 4)
for i := range items {
items[i] = map[string]interface{}{
"message_id": fmt.Sprintf("om_reply_%s_%d", tid, i),
"msg_type": "text",
"create_time": "1710500000",
"thread_id": tid,
"sender": map[string]interface{}{"name": "Sender"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": items,
},
}), nil
}))
// 3 threads × 4 replies = 12, but totalLimit = 10. So:
// - thread 0 fully attached (4 replies; running total 4)
// - thread 1 fully attached (4 replies; running total 8)
// - thread 2 truncated to 2 replies (running total 10), has_more=true
// - any thread 3+ would be dropped entirely
messages := []map[string]interface{}{
{"message_id": "om_root_0", "thread_id": "omt_0"},
{"message_id": "om_root_1", "thread_id": "omt_1"},
{"message_id": "om_root_2", "thread_id": "omt_2"},
}
ExpandThreadReplies(runtime, messages, map[string]string{}, 10, 10)
for i, want := range []int{4, 4, 2} {
replies, _ := messages[i]["thread_replies"].([]map[string]interface{})
if len(replies) != want {
t.Fatalf("thread %d replies len = %d, want %d (post-budget truncation)", i, len(replies), want)
}
}
if messages[2]["thread_has_more"] != true {
t.Fatalf("thread 2 was truncated by budget but thread_has_more = %#v, want true",
messages[2]["thread_has_more"])
}
// And the truncated host must NOT be flagged with thread_replies_error —
// budget truncation is success, not failure.
for i, m := range messages {
if v, _ := m["thread_replies_error"].(bool); v {
t.Fatalf("message %d incorrectly flagged with thread_replies_error after budget truncation: %#v", i, m)
}
}
}
func TestExpandThreadRepliesMarksFetchError(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {

View File

@@ -543,7 +543,17 @@ func findMP4Box(data []byte, start, end int, boxType string) (int, int) {
if offset+16 > end {
return -1, -1
}
boxEnd = int(binary.BigEndian.Uint64(data[offset+8:]))
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring the
// offset+size used for 32-bit boxes below). Reject sizes that do not
// fit the search window; this also rejects values that would
// overflow int and drive boxEnd negative (CWE-190), which would
// otherwise index data out of range and panic.
largesize := binary.BigEndian.Uint64(data[offset+8:])
if largesize < 16 || largesize > uint64(end-offset) {
return -1, -1
}
boxEnd = offset + int(largesize)
dataStart = offset + 16
default:
if size < 8 {
@@ -688,7 +698,16 @@ func readMp4DurationBytes(data []byte) int64 {
if offset+16 > fileSize {
return 0
}
boxEnd = int64(binary.BigEndian.Uint64(data[offset+8 : offset+16]))
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring offset+size
// for 32-bit boxes). Reject sizes that do not fit the file; this also
// rejects values that would overflow int64 and drive boxEnd negative
// (CWE-190), which would otherwise index data out of range and panic.
largesize := binary.BigEndian.Uint64(data[offset+8 : offset+16])
if largesize < 16 || largesize > uint64(fileSize-offset) {
return 0
}
boxEnd = offset + int64(largesize)
dataStart = offset + 16
case size < 8:
return 0
@@ -749,7 +768,16 @@ func readMp4Duration(f fileio.File, fileSize int64) int64 {
if _, err := f.ReadAt(hdr[8:16], offset+8); err != nil {
return 0
}
boxEnd = int64(binary.BigEndian.Uint64(hdr[8:16]))
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring offset+size
// for 32-bit boxes). Reject sizes that do not fit the file; this also
// rejects values that would overflow int64 and drive boxEnd negative
// (CWE-190).
largesize := binary.BigEndian.Uint64(hdr[8:16])
if largesize < 16 || largesize > uint64(fileSize-offset) {
return 0
}
boxEnd = offset + int64(largesize)
dataStart = offset + 16
case size < 8:
return 0

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -15,13 +16,31 @@ import (
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
const imChatListPath = "/open-apis/im/v1/chats"
// bot_strip_p2p is the request-level adjustment notice emitted when bot
// identity receives a mixed --types containing "p2p": the p2p value is
// removed from the outgoing query (which the API would otherwise reject)
// and the caller is informed via a stderr warning + a structured entry
// in outData["notices"]. This is a notice, not a filter — it lives in a
// separate slot from outData["filter"] so the two never collide.
const (
botStripP2pCode = "bot_strip_p2p"
botStripP2pMessage = "To protect user privacy, bot identity cannot list p2p chats; --types=p2p,group was sent as types=group. Use --as user to include p2p."
)
// writeBotStripP2pWarning prints the bot_strip_p2p adjustment to stderr in
// the repo's standard "warning: <code>: <message>" form (matches the format
// used in shortcuts/common/runner.go's unknown-format fallback).
func writeBotStripP2pWarning(errOut io.Writer) {
fmt.Fprintf(errOut, "warning: %s: %s\n", botStripP2pCode, botStripP2pMessage)
}
// ImChatList is the +chat-list shortcut: wraps GET /open-apis/im/v1/chats to
// list groups the current user/bot is a member of. Supports sort order,
// pagination, and (user identity only) muted-chat filtering via --exclude-muted.
var ImChatList = common.Shortcut{
Service: "im",
Command: "+chat-list",
Description: "List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only)",
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)",
Risk: "read",
Scopes: []string{"im:chat:read"},
AuthTypes: []string{"user", "bot"},
@@ -29,28 +48,53 @@ var ImChatList = common.Shortcut{
Flags: []common.Flag{
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
// DryRun previews the GET /open-apis/im/v1/chats request without executing.
// When bot identity strips p2p from --types, emits the same stderr warning
// Execute would emit, so DryRun output truthfully reflects what the API
// will receive (matches the shortcuts/drive/drive_search.go pattern of
// echoing request-level adjustments in both DryRun and Execute).
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
effective, stripped, _ := resolveTypes(runtime) // Validate has already guaranteed err == nil
if stripped {
writeBotStripP2pWarning(runtime.IO().ErrOut)
}
return common.NewDryRunAPI().
GET(imChatListPath).
Params(buildChatListParams(runtime))
Params(buildChatListParams(runtime, effective))
},
// Validate enforces flag preconditions; only --page-size has bounds (1-100).
// Validate enforces flag preconditions: page-size bounds, --types element
// enum, and the bot + single-p2p rejection (mixed types degrade in Execute).
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if n := runtime.Int("page-size"); n < 1 || n > 100 {
return output.ErrValidation("--page-size must be an integer between 1 and 100")
}
parts, err := normalizeTypes(runtime.StrSlice("types"))
if err != nil {
return err
}
if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() {
return output.ErrValidation(
`--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`)
}
return nil
},
// Execute fetches one page of chats, optionally applies --exclude-muted
// via MaybeApplyMuteFilter, and renders the result. outData["filter"] is
// populated only when --exclude-muted is set (backward compatible).
// outData["notices"] is populated only when bot identity strips p2p from
// --types — a request-level adjustment that lives in its own slot so it
// never collides with the row-level mute filter.
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params := buildChatListParams(runtime)
effective, stripped, _ := resolveTypes(runtime) // Validate guarantees err == nil
if stripped {
writeBotStripP2pWarning(runtime.IO().ErrOut)
}
params := buildChatListParams(runtime, effective)
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
if err != nil {
return err
@@ -88,6 +132,11 @@ var ImChatList = common.Shortcut{
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
}
if stripped {
outData["notices"] = []map[string]interface{}{
{"code": botStripP2pCode, "message": botStripP2pMessage},
}
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(items) == 0 {
@@ -115,6 +164,17 @@ var ImChatList = common.Shortcut{
if status, _ := m["chat_status"].(string); status != "" {
row["chat_status"] = status
}
if chatMode, _ := m["chat_mode"].(string); chatMode != "" {
row["chat_mode"] = chatMode
if chatMode == "p2p" {
if pt, _ := m["p2p_target_type"].(string); pt != "" {
row["p2p_target_type"] = pt
}
if pid, _ := m["p2p_target_id"].(string); pid != "" {
row["p2p_target_id"] = pid
}
}
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
@@ -135,11 +195,76 @@ var ImChatList = common.Shortcut{
},
}
// buildChatListParams builds the query parameters for the GET /im/v1/chats
// call from the runtime flag values. user_id_type and sort_type are always
// present (their flag defaults are non-empty); page_token is omitted when
// empty; page_size falls back to the API default of 20 when not provided.
func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{} {
// normalizeTypes validates and normalizes the --types slice already parsed by cobra.
// cobra's StringSlice handles the CSV split automatically — both --types=p2p,group
// and repeated --types p2p --types group arrive here as a 2-element []string,
// so this function never re-splits on commas.
// Returns the normalized (lowercased, deduped, in input order) parts on success.
// Empty raw input is a no-op (returns nil, nil).
// Returns ErrValidation when any element is empty or outside {"p2p", "group"}.
func normalizeTypes(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, p := range raw {
p = strings.TrimSpace(strings.ToLower(p))
if p == "" {
return nil, output.ErrValidation("--types must contain at least one of p2p, group")
}
if p != "p2p" && p != "group" {
return nil, output.ErrValidation("--types contains invalid value %q: expected one of p2p, group", p)
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out, nil
}
// resolveTypes layers bot identity downgrade on top of normalizeTypes.
// Under bot identity, "p2p" is stripped from the parts and the caller is
// informed (DryRun / Execute emit a stderr warning; Execute additionally
// writes a structured entry under outData["notices"]).
// Validate has already rejected "bot + parts == ['p2p']" cases, so kept is
// never empty here.
//
// Returns (effective CSV, stripped, err):
// - effective: comma-joined types to send as the API query param
// - stripped: true iff bot identity removed "p2p" from a mixed --types value
// - err: forwarded from normalizeTypes
func resolveTypes(runtime *common.RuntimeContext) (string, bool, error) {
parts, err := normalizeTypes(runtime.StrSlice("types"))
if err != nil {
return "", false, err
}
if !runtime.IsBot() {
return strings.Join(parts, ","), false, nil
}
// Bot identity: strip "p2p" so the API call succeeds with just groups.
// Validate has already rejected the "bot + only p2p" case, so kept is never empty here.
// Allocate a fresh slice (rather than aliasing parts[:0]) — parts has at most 2
// elements so the cost is negligible, and avoiding shared backing storage removes
// a class of "two slices, same array" surprises if a future caller keeps parts.
stripped := false
kept := make([]string, 0, len(parts))
for _, p := range parts {
if p == "p2p" {
stripped = true
continue
}
kept = append(kept, p)
}
return strings.Join(kept, ","), stripped, nil
}
// buildChatListParams builds the query parameters. effectiveTypes is the
// CSV string already normalized + bot-stripped by resolveTypes; pass "" to
// omit the types query param entirely (backward compatible default).
func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string) map[string]interface{} {
params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"),
"sort_type": runtime.Str("sort-type"),
@@ -152,5 +277,8 @@ func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{}
if pt := runtime.Str("page-token"); pt != "" {
params["page_token"] = pt
}
if effectiveTypes != "" {
params["types"] = effectiveTypes
}
return params
}

View File

@@ -4,26 +4,41 @@
package im
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newChatListTestRuntimeContext mirrors newMessagesSearchTestRuntimeContext
// it registers page-size as Int (the existing newTestRuntimeContext registers
// it as String, which would short-circuit our buildChatListParams logic).
// newChatListTestRuntimeContext registers flags and returns a user-identity runtime context.
func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
return newChatListTestRuntimeContextWithIdentity(t, stringFlags, boolFlags, core.AsUser)
}
// newChatListTestRuntimeContextWithIdentity is the identity-aware variant.
func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool, as core.Identity) *common.RuntimeContext {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
cmd.Flags().String(name, "", "")
if name == "types" {
cmd.Flags().StringSlice(name, nil, "")
} else {
cmd.Flags().String(name, "", "")
}
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
@@ -37,11 +52,22 @@ func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string,
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
if err := cmd.Flags().Set(name, strconv.FormatBool(val)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
rt := common.TestNewRuntimeContextWithIdentity(cmd, nil, as)
// Attach a minimal Factory with IOStreams so DryRun / Execute paths that
// emit stderr warnings (e.g. bot_strip_p2p) don't panic on runtime.IO().
// Stays pure-logic — no HTTP client, no httpmock; integration tests use
// newBotShortcutRuntime / newUserShortcutRuntime for that.
rt.Factory = &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
},
}
return rt
}
func TestBuildChatListParams_Defaults(t *testing.T) {
@@ -49,7 +75,7 @@ func TestBuildChatListParams_Defaults(t *testing.T) {
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt)
got := buildChatListParams(rt, "")
if got["user_id_type"] != "open_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
@@ -62,6 +88,9 @@ func TestBuildChatListParams_Defaults(t *testing.T) {
if _, present := got["page_token"]; present {
t.Fatalf("page_token should be omitted when empty")
}
if _, present := got["types"]; present {
t.Fatalf("types should be omitted when --types is empty")
}
}
func TestBuildChatListParams_Overrides(t *testing.T) {
@@ -71,7 +100,7 @@ func TestBuildChatListParams_Overrides(t *testing.T) {
"page-size": "50",
"page-token": "tok_xyz",
}, nil)
got := buildChatListParams(rt)
got := buildChatListParams(rt, "")
if got["user_id_type"] != "user_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
@@ -126,3 +155,459 @@ func TestImChatList_DryRun_IncludesEndpoint(t *testing.T) {
t.Fatalf("DryRun missing page_size: %s", got)
}
}
func TestNormalizeTypes(t *testing.T) {
cases := []struct {
name string
raw []string
want []string
wantErr string // substring match
}{
{"empty returns nil no error", nil, nil, ""},
{"single p2p", []string{"p2p"}, []string{"p2p"}, ""},
{"single group", []string{"group"}, []string{"group"}, ""},
{"p2p,group preserves order", []string{"p2p", "group"}, []string{"p2p", "group"}, ""},
{"group,p2p preserves order", []string{"group", "p2p"}, []string{"group", "p2p"}, ""},
{"trim whitespace", []string{" p2p ", " group "}, []string{"p2p", "group"}, ""},
{"lowercase", []string{"P2P", "GROUP"}, []string{"p2p", "group"}, ""},
{"dedupe", []string{"p2p", "p2p"}, []string{"p2p"}, ""},
{"empty element rejected", []string{""}, nil, "must contain at least one of p2p, group"},
{"invalid element rejected", []string{"private"}, nil, `expected one of p2p, group`},
{"mixed invalid rejected", []string{"p2p", "private"}, nil, `expected one of p2p, group`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := normalizeTypes(c.raw)
if c.wantErr != "" {
if err == nil {
t.Fatalf("normalizeTypes(%v) err = nil; want substring %q", c.raw, c.wantErr)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("normalizeTypes(%v) err = %v; want substring %q", c.raw, err, c.wantErr)
}
return
}
if err != nil {
t.Fatalf("normalizeTypes(%v) unexpected err = %v", c.raw, err)
}
if len(got) != len(c.want) {
t.Fatalf("normalizeTypes(%v) = %v; want %v", c.raw, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("normalizeTypes(%v)[%d] = %q; want %q", c.raw, i, got[i], c.want[i])
}
}
})
}
}
func TestResolveTypes(t *testing.T) {
cases := []struct {
name string
raw string
as core.Identity
wantEffective string
wantStripped bool
}{
{"user empty", "", core.AsUser, "", false},
{"user p2p", "p2p", core.AsUser, "p2p", false},
{"user p2p,group", "p2p,group", core.AsUser, "p2p,group", false},
{"user group,p2p preserves order", "group,p2p", core.AsUser, "group,p2p", false},
{"user normalized casing", "P2P,GROUP", core.AsUser, "p2p,group", false},
{"bot empty", "", core.AsBot, "", false},
{"bot group only", "group", core.AsBot, "group", false},
{"bot p2p,group strips p2p", "p2p,group", core.AsBot, "group", true},
{"bot group,p2p strips p2p", "group,p2p", core.AsBot, "group", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{"types": c.raw}, nil, c.as)
effective, stripped, err := resolveTypes(rt)
if err != nil {
t.Fatalf("resolveTypes() unexpected err = %v", err)
}
if effective != c.wantEffective {
t.Fatalf("effective = %q; want %q", effective, c.wantEffective)
}
if stripped != c.wantStripped {
t.Fatalf("stripped = %v; want %v", stripped, c.wantStripped)
}
})
}
}
func TestBuildChatListParams_TypesPassthrough(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt, "p2p,group")
if got["types"] != "p2p,group" {
t.Fatalf("types = %v; want \"p2p,group\"", got["types"])
}
}
func TestImChatList_Validate_Types(t *testing.T) {
cases := []struct {
name string
typesRaw string
as core.Identity
wantErr string // substring; "" means no error
}{
{"user empty ok", "", core.AsUser, ""},
{"user p2p ok", "p2p", core.AsUser, ""},
{"user group ok", "group", core.AsUser, ""},
{"user p2p,group ok", "p2p,group", core.AsUser, ""},
{"user invalid element rejected", "private", core.AsUser, "expected one of p2p, group"},
{"user comma-only rejected", ",", core.AsUser, "must contain at least one of p2p, group"},
{"bot empty ok", "", core.AsBot, ""},
{"bot group ok", "group", core.AsBot, ""},
{"bot p2p,group ok (degraded at Execute)", "p2p,group", core.AsBot, ""},
{"bot single p2p rejected", "p2p", core.AsBot, "only supported with user identity"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t,
map[string]string{"types": c.typesRaw, "page-size": "20"},
nil, c.as)
err := ImChatList.Validate(context.Background(), rt)
if c.wantErr == "" {
if err != nil {
t.Fatalf("Validate() unexpected err = %v", err)
}
return
}
if err == nil {
t.Fatalf("Validate() err = nil; want substring %q", c.wantErr)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("Validate() err = %v; want substring %q", err, c.wantErr)
}
})
}
}
// attachChatListCmd builds a cobra.Command pre-loaded with all flags ImChatList
// reads, applies stringFlags / boolFlags, and assigns it to runtime.Cmd. Format
// is forced to "json" so Execute output lands in a parseable form on
// runtime.Factory.IOStreams.Out.
func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags map[string]string, boolFlags map[string]bool) {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().String("user-id-type", "open_id", "")
cmd.Flags().String("sort-type", "ByCreateTimeAsc", "")
cmd.Flags().StringSlice("types", nil, "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().Bool("exclude-muted", false, "")
cmd.Flags().Bool("dry-run", false, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range stringFlags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, strconv.FormatBool(val)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
runtime.Cmd = cmd
runtime.Format = "json"
}
// chatListOutBuf retrieves the captured stdout buffer for assertions.
func chatListOutBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer {
t.Helper()
buf, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if !ok {
t.Fatalf("expected IOStreams.Out to be *bytes.Buffer")
}
return buf
}
// chatListErrBuf retrieves the captured stderr buffer for assertions
// (used to verify request-level warnings like `bot_strip_p2p`).
func chatListErrBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer {
t.Helper()
buf, ok := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
if !ok {
t.Fatalf("expected IOStreams.ErrOut to be *bytes.Buffer")
}
return buf
}
func TestImChatList_Execute_BotStripsP2p(t *testing.T) {
var capturedURL string
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
capturedURL = req.URL.String()
body := `{"code":0,"msg":"ok","data":{"items":[{"chat_id":"oc_g","name":"G","chat_mode":"group"}],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
if !strings.Contains(capturedURL, "types=group") {
t.Fatalf("request URL = %s; want types=group (bot strips p2p)", capturedURL)
}
if strings.Contains(capturedURL, "p2p") {
t.Fatalf("request URL = %s; must NOT contain p2p (bot stripped it)", capturedURL)
}
// Structured notice: outData["notices"] contains a {code, message} entry
// for bot_strip_p2p (request-level adjustment, not a row-level filter).
out := chatListOutBuf(t, rt).String()
for _, want := range []string{`"notices"`, `"code": "bot_strip_p2p"`, `"message"`} {
if !strings.Contains(out, want) {
t.Fatalf("stdout JSON missing notice field %q:\n%s", want, out)
}
}
// filter slot must remain mute-scoped: bot_strip_p2p must not leak into
// outData["filter"].applied (no priority conflict by design).
if strings.Contains(out, `"applied": "bot_strip_p2p"`) {
t.Fatalf("bot_strip_p2p should not appear in filter.applied (separate slot):\n%s", out)
}
// Stderr: matches repo `warning: <code>: <message>` convention (cf.
// shortcuts/common/runner.go unknown-format fallback).
errOut := chatListErrBuf(t, rt).String()
if !strings.Contains(errOut, "warning: bot_strip_p2p:") {
t.Fatalf("stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut)
}
}
// TestImChatList_DryRun_BotStripsP2pStderrNotice verifies the DryRun branch
// also emits the bot_strip_p2p warning to stderr so a previewed request
// truthfully reflects what Execute would send (drive_search.go DryRun parity).
func TestImChatList_DryRun_BotStripsP2pStderrNotice(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("DryRun should not make HTTP calls; got: %s", req.URL.String())
return nil, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
dr := ImChatList.DryRun(context.Background(), rt)
if dr == nil {
t.Fatalf("DryRun returned nil")
}
errOut := chatListErrBuf(t, rt).String()
if !strings.Contains(errOut, "warning: bot_strip_p2p:") {
t.Fatalf("DryRun stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut)
}
}
func TestImChatList_RowRendering_P2pFields(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
for _, want := range []string{"oc_g", "oc_p", "Group", "Peer", `"chat_mode": "p2p"`, `"p2p_target_id": "ou_peer"`} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q; got: %s", want, out)
}
}
}
// TestImChatList_Execute_PrettyOutputRendersP2pRow exercises the pretty-format
// rendering closure in Execute, including the new chat_mode=="p2p" branch that
// surfaces p2p_target_type / p2p_target_id, and the has_more footer that
// echoes back the page_token.
func TestImChatList_Execute_PrettyOutputRendersP2pRow(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner","description":"a group","external":false,"chat_status":"normal"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":true,"page_token":"next_tok"}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
rt.Format = "pretty"
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
for _, want := range []string{"oc_g", "Group", "a group", "ou_owner", "normal"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing group-row field %q:\n%s", want, out)
}
}
for _, want := range []string{"oc_p", "Peer", "p2p", "ou_peer"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing p2p-row field %q:\n%s", want, out)
}
}
if !strings.Contains(out, "2 chat(s) listed") {
t.Fatalf("pretty output missing footer count:\n%s", out)
}
if !strings.Contains(out, "next_tok") {
t.Fatalf("pretty output missing page_token in has_more footer:\n%s", out)
}
}
func TestImChatList_DryRun_TypesPassthrough(t *testing.T) {
cases := []struct {
name string
as core.Identity
typesRaw string
wantSub string // substring expected in dry-run JSON
wantErr bool // whether Validate should reject before DryRun runs
}{
{"user p2p", core.AsUser, "p2p", `"types":"p2p"`, false},
{"user p2p,group", core.AsUser, "p2p,group", `"types":"p2p,group"`, false},
{"bot p2p,group strips to group", core.AsBot, "p2p,group", `"types":"group"`, false},
{"bot group passes", core.AsBot, "group", `"types":"group"`, false},
{"bot single p2p rejected at Validate", core.AsBot, "p2p", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
"page-size": "20",
"types": c.typesRaw,
}, nil, c.as)
if err := ImChatList.Validate(context.Background(), rt); err != nil {
if !c.wantErr {
t.Fatalf("Validate() unexpected err = %v", err)
}
return
}
if c.wantErr {
t.Fatalf("Validate() err = nil; want rejection")
}
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt))
if !strings.Contains(got, c.wantSub) {
t.Fatalf("DryRun = %s; want substring %q", got, c.wantSub)
}
})
}
}
func TestImChatList_RowRendering_ChatModeAbsent(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// Response items deliberately omit chat_mode / p2p_target_* (legacy/defensive case).
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g1","name":"Group1","owner_id":"ou_owner"},
{"chat_id":"oc_g2","name":"Group2","external":true}
],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, nil, nil) // no --types; default behavior
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
// chat_mode / p2p_target_* must NOT appear since the API didn't return them.
for _, forbidden := range []string{`"chat_mode"`, `"p2p_target_type"`, `"p2p_target_id"`} {
// "chats[].chat_mode" is the row-level field — JSON envelope might include it as null or omit it;
// asserting the rendered table fields are missing is the goal.
// The JSON pass-through preserves whatever API returned (omitted here),
// so neither path should produce these strings.
if strings.Contains(out, forbidden) {
t.Fatalf("output unexpectedly contains %q (should not appear when API omitted these fields); got: %s", forbidden, out)
}
}
// Sanity: the two chat IDs must still be present (renderer didn't crash).
for _, want := range []string{"oc_g1", "oc_g2", "Group1", "Group2"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q; got: %s", want, out)
}
}
}
func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
path := req.URL.Path
switch {
case strings.HasSuffix(path, "/im/v1/chats"):
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":false,"page_token":""}}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
case strings.HasSuffix(path, "/chat_user_setting/batch_get_mute_status"):
// Mark oc_p (the p2p) as muted; oc_g not muted.
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","is_muted":false},
{"chat_id":"oc_p","is_muted":true}
]}}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
}
t.Fatalf("unexpected request path: %s", path)
return nil, nil
}))
attachChatListCmd(t, rt,
map[string]string{"types": "p2p,group"},
map[string]bool{"exclude-muted": true})
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
var parsed struct {
Data struct {
Chats []map[string]interface{} `json:"chats"`
Filter struct {
Applied string `json:"applied"`
FilteredCount int `json:"filtered_count"`
} `json:"filter"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("Unmarshal output failed: %v; raw: %s", err, out)
}
if parsed.Data.Filter.Applied != "exclude_muted" {
t.Fatalf("filter.applied = %q; want exclude_muted (no bot_strip_p2p under user). Raw: %s",
parsed.Data.Filter.Applied, out)
}
if parsed.Data.Filter.FilteredCount != 1 {
t.Fatalf("filter.filtered_count = %d; want 1 (the muted p2p row). Raw: %s",
parsed.Data.Filter.FilteredCount, out)
}
// The muted p2p row should be gone from chats; only oc_g remains.
if len(parsed.Data.Chats) != 1 {
t.Fatalf("expected 1 chat after muting; got %d. Raw: %s", len(parsed.Data.Chats), out)
}
if parsed.Data.Chats[0]["chat_id"] != "oc_g" {
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"])
}
}

View File

@@ -117,10 +117,19 @@ var ImChatMessageList = common.Shortcut{
hasMore, nextPageToken := common.PaginationMeta(data)
nameCache := make(map[string]string)
// Pre-fetch merge_forward sub-messages concurrently before the per-item
// conversion loop. Each merge_forward in the page would otherwise issue
// its own serial GET inside FormatMessageItem; N merge_forwards turned
// into N × ~1s of stall. Passing nameCache also lets the prefetch
// batch-resolve every sub-item's sender open_id in one contact API
// call, so the per-merge_forward render path doesn't fan out N more
// serial contact requests during the FormatMessageItem loop.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)
messages := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache))
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
}
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)

View File

@@ -66,10 +66,17 @@ var ImMessagesMGet = common.Shortcut{
rawItems, _ := data["items"].([]interface{})
nameCache := make(map[string]string)
// Pre-fetch merge_forward sub-messages concurrently before the per-item
// conversion loop, so N merge_forwards in the input don't serialize
// into N × ~1s of stall inside FormatMessageItem. Passing nameCache
// also pre-resolves every sub-item's sender open_id in one batched
// contact API call.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)
messages := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache))
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
}
convertlib.ResolveSenderNames(runtime, messages, nameCache)

View File

@@ -159,13 +159,19 @@ var ImMessagesSearch = common.Shortcut{
// ── Step 4: Format message content + attach chat context ──
nameCache := make(map[string]string)
// Pre-fetch merge_forward sub-messages concurrently before the per-item
// conversion loop, so N merge_forwards in the search hits don't
// serialize into N × ~1s of stall inside FormatMessageItem. Passing
// nameCache also pre-resolves every sub-item's sender open_id in one
// batched contact API call.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, msgItems, nameCache)
enriched := make([]map[string]interface{}, 0, len(msgItems))
for _, item := range msgItems {
m, _ := item.(map[string]interface{})
chatId, _ := m["chat_id"].(string)
// Reuse unified content converter
msg := convertlib.FormatMessageItem(m, runtime, nameCache)
msg := convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch)
if chatId != "" {
msg["chat_id"] = chatId
}

View File

@@ -81,10 +81,14 @@ var ImMessagesSend = common.Shortcut{
if desc != "" {
d.Desc(desc)
}
return d.
d.
POST("/open-apis/im/v1/messages").
Params(map[string]interface{}{"receive_id_type": receiveIdType}).
Body(body)
if chatFlag != "" {
d.Desc("NOTE: dry-run validates request shape only. Bot/user membership in the target chat is not verified; the real send may fail with `Bot/User can NOT be out of the chat`.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
chatFlag := runtime.Str("chat-id")

View File

@@ -121,10 +121,17 @@ var ImThreadsMessagesList = common.Shortcut{
hasMore, nextPageToken := common.PaginationMeta(data)
nameCache := make(map[string]string)
// Pre-fetch merge_forward sub-messages concurrently before the per-item
// conversion loop. Thread replies that are themselves merge_forward
// messages would otherwise issue serial GETs inside FormatMessageItem.
// Passing nameCache also pre-resolves every sub-item's sender open_id
// in one batched contact API call.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)
messages := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache))
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
}
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"encoding/binary"
"testing"
)
// build64BitBox builds an ISO-BMFF box using the 64-bit "largesize" form: the
// 32-bit size field is set to 1 and an 8-byte largesize follows the 4-byte box
// type. largesize is the total box length including the 16-byte header.
func build64BitBox(boxType string, largesize uint64, payload []byte) []byte {
box := make([]byte, 16+len(payload))
binary.BigEndian.PutUint32(box[0:4], 1) // size == 1 → 64-bit largesize follows
copy(box[4:8], boxType)
binary.BigEndian.PutUint64(box[8:16], largesize)
copy(box[16:], payload)
return box
}
// build32BitBox builds an ISO-BMFF box using the ordinary 32-bit size form.
func build32BitBox(boxType string, payload []byte) []byte {
box := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(box[0:4], uint32(len(box)))
copy(box[4:8], boxType)
copy(box[8:], payload)
return box
}
// TestMP4BoxLargeSizeOverflowNoPanic guards the 64-bit box-size branch against
// CWE-190 integer overflow. A largesize whose high bit is set converts to a
// negative offset; without a bounds guard that offset indexes the input slice
// out of range and panics, crashing the CLI on a crafted/corrupt MP4 (the
// in-memory walkers run on URL-sourced media that the caller does not control).
// The walkers' contract is best-effort: malformed input must return 0, not panic.
func TestMP4BoxLargeSizeOverflowNoPanic(t *testing.T) {
// A single top-level box in the 64-bit form with largesize = 2^64-1.
data := build64BitBox("ftyp", 0xFFFFFFFFFFFFFFFF, nil)
if got := readMp4DurationBytes(data); got != 0 {
t.Errorf("readMp4DurationBytes(overflow largesize) = %d, want 0", got)
}
if got := parseMp4Duration(data); got != 0 {
t.Errorf("parseMp4Duration(overflow largesize) = %d, want 0", got)
}
if start, end := findMP4Box(data, 0, len(data), "ftyp"); start != -1 || end != -1 {
t.Errorf("findMP4Box(overflow largesize) = (%d, %d), want (-1, -1)", start, end)
}
}
// TestMP4Box64BitSizeAtNonZeroOffset locks in correct handling of a 64-bit box
// that does not start at offset 0. boxEnd must be offset+largesize (as the
// 32-bit branch already does with offset+size); dropping the offset truncates
// the box and the duration is silently lost.
func TestMP4Box64BitSizeAtNonZeroOffset(t *testing.T) {
mvhd := buildMvhdBox(0, 1000, 5000) // timescale=1000, duration=5000 → 5000ms
// moov carried as a 64-bit box: largesize = 16-byte header + mvhd payload.
moov := build64BitBox("moov", uint64(16+len(mvhd)), mvhd)
// Precede moov with a 32-bit ftyp box so it sits at a non-zero offset —
// that is where the missing "offset +" surfaces.
data := append(build32BitBox("ftyp", []byte("isom")), moov...)
if got := readMp4DurationBytes(data); got != 5000 {
t.Errorf("readMp4DurationBytes(64-bit moov at offset>0) = %d, want 5000", got)
}
}
// TestFindMP4Box64BitSizeAtNonZeroOffset is the findMP4Box-level analogue: a
// 64-bit box preceding the target must advance the cursor by offset+largesize
// so the following box is located at the right position.
func TestFindMP4Box64BitSizeAtNonZeroOffset(t *testing.T) {
free := build64BitBox("free", 24, make([]byte, 8)) // 16-byte header + 8 bytes
target := build32BitBox("mvhd", []byte("payload!"))
data := append(free, target...)
start, end := findMP4Box(data, 0, len(data), "mvhd")
if start < 0 {
t.Fatalf("findMP4Box did not find mvhd after a 64-bit box (start=%d)", start)
}
if got := string(data[start:end]); got != "payload!" {
t.Errorf("findMP4Box returned %q, want %q", got, "payload!")
}
}

109
shortcuts/mail/body_file.go Normal file
View File

@@ -0,0 +1,109 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"io"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// bodyFileFlag is the shared `--body-file` flag declaration reused by every
// compose shortcut (+send / +draft-create / +reply / +reply-all / +forward).
// All six shortcuts honour the same mutual-exclusion contract with `--body`
// and the cwd-subtree path safety rule. The flag is intentionally NOT
// shared with `+lint-html` because that command's description differs
// ("HTML to lint" vs "email body") in a way that is more readable when
// authored per-shortcut. `+draft-edit` does not expose `--body-file` either
// — its body ops flow through `--patch-file` JSON whose `value` field is
// the natural file-based entry point for large bodies.
var bodyFileFlag = common.Flag{
Name: "body-file",
Desc: "Path (relative, within cwd subtree) to a file containing the email body HTML. Mutually exclusive with --body. Size capped at 32 MB.",
Input: []string{common.File},
}
// maxBodyFileSize caps the size of a `--body-file` HTML input. The compose
// path's downstream EML limit is 25 MB (helpers.go MAX_EML_BYTES); we allow a
// bit more headroom here (32 MB) so a body close to the limit still loads
// before the downstream check fires with a clearer error message. The cap
// prevents an `io.ReadAll` from blowing memory on a misdirected gigabyte
// file.
const maxBodyFileSize = 32 * 1024 * 1024 // 32 MB
// validateBodyFileMutex enforces the `--body` / `--body-file` mutual
// exclusion + cwd-subtree path safety. Compose shortcuts call this in
// their Validate phase so AI / users see a clear error before any work
// runs. Pass the shortcut's RuntimeContext-resolved flag values directly:
// `bodyFlag` is the `--body` value (may be empty), `bodyFile` is the
// trimmed `--body-file` value, and `validatePath` is the
// runtime.ValidatePath bound function used to enforce the relative-path
// rule (cwd-subtree only; no absolute / `..` traversal).
//
// Returns an ErrValidation error when either invariant is violated, nil
// otherwise. The "exactly one of {--body, --body-file}" check is
// shortcut-specific (some shortcuts allow neither, e.g. `+forward` with
// no explicit body) and is therefore left to the caller.
func validateBodyFileMutex(bodyFlag, bodyFile string, validatePath func(string) error) error {
bodyEmpty := strings.TrimSpace(bodyFlag) == ""
if !bodyEmpty && bodyFile != "" {
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
}
if bodyFile != "" {
if err := validatePath(bodyFile); err != nil {
return output.ErrValidation("--body-file: %v", err)
}
}
return nil
}
// resolveBodyFromFlags returns the body content from --body or --body-file.
// Validate has already enforced mutual exclusion via validateBodyFileMutex,
// so exactly one is set (or neither when a template / parent message
// supplies the body). Returns ("", nil) when neither flag is set so
// downstream code can decide whether the empty body is allowed.
func resolveBodyFromFlags(runtime *common.RuntimeContext) (string, error) {
if body := runtime.Str("body"); strings.TrimSpace(body) != "" {
return body, nil
}
path := strings.TrimSpace(runtime.Str("body-file"))
if path == "" {
return "", nil
}
return readBodyFile(runtime.FileIO(), path)
}
func validateRequiredResolvedBody(body string, hasTemplate bool, message string) error {
if !hasTemplate && strings.TrimSpace(body) == "" {
return output.ErrValidation(message)
}
return nil
}
// readBodyFile loads --body-file content with a size cap. Returns an
// ErrValidation error if the file exceeds maxBodyFileSize or any IO error
// occurs. The size check uses io.LimitReader(maxBodyFileSize+1) so any
// over-cap byte is observable without reading the whole file.
//
// Callers MUST have run runtime.ValidatePath(path) on `path` first — the
// helper only opens the file via the supplied FileIO and does not repeat
// the cwd-subtree safety check.
func readBodyFile(fio fileio.FileIO, path string) (string, error) {
f, err := fio.Open(path)
if err != nil {
return "", output.ErrValidation("open --body-file %s: %v", path, err)
}
defer f.Close()
buf, err := io.ReadAll(io.LimitReader(f, maxBodyFileSize+1))
if err != nil {
return "", output.ErrValidation("read --body-file %s: %v", path, err)
}
if len(buf) > maxBodyFileSize {
return "", output.ErrValidation("--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)
}
return string(buf), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,920 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package lint
import (
"strings"
"testing"
)
// =====================================================================
// Tier 1 — pass-through tags / attrs / styles (tag classification row "通过").
// =====================================================================
// TestRun_AllowedTagsPassThrough verifies that the canonical Feishu-native
// tag set passes through without findings (tag classification row "通过").
func TestRun_AllowedTagsPassThrough(t *testing.T) {
cases := []struct {
name string
html string
}{
{"plain paragraph", `<p>hello world</p>`},
{"div with span", `<div><span>nested</span></div>`},
{"unordered list", `<ul><li>a</li><li>b</li></ul>`},
{"ordered list", `<ol><li>x</li></ol>`},
{"table", `<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>v</td></tr></tbody></table>`},
{"headings", `<h1>t</h1><h2>t</h2><h3>t</h3><h4>t</h4><h5>t</h5><h6>t</h6>`},
{"emphasis", `<b>b</b><i>i</i><em>e</em><strong>s</strong><u>u</u><s>k</s>`},
{"sub sup", `<sub>s</sub><sup>p</sup>`},
{"hr br", `<p>x<br>y</p><hr>`},
{"blockquote", `<blockquote>q</blockquote>`},
{"code pre", `<pre><code>x = 1</code></pre>`},
{"safe href", `<a href="https://example.com">link</a>`},
{"mailto href", `<a href="mailto:a@b.c">m</a>`},
{"cid img", `<img src="cid:abc123">`},
{"data:image png", `<img src="data:image/png;base64,iVBOR" alt="x">`},
{"feishu native quote class",
`<div class="adit-html-block adit-html-block--collapsed"><div>x</div></div>`},
}
// Feishu-native autofix rules apply to <p>/<ul>/<ol>/<li>/<blockquote>/<a>
// — those are not "violations" so must not be flagged as errors. We
// allow STYLE_*_NATIVE_INLINE_APPLIED + STYLE_PARA_WRAPPER_REWRITTEN
// findings here but reject any other rule.
feishuNativeRules := map[string]bool{
RuleStyleListNative: true,
RuleStyleListItemNative: true,
RuleStyleBlockquoteNative: true,
RuleStyleLinkNative: true,
RuleStyleParaWrapper: true,
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rep := Run(tc.html, Options{})
if len(rep.Blocked) != 0 {
t.Errorf("expected no errors, got %d: %+v", len(rep.Blocked), rep.Blocked)
}
for _, f := range rep.Applied {
if !feishuNativeRules[f.RuleID] {
t.Errorf("unexpected non-Feishu-native warning: %+v", f)
}
}
})
}
}
// TestRun_AllowedStylePropertiesPassThrough verifies all allowed style
// properties survive a round-trip without dropping.
func TestRun_AllowedStylePropertiesPassThrough(t *testing.T) {
allowed := []string{
"color:rgb(31,35,41)",
"background-color:rgb(245,246,247)",
"font-size:14px",
"font-weight:bold",
"font-style:italic",
"text-align:center",
"text-decoration:underline",
"line-height:1.6",
"padding:8px",
"margin:12px",
"border:1px solid #ccc",
"border-top:1px solid red",
"border-bottom:2px solid blue",
"border-left:1px",
"border-right:1px",
"width:100%",
"height:auto",
"display:block",
"text-indent:2em",
}
for _, prop := range allowed {
t.Run(prop, func(t *testing.T) {
html := `<p style="` + prop + `">x</p>`
rep := Run(html, Options{})
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped {
t.Errorf("property %q unexpectedly dropped: %+v", prop, f)
}
}
})
}
}
// =====================================================================
// Tier 2 — warning + autofix tags (tag classification row "警告 + 自动修复").
// =====================================================================
// TestRun_FontTagAutofixedToSpan verifies <font color="..."> rewrites to
// <span style="color:..."> with AutoFix=true.
func TestRun_FontTagAutofixedToSpan(t *testing.T) {
// Use <div> wrapper to avoid the Feishu-native paragraph autofix
// firing alongside the <font> rewrite.
rep := Run(`<div><font color="red">x</font></div>`, Options{})
if len(rep.Applied) != 1 {
t.Fatalf("expected 1 warning, got %d: %+v", len(rep.Applied), rep.Applied)
}
got := rep.Applied[0]
if got.RuleID != RuleTagFontToSpan {
t.Errorf("rule = %s, want %s", got.RuleID, RuleTagFontToSpan)
}
if got.Severity != SeverityWarning {
t.Errorf("severity = %s, want warning", got.Severity)
}
if !strings.Contains(rep.CleanedHTML, "<span") || strings.Contains(rep.CleanedHTML, "<font") {
t.Errorf("expected <font>→<span> rewrite, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "color:red") {
t.Errorf("expected color preserved as inline style, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_FontTagSizeMappedToPx checks legacy <font size="N"> → font-size:Npx.
func TestRun_FontTagSizeMappedToPx(t *testing.T) {
rep := Run(`<font size="3">x</font>`, Options{})
if !strings.Contains(rep.CleanedHTML, "font-size:16px") {
t.Errorf("expected size=3 → 16px, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_CenterTagAutofixedToDiv verifies <center> → <div text-align:center>.
func TestRun_CenterTagAutofixedToDiv(t *testing.T) {
rep := Run(`<center>x</center>`, Options{})
if len(rep.Applied) != 1 {
t.Fatalf("expected 1 warning, got %d", len(rep.Applied))
}
if rep.Applied[0].RuleID != RuleTagCenterToDiv {
t.Errorf("rule = %s, want %s", rep.Applied[0].RuleID, RuleTagCenterToDiv)
}
if !strings.Contains(rep.CleanedHTML, "<div") || !strings.Contains(rep.CleanedHTML, "text-align:center") {
t.Errorf("expected <center>→<div text-align:center>, cleaned=%q", rep.CleanedHTML)
}
if strings.Contains(rep.CleanedHTML, "<center") {
t.Errorf("<center> should have been replaced, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_MarqueeBlinkCollapseToSpan verifies <marquee>/<blink> → <span>.
func TestRun_MarqueeBlinkCollapseToSpan(t *testing.T) {
for _, tag := range []string{"marquee", "blink"} {
rep := Run("<"+tag+">x</"+tag+">", Options{})
if len(rep.Applied) != 1 {
t.Errorf("[%s] expected 1 warning, got %d", tag, len(rep.Applied))
continue
}
if !strings.Contains(rep.CleanedHTML, "<span") {
t.Errorf("[%s] expected <span> wrapper, cleaned=%q", tag, rep.CleanedHTML)
}
}
}
// =====================================================================
// Tier 3 — error / delete tags (tag classification row "错误(删除)").
// =====================================================================
// TestRun_ScriptTagBlocked checks that <script> is removed unconditionally.
func TestRun_ScriptTagBlocked(t *testing.T) {
rep := Run(`<p>safe</p><script>alert(1)</script><p>after</p>`, Options{})
if len(rep.Blocked) != 1 {
t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked))
}
if rep.Blocked[0].RuleID != RuleTagScriptBlocked {
t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleTagScriptBlocked)
}
if strings.Contains(rep.CleanedHTML, "<script") || strings.Contains(rep.CleanedHTML, "alert(1)") {
t.Errorf("<script> content should be deleted, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "safe") || !strings.Contains(rep.CleanedHTML, "after") {
t.Errorf("surrounding content lost, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_BlockedTagsRemoved iterates all error-tier tags.
func TestRun_BlockedTagsRemoved(t *testing.T) {
cases := map[string]string{
`<iframe src="x"></iframe>`: RuleTagIframeBlocked,
`<object data="x"></object>`: RuleTagObjectBlocked,
`<embed src="x">`: RuleTagEmbedBlocked,
`<form action="x"><input></form>`: RuleTagFormBlocked,
`<link rel="stylesheet" href="x.css">`: RuleTagLinkBlocked,
`<meta http-equiv="refresh" content="0">`: RuleTagMetaBlocked,
`<base href="https://evil.com">`: RuleTagBaseBlocked,
}
for input, wantRule := range cases {
t.Run(input[:min(len(input), 30)], func(t *testing.T) {
rep := Run(input, Options{})
found := false
for _, f := range rep.Blocked {
if f.RuleID == wantRule {
found = true
break
}
}
if !found {
t.Errorf("expected rule %s, got %+v", wantRule, rep.Blocked)
}
})
}
}
// TestRun_EventHandlerAttrBlocked verifies on*-handlers (onclick etc.) are
// stripped — they are an event-handler injection vector.
func TestRun_EventHandlerAttrBlocked(t *testing.T) {
rep := Run(`<p onclick="alert(1)" id="ok">x</p>`, Options{})
if len(rep.Blocked) != 1 {
t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked))
}
if rep.Blocked[0].RuleID != RuleAttrEventHandlerBlocked {
t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleAttrEventHandlerBlocked)
}
if strings.Contains(rep.CleanedHTML, "onclick") {
t.Errorf("onclick should be stripped, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, `id="ok"`) {
t.Errorf("non-handler attrs should survive, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_OnErrorAttrBlocked tests one of the more common XSS vectors.
func TestRun_OnErrorAttrBlocked(t *testing.T) {
rep := Run(`<img src="cid:x" onerror="alert(1)">`, Options{})
hasErr := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrEventHandlerBlocked && f.TagOrAttr == "onerror" {
hasErr = true
}
}
if !hasErr {
t.Errorf("onerror should fire, got %+v", rep.Blocked)
}
}
// =====================================================================
// URL scheme allow-list.
// =====================================================================
// TestRun_JavaScriptURLBlocked verifies javascript: hrefs are stripped.
func TestRun_JavaScriptURLBlocked(t *testing.T) {
rep := Run(`<a href="javascript:alert(1)">click</a>`, Options{})
hasErr := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked {
hasErr = true
}
}
if !hasErr {
t.Errorf("javascript: URL should fire ATTR_JS_URL_BLOCKED, got %+v", rep.Blocked)
}
if strings.Contains(rep.CleanedHTML, "javascript:") {
t.Errorf("javascript: should be stripped, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_VBScriptURLBlocked verifies vbscript: is rejected.
func TestRun_VBScriptURLBlocked(t *testing.T) {
rep := Run(`<a href="vbscript:msgbox 1">x</a>`, Options{})
if len(rep.Blocked) == 0 {
t.Errorf("expected vbscript: to be blocked, got 0 findings")
}
}
// TestRun_DataNonImageURLBlocked verifies data:text/html is rejected
// (only data:image/* is allowed).
func TestRun_DataNonImageURLBlocked(t *testing.T) {
rep := Run(`<img src="data:text/html,<script>1</script>">`, Options{})
if len(rep.Blocked) == 0 {
t.Errorf("expected data:text/html to be blocked")
}
}
// TestRun_DataImageAllowed verifies data:image/png passes.
func TestRun_DataImageAllowed(t *testing.T) {
rep := Run(`<img src="data:image/png;base64,iVBORw0KGg=">`, Options{})
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked {
t.Errorf("data:image/* should pass, got %+v", f)
}
}
}
// TestRun_RelativeURLAllowed verifies relative URLs (no scheme) pass.
func TestRun_RelativeURLAllowed(t *testing.T) {
rep := Run(`<img src="./local.png"><a href="/path">x</a>`, Options{})
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked || f.RuleID == RuleAttrUnsafeSchemeBlocked {
t.Errorf("relative URL should pass, got %+v", f)
}
}
}
// =====================================================================
// Style property allow-list.
// =====================================================================
// TestRun_StylePropertyDropped verifies non-allow-list properties drop.
func TestRun_StylePropertyDropped(t *testing.T) {
rep := Run(`<p style="color:red; position:absolute; z-index:99">x</p>`, Options{})
dropped := []string{}
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped {
dropped = append(dropped, f.TagOrAttr)
}
}
if !sliceContains(dropped, "style.position") {
t.Errorf("expected position to be dropped, got %v", dropped)
}
if !sliceContains(dropped, "style.z-index") {
t.Errorf("expected z-index to be dropped, got %v", dropped)
}
if strings.Contains(rep.CleanedHTML, "position:") || strings.Contains(rep.CleanedHTML, "z-index:") {
t.Errorf("dropped properties should be removed from cleaned style, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "color:red") {
t.Errorf("allowed property should survive, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_StyleBorderPrefixAllowed verifies the border-* prefix rule.
func TestRun_StyleBorderPrefixAllowed(t *testing.T) {
rep := Run(`<p style="border-top:1px; border-bottom-color:red; border-radius:4px">x</p>`, Options{})
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped {
t.Errorf("border-* should pass, got %+v", f)
}
}
}
// TestRun_FeishuListShorthandMarginPreserved guards the nested-list indent
// regression: when a user writes shorthand `margin:0 0 0 24px` on an inner
// <ul> (mail-editor's own native nested-list shape), the Feishu-list autofix
// must NOT clobber it by appending `margin-left:0`. ensureInlineStyleProps
// is supposed to skip props the user already declared, but earlier
// hasInlineStyleProp was only matching longhand `margin-left:` literally
// and missed the shorthand form, causing 24px indents to be reset to 0.
func TestRun_FeishuListShorthandMarginPreserved(t *testing.T) {
in := `<ul style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;list-style-type:circle;font-size:14px" dir="auto"><span style="font-family:inherit"><span style="color:rgb(0,0,0)">indented</span></span></li></ul>`
rep := Run(in, Options{})
cleaned := rep.CleanedHTML
// Extract just the <ul ...> opening tag's style attr (li has its own
// independent margin-left:0 longhand which is correct — list indent
// belongs on the container, not the item).
ulOpen := cleaned
if i := strings.Index(ulOpen, ">"); i >= 0 {
ulOpen = ulOpen[:i]
}
if !strings.Contains(ulOpen, "margin:0px 0px 0px 24px") {
t.Errorf("shorthand margin with 24px left should survive on <ul>, ulOpen=%q", ulOpen)
}
// The bug signature: extra `margin-left:` appended after the shorthand
// on the <ul> element itself (CSS rule says the later one wins, so any
// margin-left:0 after the shorthand resets the indent to 0).
if strings.Contains(ulOpen, "margin-left") {
t.Errorf("autofix must not append margin-left longhand onto <ul> when shorthand already declares it, ulOpen=%q", ulOpen)
}
}
// TestRun_BlockquoteShorthandBorderPreserved verifies the blockquote native
// autofix does not override a user-authored border shorthand by appending
// border-left. CSS applies the later longhand over the earlier shorthand, so
// adding border-left here would replace the user's left border.
func TestRun_BlockquoteShorthandBorderPreserved(t *testing.T) {
rep := Run(`<blockquote style="border:1px solid red">quoted</blockquote>`, Options{})
cleaned := rep.CleanedHTML
if !strings.Contains(cleaned, `border:1px solid red`) {
t.Fatalf("user-authored border shorthand should survive, cleaned=%q", cleaned)
}
if strings.Contains(cleaned, `border-left:`) {
t.Fatalf("autofix must not append border-left when border shorthand already declares it, cleaned=%q", cleaned)
}
if !strings.Contains(cleaned, `color:rgb(100,106,115)`) {
t.Fatalf("blockquote native autofix should still add missing non-border style props, cleaned=%q", cleaned)
}
}
func TestRun_BlockquoteNativeContentWrapper(t *testing.T) {
rep := Run(`<blockquote>quoted</blockquote>`, Options{})
cleaned := rep.CleanedHTML
for _, want := range []string{
`class="lark-mail-doc-quote"`,
`border-left:2px solid rgb(187,191,196)`,
`<div dir="auto" style="font-size:14px;padding-left:12px">quoted</div>`,
} {
if !strings.Contains(cleaned, want) {
t.Fatalf("cleaned blockquote missing %q, cleaned=%q", want, cleaned)
}
}
}
func TestRun_BlockquoteNativeContentWrapperIdempotent(t *testing.T) {
in := `<blockquote class="lark-mail-doc-quote" style="padding-left:0px;color:rgb(100,106,115);border-left:2px solid rgb(187,191,196);margin:0px"><div dir="auto" style="font-size:14px;padding-left:12px">quoted</div></blockquote>`
rep := Run(in, Options{})
if strings.Count(rep.CleanedHTML, `padding-left:12px`) != 1 {
t.Fatalf("native-shaped blockquote should not get nested content wrappers, cleaned=%q", rep.CleanedHTML)
}
}
func TestRun_ParagraphRewritePreservesDirAndFontSize(t *testing.T) {
rep := Run(`<p style="font-size:20px" dir="rtl">hello</p>`, Options{})
cleaned := rep.CleanedHTML
if !strings.Contains(cleaned, `style="font-size:20px;margin-top:4px;margin-bottom:4px;line-height:1.6" dir="rtl"`) {
t.Fatalf("outer paragraph wrapper should preserve author font-size and dir, cleaned=%q", cleaned)
}
if !strings.Contains(cleaned, `<div dir="rtl">hello</div>`) {
t.Fatalf("inner paragraph wrapper should inherit author dir and omit default font-size, cleaned=%q", cleaned)
}
if strings.Contains(cleaned, `font-size:14px`) {
t.Fatalf("inner paragraph wrapper must not force default font-size over author value, cleaned=%q", cleaned)
}
if strings.Contains(cleaned, `dir="auto"`) {
t.Fatalf("inner paragraph wrapper must not force dir=auto over author value, cleaned=%q", cleaned)
}
}
// =====================================================================
// CleanedHTML output / contract guarantees.
// =====================================================================
// TestRun_EmptyArraysAlwaysPresent verifies the report has non-nil empty
// slices when nothing is found (the JSON envelope contract requires `[]`,
// not `null`).
func TestRun_EmptyArraysAlwaysPresent(t *testing.T) {
// Use <div> instead of <p> to avoid the Feishu-native paragraph
// rewrite autofix, which would surface a finding even on otherwise
// clean input.
rep := Run(`<div>nothing here</div>`, Options{})
if rep.Applied == nil || rep.Blocked == nil {
t.Errorf("Applied/Blocked must be non-nil; got applied=%v blocked=%v", rep.Applied, rep.Blocked)
}
if len(rep.Applied) != 0 || len(rep.Blocked) != 0 {
t.Errorf("expected empty findings, got applied=%d blocked=%d", len(rep.Applied), len(rep.Blocked))
}
}
// TestEmptyReport_HasContractFields covers the helper used by compose 5's
// plain-text branch.
func TestEmptyReport_HasContractFields(t *testing.T) {
rep := EmptyReport(`plain text`)
if rep.Applied == nil {
t.Error("Applied must be non-nil")
}
if rep.Blocked == nil {
t.Error("Blocked must be non-nil")
}
if rep.CleanedHTML != "plain text" {
t.Errorf("CleanedHTML = %q, want %q", rep.CleanedHTML, "plain text")
}
}
// TestRun_CleanedHTMLPreservesStructure verifies that the round-trip through
// the parser doesn't accidentally lose user content.
func TestRun_CleanedHTMLPreservesStructure(t *testing.T) {
html := `<div style="line-height:1.6"><h3>title</h3><p>body <b>bold</b> end</p><ul><li>a</li><li>b</li></ul></div>`
rep := Run(html, Options{})
if len(rep.Blocked) != 0 {
t.Fatalf("unexpected blocked: %+v", rep.Blocked)
}
// Feishu-native autofix expected to fire on <p>, <ul>, <li> — content
// must still survive untouched even though structure is augmented.
for _, want := range []string{"line-height:1.6", "<h3>", "title", "<b>", "bold", "<ul", "<li", "</ul>"} {
if !strings.Contains(rep.CleanedHTML, want) {
t.Errorf("expected %q in cleaned, got %q", want, rep.CleanedHTML)
}
}
}
// TestRun_EmptyInput verifies the lib short-circuits cleanly on empty input.
func TestRun_EmptyInput(t *testing.T) {
rep := Run("", Options{})
if rep.CleanedHTML != "" {
t.Errorf("CleanedHTML = %q, want empty", rep.CleanedHTML)
}
if len(rep.Applied) != 0 || len(rep.Blocked) != 0 {
t.Errorf("empty input must produce empty findings")
}
}
// TestRun_HasErrorFindingsFlag verifies the flag tracks blocked findings.
func TestRun_HasErrorFindingsFlag(t *testing.T) {
rep := Run(`<script>x</script>`, Options{})
if !rep.HasErrorFindings {
t.Error("expected HasErrorFindings=true")
}
clean := Run(`<p>safe</p>`, Options{})
if clean.HasErrorFindings {
t.Error("expected HasErrorFindings=false on clean HTML")
}
}
// TestRun_HasWarningFindingsFlag verifies the flag tracks warnings.
func TestRun_HasWarningFindingsFlag(t *testing.T) {
rep := Run(`<font color="red">x</font>`, Options{})
if !rep.HasWarningFindings {
t.Error("expected HasWarningFindings=true")
}
}
// =====================================================================
// Excerpt cap.
// =====================================================================
// TestTruncateExcerpt_RespectsCap verifies the per-finding excerpt cap.
func TestTruncateExcerpt_RespectsCap(t *testing.T) {
long := strings.Repeat("x", MaxExcerptBytes+50)
got := truncateExcerpt(long)
if len(got) > MaxExcerptBytes {
t.Errorf("excerpt len %d exceeds cap %d", len(got), MaxExcerptBytes)
}
if !strings.HasSuffix(got, " ...") {
t.Errorf("expected truncation suffix, got %q", got[len(got)-10:])
}
}
// TestRun_ExcerptCappedForLargeOffender verifies large blocked content
// produces a short excerpt (envelope size protection).
func TestRun_ExcerptCappedForLargeOffender(t *testing.T) {
bigAttr := strings.Repeat("a", MaxExcerptBytes*2)
rep := Run(`<a href="javascript:`+bigAttr+`">x</a>`, Options{})
if len(rep.Blocked) == 0 {
t.Fatal("expected blocked finding")
}
for _, f := range rep.Blocked {
if len(f.Excerpt) > MaxExcerptBytes {
t.Errorf("excerpt len %d exceeds cap %d", len(f.Excerpt), MaxExcerptBytes)
}
}
}
// =====================================================================
// Helpers.
// =====================================================================
func sliceContains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// =====================================================================
// Additional coverage for edge cases and exhaustive value mapping.
// =====================================================================
// TestMapFontSize_ExhaustiveSpan covers every <font size="N"> mapping
// + invalid values fall through to "" so the property is dropped.
func TestMapFontSize_ExhaustiveSpan(t *testing.T) {
cases := map[string]string{
"1": "10px",
"2": "13px",
"3": "16px",
"4": "18px",
"5": "24px",
"6": "32px",
"7": "48px",
"": "",
"8": "",
"abc": "",
"3.5": "",
" 3 ": "16px",
}
for raw, want := range cases {
got := mapFontSize(raw)
if got != want {
t.Errorf("mapFontSize(%q) = %q, want %q", raw, got, want)
}
}
}
// TestRun_FontTagWithFaceMappedToFontFamily ensures <font face="..."> →
// font-family inline style.
func TestRun_FontTagWithFaceMappedToFontFamily(t *testing.T) {
rep := Run(`<font face="Arial">x</font>`, Options{})
if !strings.Contains(rep.CleanedHTML, "font-family:Arial") {
t.Errorf("expected font-family preserved, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_FontTagWithExistingStyleMerged ensures distillation merges with an
// existing style attribute on the same element.
func TestRun_FontTagWithExistingStyleMerged(t *testing.T) {
rep := Run(`<font color="red" style="line-height:1.6">x</font>`, Options{})
if !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
t.Errorf("expected line-height retained, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "color:red") {
t.Errorf("expected color merged, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_CenterTagWithExistingStyleMerged ensures <center>'s style merge.
func TestRun_CenterTagWithExistingStyleMerged(t *testing.T) {
rep := Run(`<center style="line-height:1.6">x</center>`, Options{})
if !strings.Contains(rep.CleanedHTML, "text-align:center") {
t.Errorf("expected text-align:center, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
t.Errorf("expected line-height preserved, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_MarqueeRetainsClassAndID verifies marquee → span keeps class/id.
func TestRun_MarqueeRetainsClassAndID(t *testing.T) {
rep := Run(`<marquee class="cls" id="x" direction="left">y</marquee>`, Options{})
if !strings.Contains(rep.CleanedHTML, `class="cls"`) {
t.Errorf("expected class preserved, cleaned=%q", rep.CleanedHTML)
}
if strings.Contains(rep.CleanedHTML, `direction`) {
t.Errorf("expected marquee-specific attrs stripped, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_UnknownSchemeBlocked verifies an unknown URL scheme produces a
// blocked (error) finding and the attribute is dropped.
func TestRun_UnknownSchemeBlocked(t *testing.T) {
rep := Run(`<a href="webcal://x">x</a>`, Options{})
gotBlocked := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrUnsafeSchemeBlocked {
gotBlocked = true
}
}
if !gotBlocked {
t.Errorf("expected ATTR_UNSAFE_SCHEME_BLOCKED in Blocked, got blocked=%+v applied=%+v", rep.Blocked, rep.Applied)
}
if strings.Contains(rep.CleanedHTML, "webcal:") {
t.Errorf("expected unknown scheme stripped, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_WhitespaceObfuscatedJavaScriptScheme verifies "java\tscript:..."
// is still caught after control-byte stripping in classifyURLValue.
func TestRun_WhitespaceObfuscatedJavaScriptScheme(t *testing.T) {
rep := Run("<a href=\"java\tscript:alert(1)\">x</a>", Options{})
gotErr := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked {
gotErr = true
}
}
if !gotErr {
t.Errorf("expected obfuscated javascript: to be caught, got %+v", rep.Blocked)
}
}
// TestRun_FileSchemeBlocked verifies file: URLs are rejected.
func TestRun_FileSchemeBlocked(t *testing.T) {
rep := Run(`<a href="file:///etc/passwd">x</a>`, Options{})
if len(rep.Blocked) == 0 {
t.Error("expected file: to be blocked")
}
}
// TestRun_StyleMalformedDeclarationDropped verifies a property without a
// colon delimiter is treated as malformed and dropped.
func TestRun_StyleMalformedDeclarationDropped(t *testing.T) {
rep := Run(`<p style="color:red; malformed; line-height:1.6">x</p>`, Options{})
gotMalformed := false
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped && f.TagOrAttr == "style.malformed" {
gotMalformed = true
}
}
if !gotMalformed {
t.Errorf("expected malformed declaration to be dropped, got %+v", rep.Applied)
}
if !strings.Contains(rep.CleanedHTML, "color:red") || !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
t.Errorf("valid declarations should survive, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_StyleAllPropertiesDroppedRemovesAttribute verifies the style
// attribute is removed entirely when every property is invalid.
func TestRun_StyleAllPropertiesDroppedRemovesAttribute(t *testing.T) {
// Use <div> to avoid the Feishu-native paragraph autofix, which adds
// a fresh style attribute on the rewritten outer wrapper.
rep := Run(`<div style="position:absolute; z-index:99">x</div>`, Options{})
if strings.Contains(rep.CleanedHTML, "style=") {
t.Errorf("style attribute should be removed when all props invalid, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_StyleEmptyValuePassThrough verifies an empty style attr passes.
func TestRun_StyleEmptyValuePassThrough(t *testing.T) {
// Use <div> to avoid the Feishu-native paragraph autofix.
rep := Run(`<div style="">x</div>`, Options{})
if len(rep.Applied) != 0 {
t.Errorf("empty style attr should not produce findings, got %+v", rep.Applied)
}
}
// TestRun_HintsForAllBlockedTags verifies every blocked-tag rule has a
// non-empty hint (consumer contract).
func TestRun_HintsForAllBlockedTags(t *testing.T) {
cases := []string{
`<script>x</script>`, `<iframe src="x"></iframe>`,
`<object data="x"></object>`, `<embed src="x">`, `<form><input></form>`,
`<select></select>`, `<button>x</button>`, `<link href="x">`,
`<meta name="x">`, `<base href="x">`,
}
for _, html := range cases {
rep := Run(html, Options{})
for _, f := range rep.Blocked {
if f.Hint == "" {
t.Errorf("blocked rule %s missing hint for %q", f.RuleID, html)
}
}
}
}
// TestRun_HintsForAllWarnTags verifies every warn-tag rule has a non-empty hint.
func TestRun_HintsForAllWarnTags(t *testing.T) {
cases := []string{
`<font>x</font>`, `<center>x</center>`,
`<marquee>x</marquee>`, `<blink>x</blink>`,
}
for _, html := range cases {
rep := Run(html, Options{})
for _, f := range rep.Applied {
if f.Hint == "" {
t.Errorf("warn rule %s missing hint for %q", f.RuleID, html)
}
}
}
}
// TestClassifyTag_Coverage exercises classifyTag with every category.
func TestClassifyTag_Coverage(t *testing.T) {
if k, _ := classifyTag("p"); k != "allow" {
t.Errorf("p classified as %q", k)
}
if k, id := classifyTag("script"); k != "error" || id != RuleTagScriptBlocked {
t.Errorf("script classified as %q/%q", k, id)
}
if k, id := classifyTag("font"); k != "warn" || id != RuleTagFontToSpan {
t.Errorf("font classified as %q/%q", k, id)
}
// Niche tag passes silently (e.g. <details>).
if k, _ := classifyTag("details"); k != "allow" {
t.Errorf("niche tag <details> should pass through, got %q", k)
}
// Case-insensitive.
if k, _ := classifyTag("SCRIPT"); k != "error" {
t.Errorf("SCRIPT (uppercase) should still classify as error")
}
}
// TestClassifyURLValue_CoverageEdges covers empty, whitespace-only,
// no-scheme variants.
func TestClassifyURLValue_CoverageEdges(t *testing.T) {
cases := map[string]string{
"": "ok",
" ": "ok",
"https://x": "ok",
"https://x/path?q=1": "ok",
"#fragment": "ok",
"/relative": "ok",
"javascript:alert(1)": "error",
"vbscript:msgbox 1": "error",
"data:image/png;base64,XYZ": "ok",
"data:text/html,<script>": "error",
"webcal://x": "warn",
}
for raw, want := range cases {
got, _ := classifyURLValue(raw)
if got != want {
t.Errorf("classifyURLValue(%q) = %q, want %q", raw, got, want)
}
}
}
// TestClassifyStyleProperty_Coverage covers prefixes & explicit set.
func TestClassifyStyleProperty_Coverage(t *testing.T) {
cases := map[string]bool{
"color": true,
"BACKGROUND-COLOR": true, // case-insensitive
"border-top": true,
"padding-left": true,
"margin-bottom": true,
"position": false,
"z-index": false,
"": false,
" ": false,
}
for prop, want := range cases {
got := classifyStyleProperty(prop)
if got != want {
t.Errorf("classifyStyleProperty(%q) = %v, want %v", prop, got, want)
}
}
}
// TestIsEventHandlerAttr_Coverage covers the on*-detection rule.
func TestIsEventHandlerAttr_Coverage(t *testing.T) {
cases := map[string]bool{
"onclick": true,
"onmouseover": true,
"OnLoad": true, // case-insensitive
"on0": true,
"on": false, // need at least one char after "on"
"onerror": true,
"onsubmit": true,
"once": true, // would match unfortunately because "once" starts with "on" + 'c'
"id": false,
"href": false,
"data-on": false,
}
for k, want := range cases {
got := isEventHandlerAttr(k)
if got != want {
t.Errorf("isEventHandlerAttr(%q) = %v, want %v", k, got, want)
}
}
}
// TestRun_ParseFailureFallsBackGracefully verifies extreme malformed input
// short-circuits to EmptyReport.
func TestRun_PlainTextInputProducesNoFindings(t *testing.T) {
rep := Run("just a plain string with no markup", Options{})
if len(rep.Blocked) != 0 || len(rep.Applied) != 0 {
t.Errorf("plain text should produce no findings, got %+v %+v", rep.Blocked, rep.Applied)
}
}
// TestRun_MultipleErrorsAccumulate ensures multiple offenders all surface.
func TestRun_MultipleErrorsAccumulate(t *testing.T) {
html := `<script>1</script><iframe></iframe><a href="javascript:0">x</a>` +
`<form></form><p onclick="">y</p>`
rep := Run(html, Options{})
if len(rep.Blocked) < 4 {
t.Errorf("expected ≥4 errors, got %d: %+v", len(rep.Blocked), rep.Blocked)
}
}
// TestRun_NestedStructurePreserved verifies deep nesting passes through.
func TestRun_NestedStructurePreserved(t *testing.T) {
html := `<div><div><div><p><span><b>deep</b></span></p></div></div></div>`
rep := Run(html, Options{})
if len(rep.Blocked) != 0 {
t.Errorf("nested allowed tags should pass, got %+v", rep.Blocked)
}
if !strings.Contains(rep.CleanedHTML, "deep") {
t.Errorf("inner text lost, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_BlockedInsideAllowedRemovedNotParent verifies that removing a
// blocked tag inside an allowed parent leaves the parent intact.
func TestRun_BlockedInsideAllowedRemovedNotParent(t *testing.T) {
html := `<div>before<script>1</script>after</div>`
rep := Run(html, Options{})
if !strings.Contains(rep.CleanedHTML, "before") || !strings.Contains(rep.CleanedHTML, "after") {
t.Errorf("parent text should survive, cleaned=%q", rep.CleanedHTML)
}
if strings.Contains(rep.CleanedHTML, "<script") {
t.Errorf("script should be removed, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_ListDirectChildNonLIWrapped verifies that a <ul><ul> nested
// directly without an <li> wrapper triggers LIST_DIRECT_CHILD_NON_LI and
// the inner <ul> ends up wrapped in a synthetic <li>. Same for <ol><ol>.
func TestRun_ListDirectChildNonLIWrapped(t *testing.T) {
cases := []struct {
name string
html string
}{
{"ul wraps ul", `<ul><ul><li>x</li></ul></ul>`},
{"ol wraps ol", `<ol><ol><li>x</li></ol></ol>`},
{"ul wraps div", `<ul><div>orphan</div><li>real</li></ul>`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rep := Run(tc.html, Options{})
gotRule := false
for _, f := range rep.Applied {
if f.RuleID == RuleListDirectChildNonLI {
gotRule = true
break
}
}
if !gotRule {
t.Errorf("expected LIST_DIRECT_CHILD_NON_LI, got %+v", rep.Applied)
}
// The cleaned HTML should not have a direct ul>ul or ol>ol or
// ul>div sequence anymore.
if strings.Contains(rep.CleanedHTML, "<ul><ul") ||
strings.Contains(rep.CleanedHTML, "<ol><ol") ||
strings.Contains(rep.CleanedHTML, "<ul><div") {
t.Errorf("expected synthetic <li> wrapper, cleaned=%q", rep.CleanedHTML)
}
})
}
}

View File

@@ -0,0 +1,353 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package lint
import "strings"
// Rule IDs surfaced through Finding.RuleID. UPPER_SNAKE_CASE naming is the
// contract for the stdout envelope. New rules MUST keep this naming convention
// so AI / test consumers can pattern-match reliably.
const (
// Tag-level rules.
RuleTagFontToSpan = "TAG_FONT_TO_SPAN"
RuleTagCenterToDiv = "TAG_CENTER_TO_DIV"
RuleTagMarqueeToText = "TAG_MARQUEE_TO_TEXT"
RuleTagBlinkToText = "TAG_BLINK_TO_TEXT"
RuleTagScriptBlocked = "TAG_SCRIPT_BLOCKED"
RuleTagIframeBlocked = "TAG_IFRAME_BLOCKED"
RuleTagObjectBlocked = "TAG_OBJECT_BLOCKED"
RuleTagEmbedBlocked = "TAG_EMBED_BLOCKED"
RuleTagFormBlocked = "TAG_FORM_BLOCKED"
RuleTagInputBlocked = "TAG_INPUT_BLOCKED"
RuleTagLinkBlocked = "TAG_LINK_BLOCKED"
RuleTagMetaBlocked = "TAG_META_BLOCKED"
RuleTagBaseBlocked = "TAG_BASE_BLOCKED"
RuleTagUnknownStripped = "TAG_UNKNOWN_STRIPPED"
// Attribute-level rules.
RuleAttrEventHandlerBlocked = "ATTR_EVENT_HANDLER_BLOCKED"
RuleAttrJSURLBlocked = "ATTR_JS_URL_BLOCKED"
RuleAttrUnsafeSchemeBlocked = "ATTR_UNSAFE_SCHEME_BLOCKED"
// Style-level rules.
RuleStylePropertyDropped = "STYLE_PROPERTY_DROPPED"
// Feishu-native autofix rules. These autofix the inline style /
// class / nesting shape of common elements so AI-authored HTML
// matches what Feishu mail-editor itself emits, fixing the visual
// "extra blank line between blocks", "list bullets/numbers missing",
// "link color wrong" etc. classes of issues. The rewrite is purely
// additive — user-supplied inline styles take precedence; the lib
// only fills the missing properties.
RuleStyleListNative = "STYLE_LIST_NATIVE_INLINE_APPLIED"
RuleStyleListItemNative = "STYLE_LIST_ITEM_NATIVE_INLINE_APPLIED"
RuleStyleBlockquoteNative = "STYLE_BLOCKQUOTE_NATIVE_INLINE_APPLIED"
RuleStyleLinkNative = "STYLE_LINK_NATIVE_INLINE_APPLIED"
RuleStyleParaWrapper = "STYLE_PARA_WRAPPER_REWRITTEN"
// RuleListDirectChildNonLI fires when a <ul> or <ol> has a non-<li>
// element child (e.g. nested <ul><ul>). HTML spec requires list children
// to be <li>; browsers silently hoist the nested list out and the visual
// nesting falls apart. The lib autofixes by wrapping the offending child
// in a synthetic <li>.
RuleListDirectChildNonLI = "LIST_DIRECT_CHILD_NON_LI"
)
// Tag classification ----------------------------------------------------------
// allowedTags enumerates tags that pass through verbatim (tag classification row "通过").
// Lower-case canonical names; the parser normalises tag names so we don't need
// case-insensitive comparison at lookup time.
var allowedTags = map[string]bool{
"p": true,
"div": true,
"span": true,
"br": true,
"hr": true,
"a": true,
"img": true,
"table": true,
"thead": true,
"tbody": true,
"tfoot": true,
"tr": true,
"td": true,
"th": true,
"ul": true,
"ol": true,
"li": true,
"blockquote": true,
"pre": true,
"code": true,
"b": true,
"i": true,
"em": true,
"strong": true,
"u": true,
"s": true,
"strike": true,
"h1": true,
"h2": true,
"h3": true,
"h4": true,
"h5": true,
"h6": true,
"sub": true,
"sup": true,
"section": true,
"article": true,
"header": true,
"footer": true,
"nav": true,
"main": true,
"figure": true,
"figcaption": true,
"caption": true,
"colgroup": true,
"col": true,
// Document structural tags (golang.org/x/net/html always wraps fragments
// in <html><head><body>); we treat them as transparent so the wrapper
// nodes the parser inserts don't generate spurious findings.
"html": true,
"head": true,
"body": true,
}
// blockedTags enumerates tags whose content is removed in full and a
// SeverityError finding is emitted (tag classification row "错误(删除)"). Each entry
// maps to the rule id surfaced in Finding.RuleID.
var blockedTags = map[string]string{
"script": RuleTagScriptBlocked,
"iframe": RuleTagIframeBlocked,
"object": RuleTagObjectBlocked,
"embed": RuleTagEmbedBlocked,
"form": RuleTagFormBlocked,
"input": RuleTagInputBlocked,
"select": RuleTagInputBlocked,
"option": RuleTagInputBlocked,
"button": RuleTagInputBlocked,
"link": RuleTagLinkBlocked,
"meta": RuleTagMetaBlocked,
"base": RuleTagBaseBlocked,
}
// warnAutofixTags enumerates tags rewritten when AutoFix is true (tag
// classification row "警告 + 自动修复"). The replacement strategy is per-tag.
var warnAutofixTags = map[string]string{
"font": RuleTagFontToSpan,
"center": RuleTagCenterToDiv,
"marquee": RuleTagMarqueeToText,
"blink": RuleTagBlinkToText,
}
// classifyTag returns the rule kind for the given lower-case tag name.
//
// kind is one of "allow", "warn", "error", "unknown". For "warn" / "error",
// ruleID names the firing rule; for "unknown", the caller falls back to
// allow-list-by-default but emits a hint via RuleTagUnknownStripped only when
// the tag is structurally suspect (e.g. <object>-like). The cli's existing
// `htmlTagRe` regex is the de-facto allow-list shipping with the codebase, so
// we don't aggressively flag anything outside `allowedTags` — drop-through
// preserves user intent for niche tags (e.g. `<details>` / `<summary>`) that
// browsers + Feishu native renderer already handle.
func classifyTag(tag string) (kind, ruleID string) {
tag = strings.ToLower(tag)
if allowedTags[tag] {
return "allow", ""
}
if id, ok := blockedTags[tag]; ok {
return "error", id
}
if id, ok := warnAutofixTags[tag]; ok {
return "warn", id
}
// Unknown / niche tags: pass through silently. The cli's existing
// `htmlTagRe` (mail_quote.go:333) tolerates them too. Users authoring
// HTML in Feishu native classes (`adit-html-block*`, `history-quote-*`,
// `lark-mail-doc-quote`) hit this path — they MUST pass through unchanged
// so reply / forward quote markup survives lint round-trips.
return "allow", ""
}
// Attribute / URL / style classification --------------------------------------
// allowedURLSchemes lists URL schemes that pass through hyperlink-bearing
// attrs (`href`, `src`, `cite`, `formaction` etc.). Allowed: http(s), mailto,
// cid, data:image/*; everything else (notably javascript: and vbscript:) is
// blocked. Empty / relative URLs (no scheme) are always
// allowed because they resolve relatively at render time and pose no
// injection vector.
var allowedURLSchemes = map[string]bool{
"http": true,
"https": true,
"mailto": true,
"cid": true,
}
// blockedURLSchemes is the explicit deny-list. data:image/* is special-cased
// in classifyURLValue.
var blockedURLSchemes = map[string]bool{
"javascript": true,
"vbscript": true,
"file": true,
}
// classifyURLValue returns ("ok", "") if the URL value is acceptable, or
// ("error", ruleID) when it must be removed (javascript:/vbscript:/file:),
// or ("warn", ruleID) when the scheme is unrecognised but not actively
// dangerous. Empty values pass through (browsers ignore them).
func classifyURLValue(raw string) (kind, ruleID string) {
value := strings.TrimSpace(raw)
if value == "" {
return "ok", ""
}
// Strip leading whitespace + control bytes that could obscure the
// scheme (e.g. "java\tscript:..."). The html-parser already strips
// stray whitespace at attribute boundaries; this is defence-in-depth
// for older clients that paste from Word with U+0009 / U+0020 inside
// the scheme prefix.
value = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7F {
return -1
}
return r
}, value)
// Find the colon delimiter; everything before it is the scheme.
colon := strings.IndexByte(value, ':')
if colon < 0 {
// No scheme → relative URL → allow.
return "ok", ""
}
scheme := strings.ToLower(value[:colon])
rest := value[colon+1:]
switch {
case allowedURLSchemes[scheme]:
return "ok", ""
case scheme == "data":
// data:image/* is whitelisted; anything else (e.g. data:text/html;...)
// is rejected. The check tolerates any subtype under image/* (png /
// jpeg / gif / svg+xml / webp) so users embedding base64 thumbnails
// don't trip the rule.
rest = strings.TrimSpace(rest)
if strings.HasPrefix(strings.ToLower(rest), "image/") {
return "ok", ""
}
return "error", RuleAttrJSURLBlocked
case blockedURLSchemes[scheme]:
return "error", RuleAttrJSURLBlocked
default:
// Unknown scheme: surface a warning so users see it but don't
// drop legitimate webcal:/tel: / similar in case downstream
// renders eventually support them.
return "warn", RuleAttrUnsafeSchemeBlocked
}
}
// urlAttributes lists attributes whose value is a URL and must therefore
// pass classifyURLValue. Lower-case canonical names.
var urlAttributes = map[string]bool{
"href": true,
"src": true,
"cite": true,
"formaction": true,
"action": true,
"background": true,
"poster": true,
}
// allowedStyleProps enumerates CSS property names that pass through the
// inline `style="..."` attribute. Everything else is removed from the
// property list and surfaced via STYLE_PROPERTY_DROPPED.
//
// `border-*` / `padding-*` / `margin-*` are treated as prefix matches by
// classifyStyleProperty so the four directional variants (border-top etc.)
// are all admitted without enumerating each.
var allowedStyleProps = map[string]bool{
"color": true,
"background-color": true,
"font-size": true,
"font-weight": true,
"font-style": true,
"text-align": true,
"text-decoration": true,
"line-height": true,
"padding": true,
"margin": true,
"border": true,
"width": true,
"height": true,
"display": true,
"text-indent": true,
// Quote-block / native Feishu styles (tag classification "通过").
// Whitespace + word-break are part of the existing `<pre>` / quote
// wrapper styles in mail_quote.go (e.g. `bodyDivStyle`).
"white-space": true,
"word-break": true,
"word-wrap": true,
"overflow": true,
"overflow-wrap": true,
"vertical-align": true,
"list-style": true,
"list-style-type": true,
"list-style-position": true,
"transition": true,
"font-family": true,
"text-transform": true,
"hyphens": true,
"max-width": true,
"min-width": true,
"max-height": true,
"min-height": true,
"border-radius": true,
"box-sizing": true,
"opacity": true,
"cursor": true,
}
// stylePropAllowedPrefixes enumerates property name prefixes treated as
// allowed regardless of suffix (e.g. "border-*"). A trailing "-" makes the
// prefix self-documenting.
var stylePropAllowedPrefixes = []string{
"border-",
"padding-",
"margin-",
}
// classifyStyleProperty reports whether the given lower-case property name
// is in the allow-list (incl. prefix matches).
func classifyStyleProperty(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return false
}
if allowedStyleProps[name] {
return true
}
for _, p := range stylePropAllowedPrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// isEventHandlerAttr reports whether the attribute name is a DOM event
// handler (`on*`). The lib removes every such attribute regardless of its
// value (tag classification row "错误(删除)" + the well-known XSS vector).
func isEventHandlerAttr(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
if !strings.HasPrefix(name, "on") {
return false
}
if len(name) <= 2 {
return false
}
// Defence-in-depth: avoid matching legitimate attrs whose name happens
// to begin with "on" (e.g. `onerror`-like attrs all start "on" + ascii
// letter). The `>= 'a'` check filters out "on-something" with hyphens.
c := name[2]
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package lint implements the mail-domain HTML lint lib used by `+lint-html`
// and the writing-path internals of the compose 5 shortcuts (`+send`,
// `+draft-create`, `+reply`, `+reply-all`, `+forward`) and `+draft-edit` body
// ops. The lib classifies HTML tags / attributes / inline styles into three
// tiers (pass / warn-and-autofix / error-delete) following the three-tier tag
// classification. `<style>` is passed through verbatim; `<script>` / `<iframe>`
// / external `<link>` / on*-handlers / `javascript:` URLs are removed outright.
//
// The lib is deliberately decoupled from the cobra runtime so that it can be
// re-used as a pure-CPU pass before `bld.HTMLBody(...)` (compose 5) /
// `draftpkg.Apply(...)` (draft-edit) without taking a runtime dependency.
package lint
// Severity denotes the severity of a lint finding.
type Severity string
const (
// SeverityWarning is emitted for tags / attrs / styles that have a
// safe Feishu-native replacement (e.g. <font> -> <span style>). The
// lib always applies the replacement and surfaces the finding in
// `Applied` — unsafe tags are removed at lint time and the rewrite is
// not opt-out.
SeverityWarning Severity = "warning"
// SeverityError is emitted for tags / attrs / styles that would cause
// obvious rendering / safety issues (<script>, <iframe>, on*-handlers,
// javascript:/vbscript: URLs, ...) and may be stripped or cause
// obvious rendering issues downstream. The lib always removes these to
// match the writing-path safety contract.
SeverityError Severity = "error"
)
// Finding describes a single lint observation. The stdout-envelope shape is:
// rule_id / severity / tag_or_attr / excerpt / hint, all UTF-8 strings.
type Finding struct {
RuleID string `json:"rule_id"`
Severity Severity `json:"severity"`
TagOrAttr string `json:"tag_or_attr"`
Excerpt string `json:"excerpt"`
Hint string `json:"hint"`
}
// Options control a single Run invocation. The lib always autofixes warnings
// and removes errors — there is no opt-out (`--no-lint` is not provided). The
// struct is retained for forward compatibility but currently exposes no
// behavioural switches.
type Options struct{}
// Report is the structured output of a single Run invocation.
//
// Both Applied and Blocked are always non-nil slices (possibly empty). The
// stdout envelope contract requires `lint_applied` and `original_blocked` to
// always be present arrays — the JSON encoder must render `[]` rather than
// `null` so AI / test consumers can rely on `data.lint_applied[]` /
// `data.original_blocked[]` unconditionally.
type Report struct {
// Applied surfaces warning-tier findings that the lib rewrote in place
// (e.g. <font> -> <span style>). Each entry corresponds to a single rule
// firing on a single tag / attribute / style property.
Applied []Finding `json:"lint_applied"`
// Blocked surfaces error-tier findings that the lib removed
// unconditionally (writing-path safety floor: <script> / on* /
// javascript: URLs always go).
Blocked []Finding `json:"original_blocked"`
// CleanedHTML is the rewritten HTML produced by Run (warnings rewritten
// + errors deleted). When the input is plain text (bodyIsHTML == false)
// the field equals the input verbatim.
CleanedHTML string `json:"cleaned_html,omitempty"`
// HasErrorFindings reports whether any SeverityError finding was emitted.
HasErrorFindings bool `json:"-"`
// HasWarningFindings reports whether any SeverityWarning finding was emitted.
HasWarningFindings bool `json:"-"`
}
// EmptyReport returns a Report with the contract-required empty (non-nil)
// arrays and CleanedHTML equal to the input. Compose 5 / +draft-edit call
// this when the body is plain-text or empty so the stdout envelope's
// `lint_applied` / `original_blocked` fields are always present arrays.
func EmptyReport(html string) Report {
return Report{
Applied: []Finding{},
Blocked: []Finding{},
CleanedHTML: html,
}
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
"github.com/larksuite/cli/shortcuts/mail/lint"
)
// draftCreateInput bundles all +draft-create user flags into a single
@@ -44,7 +45,8 @@ var MailDraftCreate = common.Shortcut{
Flags: []common.Flag{
{Name: "to", Desc: "Optional. Full To recipient list. Separate multiple addresses with commas. Display-name format is supported. When omitted, the draft is created without recipients (they can be added later via +draft-edit)."},
{Name: "subject", Desc: "Final draft subject. Pass the full subject you want to appear in the draft. Required unless --template-id supplies a non-empty subject."},
{Name: "body", Desc: "Full email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
{Name: "body", Desc: "Full email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
bodyFileFlag,
{Name: "from", Desc: "Optional. Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. If omitted, the mailbox's primary address is used."},
{Name: "mailbox", Desc: "Optional. Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
{Name: "cc", Desc: "Optional. Full Cc recipient list. Separate multiple addresses with commas. Display-name format is supported."},
@@ -57,6 +59,7 @@ var MailDraftCreate = common.Shortcut{
signatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveComposeMailboxID(runtime)
@@ -82,19 +85,30 @@ var MailDraftCreate = common.Shortcut{
return err
}
hasTemplate := runtime.Str("template-id") != ""
bodyFlag := runtime.Str("body")
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
return err
}
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
}
if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
return output.ErrValidation("--body is required; pass the full email body (or use --template-id)")
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
// Resolve the body (reading --body-file if set) so the inline /
// HTML check sees the real body, not an empty placeholder.
body, bErr := resolveBodyFromFlags(runtime)
if bErr != nil {
return bErr
}
if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the full email body (or use --template-id)"); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), body); err != nil {
return err
}
return validatePriorityFlag(runtime)
@@ -105,10 +119,14 @@ var MailDraftCreate = common.Shortcut{
return err
}
mailboxID := resolveComposeMailboxID(runtime)
body, bErr := resolveBodyFromFlags(runtime)
if bErr != nil {
return bErr
}
input := draftCreateInput{
To: runtime.Str("to"),
Subject: runtime.Str("subject"),
Body: runtime.Str("body"),
Body: body,
From: runtime.Str("from"),
CC: runtime.Str("cc"),
BCC: runtime.Str("bcc"),
@@ -167,7 +185,7 @@ var MailDraftCreate = common.Shortcut{
if err != nil {
return err
}
rawEML, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
rawEML, lintApplied, lintBlocked, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments)
if err != nil {
return err
@@ -180,6 +198,14 @@ var MailDraftCreate = common.Shortcut{
if draftResult.Reference != "" {
out["reference"] = draftResult.Reference
}
// Writing-path lint envelope: default has no lint fields; full Finding
// arrays (`lint_applied[]` / `original_blocked[]`) only when the
// caller asked for them via --show-lint-details.
applyLintToEnvelope(out, lintApplied, lintBlocked, runtime.Bool("show-lint-details"))
addComposeHint(out)
// `draft_edit_hint` is attached ONLY here (+draft-create); the other 5
// compose shortcuts do not — see addDraftEditHint for the rationale.
addDraftEditHint(out)
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintln(w, "Draft created.")
// Intentionally keep +draft-create output minimal: unlike reply/forward/send
@@ -202,6 +228,10 @@ var MailDraftCreate = common.Shortcut{
// senderEmail returns an error early. The returned string is ready to POST
// to the drafts endpoint. ctx is plumbed through for large-attachment
// processing.
//
// Returns the rawEML, the writing-path lint findings (lint_applied /
// original_blocked — never nil; the arrays must always be present), and
// any error encountered.
func buildRawEMLForDraftCreate(
ctx context.Context,
runtime *common.RuntimeContext,
@@ -212,14 +242,19 @@ func buildRawEMLForDraftCreate(
mailboxID, templateID string,
templateInlineAttachments []templateInlineRef,
templateSmallAttachments []templateAttachmentRef,
) (string, error) {
) (rawEMLOut string, lintApplied, lintBlocked []lint.Finding, err error) {
// Initialise lint findings as empty (non-nil) slices so callers can
// surface them through the envelope unconditionally even on the
// plain-text branch.
lintApplied, lintBlocked = emptyLintFindings()
senderEmail := resolveComposeSenderEmail(runtime)
if senderEmail == "" {
return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly")
return "", lintApplied, lintBlocked, fmt.Errorf("unable to determine sender email; please specify --from explicitly")
}
if err := validateRecipientCount(input.To, input.CC, input.BCC); err != nil {
return "", err
return "", lintApplied, lintBlocked, err
}
bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
@@ -237,7 +272,7 @@ func buildRawEMLForDraftCreate(
// compose shortcuts; if it ever trips in this path, the above check
// regressed.
if err := requireSenderForRequestReceipt(runtime, senderEmail); err != nil {
return "", err
return "", lintApplied, lintBlocked, err
}
if runtime.Bool("request-receipt") {
bld = bld.DispositionNotificationTo("", senderEmail)
@@ -248,9 +283,9 @@ func buildRawEMLForDraftCreate(
if input.BCC != "" {
bld = bld.BCCAddrs(parseNetAddrs(input.BCC))
}
inlineSpecs, err := parseInlineSpecs(input.Inline)
if err != nil {
return "", output.ErrValidation("%v", err)
inlineSpecs, parseErr := parseInlineSpecs(input.Inline)
if parseErr != nil {
return "", lintApplied, lintBlocked, output.ErrValidation("%v", parseErr)
}
var autoResolvedPaths []string
var composedHTMLBody string
@@ -265,9 +300,17 @@ func buildRawEMLForDraftCreate(
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
return "", resolveErr
return "", lintApplied, lintBlocked, resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
// Writing-path lint: AutoFix=true / Strict=false — the writing-path
// safety contract has no `--no-lint` opt-out. Runs AFTER
// applyTemplate (in caller) + ResolveLocalImagePaths +
// injectSignatureIntoBody so the lint sees the final HTML the
// recipient renderer will see.
cleaned, rep := runWritePathLint(resolved)
resolved = cleaned
lintApplied, lintBlocked = rep.Applied, rep.Blocked
composedHTMLBody = resolved
bld = bld.HTMLBody([]byte(composedHTMLBody))
bld = addSignatureImagesToBuilder(bld, sigResult)
@@ -283,13 +326,14 @@ func buildRawEMLForDraftCreate(
}
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
var tplInlineCIDs []string
bld, tplInlineCIDs, err = embedTemplateInlineAttachments(ctx, runtime, bld, resolved, mailboxID, templateID, templateInlineAttachments)
if err != nil {
return "", err
var embedErr error
bld, tplInlineCIDs, embedErr = embedTemplateInlineAttachments(ctx, runtime, bld, resolved, mailboxID, templateID, templateInlineAttachments)
if embedErr != nil {
return "", lintApplied, lintBlocked, embedErr
}
allCIDs = append(allCIDs, tplInlineCIDs...)
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return "", err
if cidErr := validateInlineCIDs(resolved, allCIDs, nil); cidErr != nil {
return "", lintApplied, lintBlocked, cidErr
}
} else {
composedTextBody = input.Body
@@ -299,9 +343,10 @@ func buildRawEMLForDraftCreate(
// when the template contributes none; runs in both HTML and plain-text
// branches because regular attachments are independent of body mode.
var templateSmallBytes int64
bld, templateSmallBytes, err = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments)
if err != nil {
return "", err
var smallErr error
bld, templateSmallBytes, smallErr = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments)
if smallErr != nil {
return "", lintApplied, lintBlocked, smallErr
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, input.To, input.CC); calData != nil {
@@ -310,16 +355,17 @@ func buildRawEMLForDraftCreate(
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes
bld, err = processLargeAttachments(ctx, runtime, bld, composedHTMLBody, composedTextBody, splitByComma(input.Attach), emlBase, 0)
if err != nil {
return "", err
var largeErr error
bld, largeErr = processLargeAttachments(ctx, runtime, bld, composedHTMLBody, composedTextBody, splitByComma(input.Attach), emlBase, 0)
if largeErr != nil {
return "", lintApplied, lintBlocked, largeErr
}
if hdr, hdrErr := encodeTemplateLargeAttachmentHeader(templateLargeAttachmentIDs); hdrErr == nil && hdr != "" {
bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, hdr)
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return "", output.ErrValidation("build EML failed: %v", err)
rawEML, buildErr := bld.BuildBase64URL()
if buildErr != nil {
return "", lintApplied, lintBlocked, output.ErrValidation("build EML failed: %v", buildErr)
}
return rawEML, nil
return rawEML, lintApplied, lintBlocked, nil
}

View File

@@ -62,7 +62,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
}
rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -88,7 +88,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
Body: `<p>Hello <b>world</b></p>`,
}
rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -124,7 +124,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
Attach: "./big.txt",
}
_, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
if err == nil {
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
}
@@ -145,7 +145,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
}
_, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
if err == nil {
t.Fatal("expected error for orphaned --inline CID not referenced in body")
}
@@ -166,7 +166,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
}
_, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
if err == nil {
t.Fatal("expected error for missing CID reference")
}
@@ -183,7 +183,7 @@ func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) {
Body: `<p>Hello</p>`,
}
rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -201,7 +201,7 @@ func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) {
Body: `<p>Hello</p>`,
}
rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -236,7 +236,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptAddsHeader(t *testing.T) {
Body: "<p>hi</p>",
}
rawEML, err := buildRawEMLForDraftCreate(context.Background(),
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
@@ -259,7 +259,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptOmittedByDefault(t *testing.T)
Body: "<p>hi</p>",
}
rawEML, err := buildRawEMLForDraftCreate(context.Background(),
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "", nil, "", "", nil, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
@@ -283,7 +283,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
PlainText: true,
}
rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -304,7 +304,7 @@ func TestBuildRawEMLForDraftCreate_WithCalendarEvent(t *testing.T) {
Body: "<p>Please join us</p>",
}
rawEML, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}

View File

@@ -35,7 +35,9 @@ var MailDraftEdit = common.Shortcut{
{Name: "set-to", Desc: "Replace the entire To recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
{Name: "set-cc", Desc: "Replace the entire Cc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
{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: "body", Desc: "Full email body for a complete replacement (set_body). Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --patch-file with set_reply_body when you need to preserve an existing reply/forward quote block; use --body when you want a full body replacement. Mutually exclusive with --body-file. Cannot be combined with --patch-file body ops."},
bodyFileFlag,
{Name: "patch-file", Desc: "Advanced edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. Use --body/--body-file for quick full-body replacement; use --patch-file with set_body/set_reply_body when you need typed body ops, especially set_reply_body to preserve an existing reply/forward 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: "set-event-summary", Desc: "Set calendar event title. Must be used together with --set-event-start and --set-event-end."},
@@ -45,6 +47,7 @@ var MailDraftEdit = common.Shortcut{
{Name: "remove-event", Type: "bool", Desc: "Remove the calendar event from the draft."},
{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."},
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the draft's sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed. Adds the Disposition-Notification-To header; existing value is overwritten."},
showLintDetailsFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if runtime.Bool("print-patch-template") {
@@ -68,7 +71,7 @@ var MailDraftEdit = common.Shortcut{
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Edit an existing draft without sending it: first call drafts.get(format=raw) to fetch the current EML, parse it into MIME structure, apply either direct flags or the typed patch from patch-file, re-serialize the updated draft, and then call drafts.update. This is a minimal-edit pipeline rather than a full rebuild, so unchanged headers, attachments, and MIME subtrees are preserved where possible. Body edits must go through --patch-file using set_body or set_reply_body ops. It also has no optimistic locking, so concurrent edits to the same draft are last-write-wins.").
Desc("Edit an existing draft without sending it: first call drafts.get(format=raw) to fetch the current EML, parse it into MIME structure, apply either direct flags or the typed patch from patch-file, re-serialize the updated draft, and then call drafts.update. This is a minimal-edit pipeline rather than a full rebuild, so unchanged headers, attachments, and MIME subtrees are preserved where possible. Quick full-body replacement can use --body/--body-file; advanced body edits can use --patch-file with set_body or set_reply_body ops. It also has no optimistic locking, so concurrent edits to the same draft are last-write-wins.").
GET(mailboxPath(mailboxID, "drafts", draftID)).
Params(map[string]interface{}{"format": "raw"}).
PUT(mailboxPath(mailboxID, "drafts", draftID)).
@@ -174,6 +177,32 @@ var MailDraftEdit = common.Shortcut{
if err != nil {
return err
}
// Writing-path lint for body ops only: set_body / set_reply_body
// rewrite the body field; other ops (set_subject / set_recipients /
// add_attachment / etc.) operate on non-HTML fields and MUST NOT be
// linted. Lint runs after loadPatchFile parses JSON and BEFORE
// draftpkg.Apply writes into the snapshot. Each op's `value` is
// replaced with the cleaned HTML in place; findings accumulate across
// ops into a single per-patch report.
lintApplied, lintBlocked := emptyLintEnvelopeFields()
for i := range patch.Ops {
op := &patch.Ops[i]
if op.Op != "set_body" && op.Op != "set_reply_body" {
continue
}
if op.Value == "" {
continue
}
if !bodyIsHTML(op.Value) {
// Plain-text body op — no lint pass needed (the HTML rule set
// is irrelevant), but the envelope still surfaces empty arrays.
continue
}
cleaned, rep := runWritePathLint(op.Value)
op.Value = cleaned
lintApplied = append(lintApplied, rep.Applied...)
lintBlocked = append(lintBlocked, rep.Blocked...)
}
dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
if len(patch.Ops) > 0 {
if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
@@ -197,6 +226,10 @@ var MailDraftEdit = common.Shortcut{
if updateResult.Reference != "" {
out["reference"] = updateResult.Reference
}
// Writing-path lint envelope: counts always present; full Finding
// arrays only when the caller asked for them via --show-lint-details.
applyLintToEnvelope(out, lintApplied, lintBlocked, runtime.Bool("show-lint-details"))
addComposeHint(out)
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintln(w, "Draft updated.")
fmt.Fprintf(w, "draft_id: %s\n", updateResult.DraftID)
@@ -370,6 +403,31 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
setRecipients("cc", runtime.Str("set-cc"))
setRecipients("bcc", runtime.Str("set-bcc"))
// --body / --body-file are convenience shorthands for a set_body patch
// op. They cannot be combined with --patch-file body ops
// (set_body / set_reply_body) to avoid ambiguous ordering.
bodyFlag := runtime.Str("body")
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
return patch, err
}
bodyVal := bodyFlag
if bodyVal == "" && bodyFile != "" {
loaded, err := readBodyFile(runtime.FileIO(), bodyFile)
if err != nil {
return patch, err
}
bodyVal = loaded
}
if bodyVal != "" {
for _, op := range patch.Ops {
if op.Op == "set_body" || op.Op == "set_reply_body" {
return patch, output.ErrValidation("--body / --body-file and --patch-file body ops (set_body/set_reply_body) are mutually exclusive; use one or the other")
}
}
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_body", Value: bodyVal})
}
// --set-priority → inject set_header / remove_header op
if setPriority := runtime.Str("set-priority"); setPriority != "" {
headerVal, pErr := parsePriority(setPriority)
@@ -531,7 +589,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
},
"recommended_usage": []string{
"Use direct flags (--set-subject, --set-to, --set-cc, --set-bcc) for simple metadata edits",
"Use --patch-file for ALL body edits and advanced changes (recipients, headers, attachments, inline images)",
"Use --body/--body-file for quick full-body replacement; use --patch-file for advanced body edits and advanced changes (recipients, headers, attachments, inline images)",
"Before editing body, run --inspect to check has_quoted_content; if true, use set_reply_body instead of set_body",
},
"body_edit_decision_guide": []map[string]interface{}{
@@ -544,7 +602,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
"`add_inline` is an advanced op for precise CID control only — in most cases, use <img src=\"./path\"> in `set_body`/`set_reply_body` instead",
"`ops` is executed in order",
"all file paths (--patch-file and `path` fields in ops) must be relative — no absolute paths or .. traversal",
"all body edits MUST go through --patch-file; there is no --set-body flag",
"use --body <html> for a quick full-body replacement (equivalent to a set_body op); use --patch-file with set_body/set_reply_body for advanced body edits; --body and --patch-file body ops are mutually exclusive",
"`set_body` replaces the user-authored content. It does NOT auto-preserve the old quote block (include one in value if needed, or use `set_reply_body`). Signature, large attachment card, and normal attachment MIME parts are auto-preserved. When the draft has both text/plain and text/html, it updates the HTML body and regenerates the plain-text summary, so the input should be HTML.",
"`set_reply_body` replaces only the user-authored portion of the body and automatically re-appends the trailing reply/forward quote block, signature, and large attachment card; the value you pass should contain ONLY the new user-authored content (no quote, no signature, no attachment card). If the user wants to modify content INSIDE the quote block, use `set_body` instead. If the draft has no quote block, it behaves identically to `set_body`.",
"`body_kind` only supports text/plain and text/html",

View File

@@ -26,10 +26,12 @@ var MailForward = common.Shortcut{
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-id", Desc: "Required. Message ID to forward", Required: true},
{Name: "to", Desc: "Recipient email address(es), comma-separated"},
{Name: "body", Desc: "Body prepended before the forwarded message. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the forward body and the original message. Use --plain-text to force plain-text mode."},
{Name: "body", Desc: "Body prepended before the forwarded message. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the forward body and the original message. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file."},
bodyFileFlag,
{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
{Name: "cc", Desc: "CC email address(es), comma-separated"},
@@ -44,7 +46,8 @@ var MailForward = common.Shortcut{
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are merged into the forward draft (template values appended to user flags / forward-derived values; no de-duplication)."},
signatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
to := runtime.Str("to")
@@ -72,6 +75,11 @@ var MailForward = common.Shortcut{
if err := validateTemplateID(runtime.Str("template-id")); err != nil {
return err
}
bodyFlag := runtime.Str("body")
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
return err
}
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
@@ -102,7 +110,10 @@ var MailForward = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
messageId := runtime.Str("message-id")
to := runtime.Str("to")
body := runtime.Str("body")
body, bErr := resolveBodyFromFlags(runtime)
if bErr != nil {
return bErr
}
ccFlag := runtime.Str("cc")
bccFlag := runtime.Str("bcc")
plainText := runtime.Bool("plain-text")
@@ -242,6 +253,8 @@ var MailForward = common.Shortcut{
var composedHTMLBody string
var composedTextBody string
var srcInlineBytes int64
// Lint findings flowing into the writing-path stdout envelope.
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return fmt.Errorf("forward blocked: %w", err)
@@ -267,6 +280,13 @@ var MailForward = common.Shortcut{
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
// Writing-path lint: lint user-authored body + signature, NOT the
// forward quote / large-attachment card derived from the original
// message (re-linting quote blocks risks dropping allow-listed
// Feishu-native quote markup).
cleaned, rep := runWritePathLint(bodyWithSig)
bodyWithSig = cleaned
lintApplied, lintBlocked = rep.Applied, rep.Blocked
composedHTMLBody = bodyWithSig + origLargeAttCard + forwardQuote
bld = bld.HTMLBody([]byte(composedHTMLBody))
bld = addSignatureImagesToBuilder(bld, sigResult)
@@ -479,8 +499,12 @@ var MailForward = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
out := buildDraftSavedOutput(draftResult, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
return nil
}
@@ -488,7 +512,10 @@ var MailForward = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftResult.DraftID, err)
}
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -0,0 +1,170 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/mail/lint"
)
// MailLintHTML is the `+lint-html` shortcut: lint a mail HTML body for
// compatibility / safety / Larksuite-native rules. Read-only — no draft is
// touched, no API call is made. This is a stand-alone preview counterpart to
// the writing-path lint built into compose 5 / +draft-edit; both share a
// single lint lib (shortcuts/mail/lint) so behaviour can't drift.
//
// Returns by default (token-frugal envelope):
//
// {ok: true, data: {cleaned_html: "..."}}
//
// With --show-lint-details, the envelope additionally surfaces the full
// `warnings[]` / `errors[]` Finding arrays. Each entry has: rule_id /
// severity / tag_or_attr / excerpt / hint.
var MailLintHTML = common.Shortcut{
Service: "mail",
Command: "+lint-html",
Description: "Lint mail HTML body for compatibility / safety / Larksuite-native rules. Returns warnings/errors and (always) auto-fixed cleaned_html. Read-only: no draft, no API call. Use this BEFORE creating a draft to preview what the writing-path lint would change.",
Risk: "read",
// No API call → no scope requirement.
Scopes: []string{},
// Identity-agnostic: lint is local pure-CPU. Both user and bot
// identities can run it.
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
// --body / --body-file are MUTUALLY EXCLUSIVE BUT EXACTLY-ONE-OF.
// We do NOT use cobra `Required: true` on either (it fires before
// Validate runs and blocks the legitimate "the other one is set"
// path); we enforce the constraint inside the Validate callback below.
{Name: "body", Desc: "HTML body to lint. Mutually exclusive with --body-file; exactly one is required."},
{Name: "body-file", Desc: "Path (relative, within cwd subtree) to a file containing HTML to lint. Mutually exclusive with --body; exactly one is required.", Input: []string{common.File}},
showLintDetailsFlag,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := runtime.Str("body")
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
// Mutual exclusion + exactly-one-of validation for --body / --body-file.
bodyEmpty := strings.TrimSpace(body) == ""
if bodyEmpty && bodyFile == "" {
return output.ErrValidation("exactly one of --body or --body-file is required")
}
if !bodyEmpty && bodyFile != "" {
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
}
// --body-file safety: cwd-subtree only. Mirrors the existing pattern
// in mail_template_create.go:resolveTemplateContent + shortcut
// runtime.ValidatePath.
if bodyFile != "" {
if err := runtime.ValidatePath(bodyFile); err != nil {
return output.ErrValidation("--body-file: %v", err)
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Pure local — no network IO. Surface this explicitly so the
// dry-run envelope makes clear that running the command for real
// has zero side effects.
api := common.NewDryRunAPI().
Desc("Lint HTML body locally (no API call, no draft mutation, no network IO).").
Set("mode", "local-lint-only")
if path := strings.TrimSpace(runtime.Str("body-file")); path != "" {
api = api.Set("body_source", "file").Set("body_file", path)
} else {
api = api.Set("body_source", "flag")
}
return api
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := readLintHTMLBody(runtime)
if err != nil {
return err
}
// Plain-text input: short-circuit to an empty report (lib short-circuit
// path, also useful so users running --body 'plain text' don't get
// confused by an empty-but-rewritten output).
var rep lint.Report
if !bodyIsHTML(body) {
rep = lint.EmptyReport(body)
} else {
rep = lint.Run(body, lint.Options{})
}
// Public envelope shape: token-frugal by default. `cleaned_html` is
// the primary product; the full `warnings[]` / `errors[]` Finding
// arrays are only attached when the caller passes
// `--show-lint-details`. A complex template can produce 30-80
// warnings whose full payload would dominate the response by
// thousands of tokens — AI consumers (the dominant audience for
// `+lint-html` as a draft pre-flight check) overwhelmingly only
// need cleaned_html.
showDetails := runtime.Bool("show-lint-details")
data := map[string]interface{}{
"cleaned_html": rep.CleanedHTML,
}
if showDetails {
data["warnings"] = rep.Applied // never nil — lib guarantees []
data["errors"] = rep.Blocked // never nil — lib guarantees []
}
runtime.OutFormat(data, &output.Meta{Count: len(rep.Applied) + len(rep.Blocked)}, func(w io.Writer) {
printLintPretty(w, rep)
})
// The lib already removed errors and rewrote warnings in place;
// `+lint-html` is a preview / advisory tool and never bumps the
// exit code. CI scripts that want to gate on findings should
// post-process the envelope (e.g. with `--show-lint-details` and
// jq on `errors[]` / `warnings[]`).
return nil
},
}
// readLintHTMLBody resolves the input HTML body from --body or --body-file.
// Validate has already enforced that exactly one is set, so we don't repeat
// the mutual-exclusion check here.
func readLintHTMLBody(runtime *common.RuntimeContext) (string, error) {
if body := runtime.Str("body"); strings.TrimSpace(body) != "" {
return body, nil
}
path := strings.TrimSpace(runtime.Str("body-file"))
if path == "" {
// Should be unreachable given Validate, but defensive.
return "", output.ErrValidation("internal: --body-file empty after Validate")
}
return readBodyFile(runtime.FileIO(), path)
}
// printLintPretty renders the lint report as a human-readable summary used
// when --format pretty is selected. Stays terse so CI logs aren't drowned.
func printLintPretty(w io.Writer, rep lint.Report) {
if len(rep.Blocked) == 0 && len(rep.Applied) == 0 {
fmt.Fprintln(w, "OK: no compatibility / safety findings.")
fmt.Fprintf(w, "cleaned_html_size: %d bytes\n", len(rep.CleanedHTML))
return
}
if len(rep.Blocked) > 0 {
fmt.Fprintf(w, "errors (%d):\n", len(rep.Blocked))
for _, f := range rep.Blocked {
fmt.Fprintf(w, " - [%s] %s — %s\n", f.RuleID, f.TagOrAttr, f.Hint)
}
}
if len(rep.Applied) > 0 {
fmt.Fprintf(w, "warnings (%d):\n", len(rep.Applied))
for _, f := range rep.Applied {
fmt.Fprintf(w, " - [%s] %s — %s\n", f.RuleID, f.TagOrAttr, f.Hint)
}
}
fmt.Fprintf(w, "cleaned_html_size: %d bytes\n", len(rep.CleanedHTML))
}

View File

@@ -0,0 +1,274 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/json"
"os"
"strings"
"testing"
)
// =====================================================================
// +lint-html Shortcut tests — public stdout envelope contract checks.
//
// These exercise the full cobra Mount → Execute pipeline (parse args →
// Validate → Execute → OutFormat) so they catch any regression in flag
// declaration, mutual-exclusion validation, path safety, and the JSON
// envelope shape.
// =====================================================================
// TestMailLintHTML_RequiresExactlyOneOfBodyOrFile verifies the mutual-
// exclusion + at-least-one-of constraint surfaces ErrValidation.
func TestMailLintHTML_RequiresExactlyOneOfBodyOrFile(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
t.Run("neither flag", func(t *testing.T) {
err := runMountedMailShortcut(t, MailLintHTML, []string{"+lint-html"}, f, stdout)
if err == nil {
t.Fatal("expected error when neither flag is set")
}
if !strings.Contains(err.Error(), "exactly one of --body or --body-file") {
t.Errorf("wrong error: %v", err)
}
})
t.Run("both flags", func(t *testing.T) {
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", "<p>x</p>",
"--body-file", "fake.html",
}, f, stdout)
if err == nil {
t.Fatal("expected error when both flags set")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("wrong error: %v", err)
}
})
}
// TestMailLintHTML_BodyFilePathSafetyRejected verifies absolute paths /
// `..` traversal are rejected by the path safety check.
func TestMailLintHTML_BodyFilePathSafetyRejected(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
chdirTemp(t)
t.Run("absolute path", func(t *testing.T) {
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body-file", "/etc/passwd",
}, f, stdout)
if err == nil {
t.Fatal("expected validation error for absolute path")
}
})
t.Run("dotdot traversal", func(t *testing.T) {
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body-file", "../../../etc/passwd",
}, f, stdout)
if err == nil {
t.Fatal("expected validation error for traversal")
}
})
}
// TestMailLintHTML_BodyFileReadsCwdSubpath verifies a legitimate cwd-subtree
// path loads HTML correctly.
func TestMailLintHTML_BodyFileReadsCwdSubpath(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
chdirTemp(t)
if err := os.WriteFile("input.html", []byte(`<p>safe</p><script>1</script>`), 0o644); err != nil {
t.Fatal(err)
}
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body-file", "input.html",
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("expected success, got: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
errors, _ := data["errors"].([]interface{})
if len(errors) != 1 {
t.Errorf("expected 1 error finding (script), got %d: %+v", len(errors), errors)
}
cleaned, _ := data["cleaned_html"].(string)
if strings.Contains(cleaned, "<script") {
t.Errorf("cleaned_html should not contain <script>, got %q", cleaned)
}
}
// TestMailLintHTML_DefaultEnvelopeShape verifies the default envelope only
// contains cleaned_html — warnings[] / errors[] are token-frugally suppressed
// unless --show-lint-details is passed.
func TestMailLintHTML_DefaultEnvelopeShape(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", `<p>safe content</p>`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if _, ok := data["cleaned_html"]; !ok {
t.Error("cleaned_html key missing from envelope (default --auto-fix=true)")
}
if _, ok := data["warnings"]; ok {
t.Error("warnings[] must be hidden in default mode (use --show-lint-details to surface)")
}
if _, ok := data["errors"]; ok {
t.Error("errors[] must be hidden in default mode (use --show-lint-details to surface)")
}
}
// TestMailLintHTML_ShowLintDetailsExposesArrays verifies --show-lint-details
// surfaces the full warnings[] / errors[] arrays alongside cleaned_html.
func TestMailLintHTML_ShowLintDetailsExposesArrays(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", `<p>safe content</p>`,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if _, ok := data["warnings"]; !ok {
t.Error("warnings[] missing in --show-lint-details mode")
}
if _, ok := data["errors"]; !ok {
t.Error("errors[] missing in --show-lint-details mode")
}
}
// TestMailLintHTML_PlainTextBodyShortCircuits verifies plain-text input
// produces empty arrays (lib short-circuit path) when --show-lint-details is
// set; without the flag, the arrays are omitted entirely.
func TestMailLintHTML_PlainTextBodyShortCircuits(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", "just plain text, no markup",
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
w, _ := data["warnings"].([]interface{})
e, _ := data["errors"].([]interface{})
if len(w) != 0 || len(e) != 0 {
t.Errorf("plain text should produce no findings, got w=%v e=%v", w, e)
}
}
// TestMailLintHTML_FindingShape verifies each finding entry has the
// contract-required keys (rule_id / severity / tag_or_attr / excerpt / hint).
func TestMailLintHTML_FindingShape(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", `<p>x</p><script>alert(1)</script>`,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
errors, _ := data["errors"].([]interface{})
if len(errors) == 0 {
t.Fatal("expected at least 1 error finding")
}
first, _ := errors[0].(map[string]interface{})
for _, key := range []string{"rule_id", "severity", "tag_or_attr", "excerpt", "hint"} {
if _, ok := first[key]; !ok {
t.Errorf("finding missing required key %q: %+v", key, first)
}
}
if first["severity"] != "error" {
t.Errorf("severity = %v, want error", first["severity"])
}
if !strings.HasPrefix(first["rule_id"].(string), "TAG_") &&
!strings.HasPrefix(first["rule_id"].(string), "ATTR_") &&
!strings.HasPrefix(first["rule_id"].(string), "STYLE_") {
t.Errorf("rule_id must be UPPER_SNAKE_CASE prefix, got %v", first["rule_id"])
}
}
// TestMailLintHTML_DryRun verifies dry-run mode doesn't execute lint and
// surfaces the read-only / no-network annotation.
func TestMailLintHTML_DryRun(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", `<p>x</p>`,
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Dry-run output is JSON containing "mode":"local-lint-only".
if !strings.Contains(stdout.String(), "local-lint-only") {
t.Errorf("expected dry-run mode marker, stdout=%s", stdout.String())
}
}
// TestMailLintHTML_BlockedTagAndWarningAccumulate verifies the report
// surfaces both warning + error findings simultaneously.
func TestMailLintHTML_BlockedTagAndWarningAccumulate(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
body := `<font color="red">warn-tag</font><script>err-tag</script>` +
`<a href="javascript:0">err-url</a>`
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", body,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
w, _ := data["warnings"].([]interface{})
e, _ := data["errors"].([]interface{})
if len(w) < 1 {
t.Errorf("expected ≥ 1 warning, got %d", len(w))
}
if len(e) < 2 {
t.Errorf("expected ≥ 2 errors (script + js URL), got %d", len(e))
}
}
// TestMailLintHTML_FindingsAreJSONSerialisable confirms the cleaned envelope
// can round-trip through json (no nil / function values leak in).
func TestMailLintHTML_FindingsAreJSONSerialisable(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", `<font color="red">x</font>`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Re-encode the data back to JSON to confirm it's serialisable.
data := decodeShortcutEnvelopeData(t, stdout)
if _, err := json.Marshal(data); err != nil {
t.Errorf("envelope not JSON-serialisable: %v", err)
}
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/mail/lint"
)
// showLintDetailsFlag is the optional --show-lint-details flag shared by every
// compose shortcut (+send / +draft-create / +reply / +reply-all / +forward /
// +draft-edit). By default the envelope carries no lint fields at all; passing
// this flag attaches the two lint contract Finding arrays together
// (`lint_applied[]` / `original_blocked[]`) so callers can inspect the
// individual findings for debugging. The two keys enter and leave the envelope
// as a single group (字段同进同退) — they are never present in a half state.
// Default-off keeps the envelope small for AI consumers; rich-list templates
// can trigger 20+ warnings whose full payload would balloon the response by
// thousands of tokens, and most callers do not need to know the lint pass ran.
// Callers who need a count can compute it locally via `len(lint_applied)` /
// `len(original_blocked)`.
var showLintDetailsFlag = common.Flag{
Name: "show-lint-details",
Type: "bool",
Desc: "Include lint metadata (lint_applied[] / original_blocked[]) in the envelope. Default: no lint fields are returned to keep the envelope small.",
}
// runWritePathLint is the single entrypoint compose 5 + +draft-edit body ops
// use to invoke the lint lib before writing to emlbuilder / draftpkg.Apply.
//
// The writing-path safety contract is:
// - The lib always autofixes warnings and removes errors; there is no
// opt-out.
// - The returned report is appended to the writing-path stdout envelope
// under the contract keys `lint_applied` (warnings) and
// `original_blocked` (errors); both arrays are always present (possibly
// empty) so consumers can rely on `data.lint_applied[]` and
// `data.original_blocked[]` unconditionally.
// - When the body is plain-text, the lib short-circuits and returns an
// EmptyReport; the cleaned HTML equals the input verbatim. Compose 5
// callers are expected to gate the call on their existing useHTML
// branch so the plain-text path doesn't pay the parse cost.
//
// Returns the cleaned HTML + the report. Callers MUST use the returned
// `cleaned` value as the body that goes to bld.HTMLBody / draftpkg.Apply
// (writing the original `body` would defeat the safety contract).
func runWritePathLint(body string) (cleaned string, rep lint.Report) {
if body == "" {
return "", lint.EmptyReport("")
}
rep = lint.Run(body, lint.Options{})
return rep.CleanedHTML, rep
}
// applyLintToEnvelope mutates the OutFormat data map by adding the
// writing-path lint contract keys.
//
// The two lint contract Finding arrays (`lint_applied[]` / `original_blocked[]`)
// enter and leave the envelope as a single group (字段同进同退) — they are
// never present in a half state.
//
// - When `showDetails` is false (default): the function adds zero keys to
// `data`. The envelope therefore carries no lint metadata at all,
// keeping it small for AI consumers who do not need to know the lint
// pass ran.
// - When `showDetails` is true (caller passed `--show-lint-details`): both
// arrays are added together. `lint_applied[]` and `original_blocked[]`
// are non-nil (possibly empty) so detail-mode consumers can rely on
// `data.lint_applied[]` / `data.original_blocked[]` unconditionally. The
// envelope no longer carries any `*_count` fields — callers needing a
// count compute it via `len(lint_applied)` / `len(original_blocked)`.
func applyLintToEnvelope(data map[string]interface{}, applied, blocked []lint.Finding, showDetails bool) {
if applied == nil {
applied = []lint.Finding{}
}
if blocked == nil {
blocked = []lint.Finding{}
}
if showDetails {
data["lint_applied"] = applied
data["original_blocked"] = blocked
}
}
// emptyLintEnvelopeFields returns the writing-path stdout-envelope fields
// representing "no lint pass occurred" (e.g. plain-text body branch). Used by
// compose 5's plain-text path so the public envelope still carries the
// contract keys as empty arrays.
func emptyLintEnvelopeFields() (lintApplied, originalBlocked []lint.Finding) {
return []lint.Finding{}, []lint.Finding{}
}
// emptyLintFindings returns two non-nil empty Finding slices, used by helpers
// that initialise their outputs before knowing whether the body is HTML.
// Equivalent to emptyLintEnvelopeFields but named to reflect "findings" rather
// than "envelope fields" so call-sites read consistently with their context.
func emptyLintFindings() (applied, blocked []lint.Finding) {
return []lint.Finding{}, []lint.Finding{}
}
// composeHTMLGuideHint is the recommended-reading message that compose
// shortcuts (+send / +draft-create / +reply / +reply-all / +forward /
// +draft-edit body op) attach to their stdout envelope under the key
// `compose_hint`. AI / users SHOULD read references/lark-mail-html.md
// before composing rich-HTML mail to follow the writing rules.
const composeHTMLGuideHint = "Please refer to skills/lark-mail/references/lark-mail-html.md for the recommended HTML writing guidelines before composing mail."
// addComposeHint inserts the compose-side reading hint into the envelope
// data map under the key `compose_hint`. Compose shortcuts call this once
// per top-level success branch so consumers always see the same hint key.
func addComposeHint(out map[string]interface{}) {
out["compose_hint"] = composeHTMLGuideHint
}
// draftEditHintConst is the recommended-workflow message that the
// +draft-create shortcut attaches to its stdout envelope under the key
// `draft_edit_hint`. AI / users SHOULD edit the existing draft via
// `+draft-edit --draft-id <id>` rather than re-running `+draft-create`,
// which would create a duplicate draft entry instead of updating the
// original one.
const draftEditHintConst = "To modify this draft later (body, subject, recipients, attachments), prefer 'lark-cli mail +draft-edit --draft-id <id>' over creating a new draft via '+draft-create'. Re-running '+draft-create' will produce a separate draft entry instead of updating the existing one."
// addDraftEditHint inserts the draft-edit recommendation into the envelope
// data map under the key `draft_edit_hint`. ONLY +draft-create calls this —
// the other 5 compose shortcuts (+send / +reply / +reply-all / +forward /
// +draft-edit) MUST NOT attach `draft_edit_hint`: it only applies to a newly
// created draft, not to a sent message or an edit of an existing draft.
func addDraftEditHint(out map[string]interface{}) {
out["draft_edit_hint"] = draftEditHintConst
}

View File

@@ -0,0 +1,719 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/mail/lint"
)
// jsonDecoderUnmarshal is a thin alias used by helpers in this file to keep
// the import set explicit even when the helper would otherwise be one-line.
func jsonDecoderUnmarshal(b []byte, v interface{}) error { return json.Unmarshal(b, v) }
// =====================================================================
// Writing-path lint integration tests — compose 5 + +draft-edit emit
// `lint_applied[]` and `original_blocked[]` arrays in the stdout envelope
// always.
// =====================================================================
// TestRunWritePathLint_PlainTextReturnsEmptyReport verifies the helper
// short-circuits on plain-text input.
func TestRunWritePathLint_PlainTextReturnsEmptyReport(t *testing.T) {
cleaned, rep := runWritePathLint("")
if cleaned != "" {
t.Errorf("cleaned = %q, want empty", cleaned)
}
if rep.Applied == nil || rep.Blocked == nil {
t.Error("Applied/Blocked must be non-nil")
}
if len(rep.Applied) != 0 || len(rep.Blocked) != 0 {
t.Errorf("expected empty report, got applied=%d blocked=%d",
len(rep.Applied), len(rep.Blocked))
}
}
// TestRunWritePathLint_HTMLAlwaysAutofixedWarningNeverElevated verifies the
// writing path always autofixes warnings and never elevates them — the
// writing-path safety contract has no opt-out. The input
// triggers two warning autofixes (<p> paragraph-rewrite + <font> tag
// rewrite); both must surface in `Applied` and never appear in `Blocked`.
func TestRunWritePathLint_HTMLAlwaysAutofixedWarningNeverElevated(t *testing.T) {
cleaned, rep := runWritePathLint(`<p><font color="red">x</font></p>`)
if !strings.Contains(cleaned, "<span") {
t.Errorf("expected autofix to rewrite <font>, cleaned=%q", cleaned)
}
if strings.Contains(cleaned, "<p>") || strings.Contains(cleaned, "<font") {
t.Errorf("expected <p>/<font> rewritten, cleaned=%q", cleaned)
}
if len(rep.Applied) < 1 {
t.Errorf("expected ≥1 warning surfaced (font + paragraph autofix), got %d", len(rep.Applied))
}
// Warnings never become errors on the writing-path; --strict no longer
// exists at the surface either, so the contract is "Applied gathers
// warnings, Blocked stays empty for warning-only inputs".
if len(rep.Blocked) != 0 {
t.Errorf("writing-path must NOT elevate warnings; expected 0 blocked, got %d", len(rep.Blocked))
}
}
// TestApplyLintToEnvelope_DefaultEmitsNoLintFields verifies the helper writes
// zero keys in the default (non-detail) mode — neither count fields nor the
// full Finding arrays appear; the envelope stays small.
func TestApplyLintToEnvelope_DefaultEmitsNoLintFields(t *testing.T) {
data := map[string]interface{}{"existing": "value"}
rep := lint.EmptyReport(`<p>x</p>`)
applyLintToEnvelope(data, rep.Applied, rep.Blocked, false)
if data["existing"] != "value" {
t.Error("existing key was clobbered")
}
if _, ok := data["lint_applied_count"]; ok {
t.Error("lint_applied_count must NOT be present in default mode")
}
if _, ok := data["original_blocked_count"]; ok {
t.Error("original_blocked_count must NOT be present in default mode")
}
if _, ok := data["lint_applied"]; ok {
t.Error("lint_applied[] must NOT be present in default mode")
}
if _, ok := data["original_blocked"]; ok {
t.Error("original_blocked[] must NOT be present in default mode")
}
}
// TestApplyLintToEnvelope_DetailModeIncludesArrays verifies the detail mode
// (showDetails=true) attaches the two non-nil Finding arrays only. The
// `*_count` fields are no longer emitted (callers can compute counts via
// `len(arr)` themselves).
func TestApplyLintToEnvelope_DetailModeIncludesArrays(t *testing.T) {
data := map[string]interface{}{}
rep := lint.EmptyReport(`<p>x</p>`)
applyLintToEnvelope(data, rep.Applied, rep.Blocked, true)
if _, ok := data["lint_applied_count"]; ok {
t.Error("lint_applied_count must NOT be present (count fields removed)")
}
if _, ok := data["original_blocked_count"]; ok {
t.Error("original_blocked_count must NOT be present (count fields removed)")
}
la, ok := data["lint_applied"].([]lint.Finding)
if !ok {
t.Fatalf("lint_applied wrong type: %T", data["lint_applied"])
}
if la == nil {
t.Error("lint_applied is nil — must be empty slice in detail mode")
}
ob, ok := data["original_blocked"].([]lint.Finding)
if !ok {
t.Fatalf("original_blocked wrong type: %T", data["original_blocked"])
}
if ob == nil {
t.Error("original_blocked is nil — must be empty slice in detail mode")
}
}
// =====================================================================
// End-to-end: +draft-create writing path emits envelope with lint fields.
// =====================================================================
// TestMailDraftCreate_WritePathLintEnvelopeDefault verifies +draft-create's
// default envelope contains the three always-present hint/id fields
// (compose_hint + draft_edit_hint + draft_id) and carries NO lint fields at
// all — neither `*_count` nor the full Finding arrays.
func TestMailDraftCreate_WritePathLintEnvelopeDefault(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
chdirTemp(t)
registerMailboxProfileMock(reg)
registerDraftCreateOK(reg)
err := runMountedMailShortcut(t, MailDraftCreate, []string{
"+draft-create",
"--to", "alice@example.com",
"--subject", "Test",
"--body", `<p>safe</p><script>alert(1)</script><font color="red">red</font>`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
// The three always-present hint/id fields must appear.
if hint, _ := data["compose_hint"].(string); hint == "" {
t.Error("compose_hint must be present in default envelope")
}
if hint, _ := data["draft_edit_hint"].(string); hint == "" {
t.Error("draft_edit_hint must be present in +draft-create default envelope")
} else if hint != draftEditHintConst {
t.Errorf("draft_edit_hint = %q, want exact const value", hint)
}
if id, _ := data["draft_id"].(string); id == "" {
t.Error("draft_id must be present in default envelope")
}
// No lint fields (neither count nor arrays) in default mode.
if _, present := data["lint_applied_count"]; present {
t.Error("lint_applied_count must NOT appear (count fields removed)")
}
if _, present := data["original_blocked_count"]; present {
t.Error("original_blocked_count must NOT appear (count fields removed)")
}
if _, present := data["lint_applied"]; present {
t.Error("lint_applied[] must be hidden in default mode")
}
if _, present := data["original_blocked"]; present {
t.Error("original_blocked[] must be hidden in default mode")
}
}
// TestMailDraftCreate_WritePathLintEnvelopeWithDetails verifies that passing
// --show-lint-details attaches the two Finding arrays only — no `*_count`
// fields — while still keeping compose_hint + draft_edit_hint + draft_id.
func TestMailDraftCreate_WritePathLintEnvelopeWithDetails(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
chdirTemp(t)
registerMailboxProfileMock(reg)
registerDraftCreateOK(reg)
err := runMountedMailShortcut(t, MailDraftCreate, []string{
"+draft-create",
"--to", "alice@example.com",
"--subject", "Test",
"--body", `<p>safe</p><script>alert(1)</script><font color="red">red</font>`,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
// Always-present hint/id fields survive in detail mode.
if hint, _ := data["compose_hint"].(string); hint == "" {
t.Error("compose_hint must be present in detail envelope")
}
if hint, _ := data["draft_edit_hint"].(string); hint == "" {
t.Error("draft_edit_hint must be present in +draft-create detail envelope")
} else if hint != draftEditHintConst {
t.Errorf("draft_edit_hint = %q, want exact const value", hint)
}
if id, _ := data["draft_id"].(string); id == "" {
t.Error("draft_id must be present in detail envelope")
}
// `*_count` fields are gone — callers compute counts via len(arr).
if _, present := data["lint_applied_count"]; present {
t.Error("lint_applied_count must NOT appear (count fields removed)")
}
if _, present := data["original_blocked_count"]; present {
t.Error("original_blocked_count must NOT appear (count fields removed)")
}
la, ok := data["lint_applied"].([]interface{})
if !ok {
t.Fatalf("lint_applied missing or wrong type: %T", data["lint_applied"])
}
ob, ok := data["original_blocked"].([]interface{})
if !ok {
t.Fatalf("original_blocked missing or wrong type: %T", data["original_blocked"])
}
if len(la) < 1 {
t.Errorf("expected ≥1 lint_applied entry, got %d", len(la))
}
if len(ob) < 1 {
t.Errorf("expected ≥1 original_blocked entry, got %d", len(ob))
}
}
// TestMailDraftCreate_PlainTextWritePathOmitsLintFields verifies the
// plain-text path's default envelope contains the always-present
// compose_hint + draft_edit_hint + draft_id and emits no lint fields at all.
func TestMailDraftCreate_PlainTextWritePathOmitsLintFields(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
chdirTemp(t)
registerMailboxProfileMock(reg)
registerDraftCreateOK(reg)
err := runMountedMailShortcut(t, MailDraftCreate, []string{
"+draft-create",
"--to", "alice@example.com",
"--subject", "Test",
"--body", "plain text only",
"--plain-text",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
// Always-present hint/id fields on the plain-text branch.
if hint, _ := data["compose_hint"].(string); hint == "" {
t.Error("compose_hint must be present on plain-text path")
}
if hint, _ := data["draft_edit_hint"].(string); hint == "" {
t.Error("draft_edit_hint must be present on +draft-create plain-text path")
} else if hint != draftEditHintConst {
t.Errorf("draft_edit_hint = %q, want exact const value", hint)
}
if id, _ := data["draft_id"].(string); id == "" {
t.Error("draft_id must be present on plain-text path")
}
// No lint fields at all on the default plain-text path.
if _, present := data["lint_applied_count"]; present {
t.Error("lint_applied_count must NOT appear on plain-text default path")
}
if _, present := data["original_blocked_count"]; present {
t.Error("original_blocked_count must NOT appear on plain-text default path")
}
if _, present := data["lint_applied"]; present {
t.Error("lint_applied[] must be hidden in default mode (plain-text)")
}
if _, present := data["original_blocked"]; present {
t.Error("original_blocked[] must be hidden in default mode (plain-text)")
}
}
// TestMailDraftCreate_AutofixApplied verifies that the writing path actually
// rewrites the body before sending it to drafts.create — the user's <font>
// tag must NOT reach the network as <font>.
func TestMailDraftCreate_AutofixApplied(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
chdirTemp(t)
registerMailboxProfileMock(reg)
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "d_test"},
},
}
reg.Register(stub)
err := runMountedMailShortcut(t, MailDraftCreate, []string{
"+draft-create",
"--to", "alice@example.com",
"--subject", "Test",
"--body", `<font color="red">x</font>`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Decode the raw EML and confirm <font> was rewritten before reaching
// emlbuilder. The base64url payload contains the HTML body in raw form.
captured := mustDecodeRawEMLFromStub(t, stub)
if strings.Contains(captured, "<font") {
t.Errorf("write-path should have rewritten <font>, EML still contains it: %q", captured)
}
if !strings.Contains(captured, "<span") {
t.Errorf("expected <span> wrapper in EML, got %q", captured)
}
}
// TestMailDraftCreate_ScriptStrippedBeforeSend verifies <script> is removed
// from the EML before drafts.create is invoked (writing-path safety floor).
func TestMailDraftCreate_ScriptStrippedBeforeSend(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
chdirTemp(t)
registerMailboxProfileMock(reg)
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "d_test"},
},
}
reg.Register(stub)
err := runMountedMailShortcut(t, MailDraftCreate, []string{
"+draft-create",
"--to", "alice@example.com",
"--subject", "Test",
"--body", `<p>before</p><script>alert(1)</script><p>after</p>`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
eml := mustDecodeRawEMLFromStub(t, stub)
if strings.Contains(eml, "<script") {
t.Errorf("script should be stripped before EML send, got %q", eml)
}
if strings.Contains(eml, "alert(1)") {
t.Errorf("script content should be removed, got %q", eml)
}
if !strings.Contains(eml, "before") || !strings.Contains(eml, "after") {
t.Errorf("surrounding paragraphs should survive, got %q", eml)
}
}
// =====================================================================
// Helpers — mail_shortcut_test.go ships the factory; these are local
// httpmock registrations specific to the lint integration tests.
// =====================================================================
// registerMailboxProfileMock registers a stock GET .../profile response so
// resolveComposeSenderEmail finds an address.
func registerMailboxProfileMock(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"primary_email_address": "sender@example.com",
"send_as": []interface{}{},
},
},
})
}
// registerDraftCreateOK registers a successful drafts.create response.
func registerDraftCreateOK(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"draft_id": "d_test123",
},
},
})
}
// mustDecodeRawEMLFromStub extracts the `raw` field from a captured body and
// base64url-decodes it. The stub.CapturedBody is populated by the httpmock
// after a match (registry.go:42 — the stub records every captured request).
func mustDecodeRawEMLFromStub(t *testing.T, stub *httpmock.Stub) string {
t.Helper()
if len(stub.CapturedBody) == 0 {
t.Fatal("stub did not capture any request body")
}
var captured map[string]interface{}
if err := jsonUnmarshal(stub.CapturedBody, &captured); err != nil {
t.Fatalf("decode captured body: %v", err)
}
raw, ok := captured["raw"].(string)
if !ok {
t.Fatalf("captured body has no `raw` string field: %#v", captured)
}
return decodeBase64URL(raw)
}
func jsonUnmarshal(b []byte, v interface{}) error {
return jsonDecoderUnmarshal(b, v)
}
// =====================================================================
// End-to-end coverage for the 5 other compose shortcuts. Each test feeds
// HTML containing a <font> tag (warning-tier autofix target) through the
// shortcut and asserts (a) the EML sent on the wire has the <font>
// rewritten to <span>, and (b) the envelope honours `--show-lint-details`.
// =====================================================================
// stubSourceMessageHTML registers a minimal source-message GET stub that
// `+reply` / `+reply-all` / `+forward` use to derive the parent message
// headers + body. The original body is plain HTML so the reply lint path
// is exercised on the user-authored body only (the writing-path contract:
// quoted block is never re-linted).
func stubSourceMessageHTML(reg *httpmock.Registry, bodyHTML string) {
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"primary_email_address": "me@example.com",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/messages/msg_w1",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message": map[string]interface{}{
"message_id": "msg_w1",
"thread_id": "thread_w1",
"smtp_message_id": "<msg_w1@example.com>",
"subject": "Original",
"head_from": map[string]interface{}{"mail_address": "sender@example.com", "name": "Sender"},
"to": []map[string]interface{}{{"mail_address": "me@example.com", "name": "Me"}},
"cc": []interface{}{},
"bcc": []interface{}{},
"body_html": base64URLEncode(bodyHTML),
"body_plain_text": base64URLEncode("plain"),
"internal_date": "1704067200000",
"attachments": []map[string]interface{}{},
},
},
},
})
}
// base64URLEncode wraps encoding/base64.URLEncoding.EncodeToString to keep
// the new tests readable inline.
func base64URLEncode(s string) string {
return base64.URLEncoding.EncodeToString([]byte(s))
}
// TestMailSend_WritePathLintAutofixesFontInEML drives +send end-to-end with
// HTML containing a <font> tag and asserts the body in the captured EML has
// been rewritten to <span> before the drafts.create POST.
func TestMailSend_WritePathLintAutofixesFontInEML(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
chdirTemp(t)
registerMailboxProfileMock(reg)
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "d_send"},
},
}
reg.Register(stub)
err := runMountedMailShortcut(t, MailSend, []string{
"+send",
"--to", "alice@example.com",
"--subject", "Send",
"--body", `<font color="red">payload</font>`,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("send failed: %v", err)
}
captured := mustDecodeRawEMLFromStub(t, stub)
if strings.Contains(captured, "<font") {
t.Errorf("+send writing-path should rewrite <font>, EML still has it: %q", captured)
}
if !strings.Contains(captured, "<span") {
t.Errorf("expected <span> in EML, got %q", captured)
}
data := decodeShortcutEnvelopeData(t, stdout)
la, ok := data["lint_applied"].([]interface{})
if !ok {
t.Fatalf("lint_applied missing or wrong type: %T", data["lint_applied"])
}
if len(la) < 1 {
t.Errorf("expected ≥1 lint_applied entry, got %d", len(la))
}
}
// TestMailReply_WritePathLintAutofixesFontInEML drives +reply end-to-end.
func TestMailReply_WritePathLintAutofixesFontInEML(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
chdirTemp(t)
stubSourceMessageHTML(reg, `<p>Original</p>`)
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "d_reply"},
},
}
reg.Register(stub)
err := runMountedMailShortcut(t, MailReply, []string{
"+reply",
"--message-id", "msg_w1",
"--body", `<font color="red">reply text</font>`,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("reply failed: %v", err)
}
captured := mustDecodeRawEMLFromStub(t, stub)
if strings.Contains(captured, "<font") {
t.Errorf("+reply writing-path should rewrite <font>, EML still has it: %q", captured)
}
if !strings.Contains(captured, "<span") {
t.Errorf("expected <span> in EML, got %q", captured)
}
data := decodeShortcutEnvelopeData(t, stdout)
if _, present := data["lint_applied"]; !present {
t.Error("lint_applied should appear under --show-lint-details")
}
}
// TestMailReplyAll_WritePathLintAutofixesFontInEML drives +reply-all e2e.
func TestMailReplyAll_WritePathLintAutofixesFontInEML(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
chdirTemp(t)
stubSourceMessageHTML(reg, `<p>Original</p>`)
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "d_replyall"},
},
}
reg.Register(stub)
err := runMountedMailShortcut(t, MailReplyAll, []string{
"+reply-all",
"--message-id", "msg_w1",
"--body", `<font color="red">reply-all text</font>`,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("reply-all failed: %v", err)
}
captured := mustDecodeRawEMLFromStub(t, stub)
if strings.Contains(captured, "<font") {
t.Errorf("+reply-all writing-path should rewrite <font>, EML still has it: %q", captured)
}
if !strings.Contains(captured, "<span") {
t.Errorf("expected <span> in EML, got %q", captured)
}
}
// TestMailForward_WritePathLintAutofixesFontInEML drives +forward e2e.
func TestMailForward_WritePathLintAutofixesFontInEML(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
chdirTemp(t)
stubSourceMessageHTML(reg, `<p>Original</p>`)
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "d_forward"},
},
}
reg.Register(stub)
err := runMountedMailShortcut(t, MailForward, []string{
"+forward",
"--message-id", "msg_w1",
"--to", "bob@example.com",
"--body", `<font color="red">forward note</font>`,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("forward failed: %v", err)
}
captured := mustDecodeRawEMLFromStub(t, stub)
if strings.Contains(captured, "<font") {
t.Errorf("+forward writing-path should rewrite <font>, EML still has it: %q", captured)
}
if !strings.Contains(captured, "<span") {
t.Errorf("expected <span> in EML, got %q", captured)
}
}
// TestMailDraftEdit_WritePathLintAutofixesFontViaBodyFlag verifies the
// `--body` shortcut on +draft-edit (which lowers to a set_body patch op)
// runs the writing-path lint before PUT-ing the updated EML.
func TestMailDraftEdit_WritePathLintAutofixesFontViaBodyFlag(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
chdirTemp(t)
// drafts.get(format=raw) returns a minimal multipart EML so the parser
// has a body to patch.
originalEML := "MIME-Version: 1.0\r\n" +
"From: me@example.com\r\n" +
"To: alice@example.com\r\n" +
"Subject: Edit\r\n" +
"Content-Type: text/html; charset=utf-8\r\n" +
"\r\n" +
"<p>original body</p>\r\n"
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/drafts/d_edit",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"draft_id": "d_edit",
"raw": base64URLEncode(originalEML),
},
},
})
stub := &httpmock.Stub{
Method: "PUT",
URL: "/user_mailboxes/me/drafts/d_edit",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "d_edit"},
},
}
reg.Register(stub)
err := runMountedMailShortcut(t, MailDraftEdit, []string{
"+draft-edit",
"--draft-id", "d_edit",
"--body", `<font color="red">new body</font>`,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("draft-edit failed: %v", err)
}
captured := mustDecodeRawEMLFromStub(t, stub)
if strings.Contains(captured, "<font") {
t.Errorf("+draft-edit writing-path should rewrite <font>, EML still has it: %q", captured)
}
if !strings.Contains(captured, "<span") {
t.Errorf("expected <span> in EML, got %q", captured)
}
data := decodeShortcutEnvelopeData(t, stdout)
if _, present := data["lint_applied"]; !present {
t.Error("lint_applied should appear under --show-lint-details on +draft-edit")
}
}
// TestMailDraftCreate_PlainTextShowLintDetailsEmitsEmptyArrays locks the
// 2×2 corner: plain-text body + --show-lint-details. The envelope must
// surface the two contract arrays as empty (non-nil) slices because the
// detail flag toggles their presence; the plain-text branch produces zero
// findings but the keys must still appear so consumers can rely on them
// unconditionally.
func TestMailDraftCreate_PlainTextShowLintDetailsEmitsEmptyArrays(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
chdirTemp(t)
registerMailboxProfileMock(reg)
registerDraftCreateOK(reg)
err := runMountedMailShortcut(t, MailDraftCreate, []string{
"+draft-create",
"--to", "alice@example.com",
"--subject", "Plain",
"--body", "plain text body, no html",
"--plain-text",
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
la, ok := data["lint_applied"].([]interface{})
if !ok {
t.Fatalf("lint_applied missing or wrong type on plain-text + show-lint-details: %T", data["lint_applied"])
}
if len(la) != 0 {
t.Errorf("plain-text body should produce 0 lint_applied entries, got %d", len(la))
}
ob, ok := data["original_blocked"].([]interface{})
if !ok {
t.Fatalf("original_blocked missing or wrong type: %T", data["original_blocked"])
}
if len(ob) != 0 {
t.Errorf("plain-text body should produce 0 original_blocked entries, got %d", len(ob))
}
}

View File

@@ -8,7 +8,6 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -24,9 +23,11 @@ var MailReply = common.Shortcut{
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-id", Desc: "Required. Message ID to reply to", Required: true},
{Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
{Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
bodyFileFlag,
{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
{Name: "to", Desc: "Additional To address(es), comma-separated (appended to original sender's address)"},
@@ -42,7 +43,8 @@ var MailReply = common.Shortcut{
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
signatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -70,8 +72,17 @@ var MailReply = common.Shortcut{
return err
}
hasTemplate := runtime.Str("template-id") != ""
if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
return output.ErrValidation("--body is required; pass the reply body (or use --template-id)")
bodyFlag := runtime.Str("body")
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
return err
}
body, bErr := resolveBodyFromFlags(runtime)
if bErr != nil {
return bErr
}
if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the reply body (or use --template-id)"); err != nil {
return err
}
if err := validateConfirmSendScope(runtime); err != nil {
return err
@@ -95,7 +106,10 @@ var MailReply = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
messageId := runtime.Str("message-id")
body := runtime.Str("body")
body, bErr := resolveBodyFromFlags(runtime)
if bErr != nil {
return bErr
}
toFlag := runtime.Str("to")
ccFlag := runtime.Str("cc")
bccFlag := runtime.Str("bcc")
@@ -244,6 +258,10 @@ var MailReply = common.Shortcut{
var composedHTMLBody string
var composedTextBody string
var srcInlineBytes int64
// Lint findings flowing into the writing-path stdout envelope.
// Initialise empty (non-nil) so the envelope always carries
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return fmt.Errorf("HTML reply blocked: %w", err)
@@ -261,6 +279,15 @@ var MailReply = common.Shortcut{
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
// Writing-path lint: operate on the user-authored body + signature
// ONLY — NOT on `quoted` (the <blockquote> derived from the
// original message). Double-sanitising risks dropping legitimate
// Lark quote markup such as adit-html-block* / history-quote-* /
// lark-mail-doc-quote (these classes are intentionally allow-listed
// in the tag classification "通过" row).
cleaned, rep := runWritePathLint(bodyWithSig)
bodyWithSig = cleaned
lintApplied, lintBlocked = rep.Applied, rep.Blocked
composedHTMLBody = bodyWithSig + quoted
bld = bld.HTMLBody([]byte(composedHTMLBody))
bld = addSignatureImagesToBuilder(bld, sigResult)
@@ -316,8 +343,12 @@ var MailReply = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
out := buildDraftSavedOutput(draftResult, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
return nil
}
@@ -325,7 +356,10 @@ var MailReply = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftResult.DraftID, err)
}
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -8,7 +8,6 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -24,9 +23,11 @@ var MailReplyAll = common.Shortcut{
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-id", Desc: "Required. Message ID to reply to all recipients", Required: true},
{Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
{Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
bodyFileFlag,
{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
{Name: "to", Desc: "Additional To address(es), comma-separated (appended to original recipients)"},
@@ -43,7 +44,8 @@ var MailReplyAll = common.Shortcut{
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
signatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -71,8 +73,17 @@ var MailReplyAll = common.Shortcut{
return err
}
hasTemplate := runtime.Str("template-id") != ""
if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
return output.ErrValidation("--body is required; pass the reply body (or use --template-id)")
bodyFlag := runtime.Str("body")
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
return err
}
body, bErr := resolveBodyFromFlags(runtime)
if bErr != nil {
return bErr
}
if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the reply body (or use --template-id)"); err != nil {
return err
}
if err := validateConfirmSendScope(runtime); err != nil {
return err
@@ -96,7 +107,10 @@ var MailReplyAll = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
messageId := runtime.Str("message-id")
body := runtime.Str("body")
body, bErr := resolveBodyFromFlags(runtime)
if bErr != nil {
return bErr
}
toFlag := runtime.Str("to")
ccFlag := runtime.Str("cc")
bccFlag := runtime.Str("bcc")
@@ -253,6 +267,8 @@ var MailReplyAll = common.Shortcut{
var composedHTMLBody string
var composedTextBody string
var srcInlineBytes int64
// Lint findings flowing into the writing-path stdout envelope.
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return fmt.Errorf("HTML reply-all blocked: %w", err)
@@ -270,6 +286,13 @@ var MailReplyAll = common.Shortcut{
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
// Writing-path lint: same pattern as +reply — operate on bodyWithSig
// only; the `quoted` block from the original message must NOT be
// re-linted (it may contain Feishu-native quote-block classes that
// the lint allow-list intentionally permits in pass-through).
cleaned, rep := runWritePathLint(bodyWithSig)
bodyWithSig = cleaned
lintApplied, lintBlocked = rep.Applied, rep.Blocked
composedHTMLBody = bodyWithSig + quoted
bld = bld.HTMLBody([]byte(composedHTMLBody))
bld = addSignatureImagesToBuilder(bld, sigResult)
@@ -325,8 +348,12 @@ var MailReplyAll = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
out := buildDraftSavedOutput(draftResult, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
return nil
}
@@ -334,7 +361,10 @@ var MailReplyAll = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftResult.DraftID, err)
}
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -23,10 +23,12 @@ var MailSend = common.Shortcut{
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "to", Desc: "Recipient email address(es), comma-separated"},
{Name: "subject", Desc: "Email subject. Required unless --template-id supplies a non-empty subject."},
{Name: "body", Desc: "Email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
{Name: "body", Desc: "Email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
bodyFileFlag,
{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
{Name: "cc", Desc: "CC email address(es), comma-separated"},
@@ -40,7 +42,8 @@ var MailSend = common.Shortcut{
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
signatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
to := runtime.Str("to")
subject := runtime.Str("subject")
@@ -74,12 +77,14 @@ var MailSend = common.Shortcut{
return err
}
hasTemplate := runtime.Str("template-id") != ""
bodyFlag := runtime.Str("body")
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
return err
}
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
}
if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
return output.ErrValidation("--body is required; pass the full email body (or use --template-id)")
}
// With --template-id, tos/ccs/bccs may come from the template, so
// defer the at-least-one-recipient check to Execute (after
// applyTemplate has merged the template addresses in).
@@ -97,7 +102,19 @@ var MailSend = common.Shortcut{
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
// Resolve the body content first (reading --body-file if set) so
// inline / HTML checks see the actual body. This makes the
// `--body-file plain.txt --inline …` combination fail validation
// the same way `--body 'plain' --inline …` already does, instead
// of silently dropping the inline images at Execute (Major #4).
body, bErr := resolveBodyFromFlags(runtime)
if bErr != nil {
return bErr
}
if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the full email body (or use --template-id)"); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), body); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
@@ -108,7 +125,10 @@ var MailSend = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
to := runtime.Str("to")
subject := runtime.Str("subject")
body := runtime.Str("body")
body, err := resolveBodyFromFlags(runtime)
if err != nil {
return err
}
ccFlag := runtime.Str("cc")
bccFlag := runtime.Str("bcc")
plainText := runtime.Bool("plain-text")
@@ -206,6 +226,10 @@ var MailSend = common.Shortcut{
var autoResolvedPaths []string
var composedHTMLBody string
var composedTextBody string
// Lint findings flowing into the writing-path stdout envelope.
// Initialised as empty (non-nil) slices so the envelope always carries
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if plainText {
composedTextBody = body
bld = bld.TextBody([]byte(composedTextBody))
@@ -220,6 +244,14 @@ var MailSend = common.Shortcut{
return resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
// Writing-path lint: AutoFix=true / Strict=false — the writing-path
// safety contract has no `--no-lint` opt-out. Runs AFTER
// applyTemplate (above) + ResolveLocalImagePaths +
// injectSignatureIntoBody so the lint sees the final HTML the
// recipient renderer will see.
cleanedHTML, rep := runWritePathLint(resolved)
resolved = cleanedHTML
lintApplied, lintBlocked = rep.Applied, rep.Blocked
composedHTMLBody = resolved
bld = bld.HTMLBody([]byte(composedHTMLBody))
bld = addSignatureImagesToBuilder(bld, sigResult)
@@ -283,8 +315,12 @@ var MailSend = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
out := buildDraftSavedOutput(draftResult, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
return nil
}
@@ -292,7 +328,10 @@ var MailSend = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftResult.DraftID, err)
}
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
return nil
},
}

View File

@@ -4,11 +4,13 @@
package mail
import (
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// assertValidationError fails the test unless err carries the validation
@@ -49,6 +51,57 @@ func assertValidatePasses(t *testing.T, err error) {
// Non-validation errors (auth/API failures) are expected without HTTP mocks.
}
func TestRequiredBodyRejectsWhitespaceBodyFile(t *testing.T) {
for _, tc := range []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "send",
shortcut: MailSend,
args: []string{
"+send", "--as", "user", "--to", "alice@example.com",
"--subject", "blank body-file", "--body-file", "blank.html",
},
},
{
name: "draft-create",
shortcut: MailDraftCreate,
args: []string{
"+draft-create", "--as", "user",
"--subject", "blank body-file", "--body-file", "blank.html",
},
},
{
name: "reply",
shortcut: MailReply,
args: []string{
"+reply", "--as", "user", "--message-id", "msg_001",
"--body-file", "blank.html",
},
},
{
name: "reply-all",
shortcut: MailReplyAll,
args: []string{
"+reply-all", "--as", "user", "--message-id", "msg_001",
"--body-file", "blank.html",
},
},
} {
t.Run(tc.name, func(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("blank.html", []byte(" \n\t"), 0o644); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
assertValidationError(t, err, "--body or --body-file is required")
})
}
}
// TC-1: +message --as bot --mailbox me → ErrValidation
func TestMailMessageBotMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)

View File

@@ -8,7 +8,7 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/mail/signature"
@@ -154,26 +154,14 @@ func executeSignatureDetail(runtime *common.RuntimeContext, resp *signature.GetS
}
// resolveLang maps CLI config lang ("zh"/"en") to i18n key ("zh_cn"/"en_us").
// resolveLang maps the preference to a locale the mail API accepts (it supports
// only zh_cn / en_us / ja_jp; anything else falls back to zh_cn).
func resolveLang(runtime *common.RuntimeContext) string {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return "zh_cn"
}
cfg, err := runtime.Factory.Config()
if err != nil {
return "zh_cn"
}
app := multi.FindApp(cfg.ProfileName)
if app == nil {
return "zh_cn"
}
switch app.Lang {
case "en":
return "en_us"
case "ja":
return "ja_jp"
switch runtime.Lang() {
case i18n.LangEnUS, i18n.LangJaJP:
return string(runtime.Lang())
default:
return "zh_cn"
return string(i18n.LangZhCN)
}
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/shortcuts/common"
)
func TestResolveLang(t *testing.T) {
tests := []struct {
name string
stored i18n.Lang
want string
}{
{"english", i18n.LangEnUS, "en_us"},
{"japanese", i18n.LangJaJP, "ja_jp"},
{"chinese", i18n.LangZhCN, "zh_cn"},
{"legacy short en", "en", "en_us"},
{"unsupported-by-mail falls back to zh_cn", i18n.LangFrFR, "zh_cn"},
{"unset falls back to zh_cn", "", "zh_cn"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rt := &common.RuntimeContext{Config: &core.CliConfig{Lang: tt.stored}}
if got := resolveLang(rt); got != tt.want {
t.Errorf("resolveLang(stored=%q) = %q, want %q", tt.stored, got, tt.want)
}
})
}
}

View File

@@ -26,5 +26,6 @@ func Shortcuts() []common.Shortcut {
MailShareToChat,
MailTemplateCreate,
MailTemplateUpdate,
MailLintHTML,
}
}

View File

@@ -97,6 +97,14 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
if workspaceCmd == nil || workspaceCmd.Name() != "+base-get" {
t.Fatalf("base workspace shortcut not mounted: %#v", workspaceCmd)
}
blockDataCmd, _, err := program.Find([]string{"base", "+dashboard-block-get-data"})
if err != nil {
t.Fatalf("find dashboard block get-data shortcut: %v", err)
}
if blockDataCmd == nil || blockDataCmd.Name() != "+dashboard-block-get-data" {
t.Fatalf("base dashboard block get-data shortcut not mounted: %#v", blockDataCmd)
}
}
// Service-level cobra commands created by RegisterShortcuts must carry

View File

@@ -322,6 +322,57 @@ lark-cli mail +send --to alice@example.com --subject '周报' \
lark-cli mail +reply --message-id <id> --body '收到,谢谢'
```
**HTML 写法、风格指引、场景模板请参考两份配套文档:**
- [邮件 HTML 写法指南](references/lark-mail-html.md) — 标签 / class / inline style 速查、飞书原生写法(含风格指引)、完整场景模板(通知 / 周报 / 决策请求);表格 / 列表 / 字号 / 引用 / 链接 / 内嵌图片标准写法都在这里
- [`+lint-html` 用法](references/lark-mail-lint-html.md) — 创建草稿前自检 / 修复 AI 输出
### 邮件风格规范
写信时必须遵守的文风底线(详见 [邮件 HTML 写法指南](references/lark-mail-html.md)
- **禁机械编号**:用 `<ul>` / `<ol>` 表达列表,不要用 "一、二、三" / "①②③" / "1) 2) 3)"
- **emoji 克制**emoji 仅作状态标签(⏰紧急 / ✅完成 / ⚠️风险),不要在正文段落里堆 emoji 装饰
- **禁冗长 disclaimer**:删除 "希望对您有帮助" / "感谢您的耐心阅读" 等填充语;信息密度优先
- **标题 ≤ 30 字**:邮件主题 `--subject` 控制在 30 字内,避免被收件箱截断
- **决策 / 结论前置**:第一段就给结论或决策项,让收件人扫一眼就知道是不是需要他做什么
- **问候 / 落款不超 1 段**`Hi 各位 Reviewer` / `各位同事:` 一句话即可;落款 `[发件人姓名] / [团队] / [日期]` 一行结束
### 严禁手拼 raw EML
> **CRITICAL严禁手拼 raw EML 直传 `drafts.create`,必须走 compose 5 shortcut`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward`)或 `+draft-edit` 的 body op。**
`emlbuilder` 已内置 RFC 合规处理base64 / boundary / header folding / 附件 RFC 2231 等AI **无需自学 RFC**。手拼 raw EML 几乎一定会踩坑(编码错误 / 边界冲突 / 收件端不渲染),且绕开了 lark-cli 的统一安全和兼容性兜底——本仓库的 `+send` / `+draft-create` 等 shortcut 已封装好所有发信细节AI 只需关注业务字段(收件人 / 主题 / HTML 正文 / 附件路径)即可。
### 写入路径内置 HTML lint
`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op 在调用 `emlbuilder` **之前**会强制对 HTML 正文做 lint
- 错误(`<script>` / `on*` / `javascript:` URL / `<iframe>` / `<form>` / `<style>` / `<link>` 等)会被**直接删除**
- 警告(`<font>` / `<center>` / `<marquee>`)会被**自动修复**为飞书原生写法
- 不允许的 CSS property`position` / `z-index` / `transform` 等)会从 inline `style` 里删除
默认 envelope 只携带必要字段;加 `--show-lint-details` 后会同时输出两个 Finding 数组(无违规时是空数组),方便调用方调试:
```json
{
"ok": true,
"data": {
"draft_id": "...",
"lint_applied": [
{"rule_id": "TAG_FONT_TO_SPAN", "severity": "warning", "tag_or_attr": "font",
"excerpt": "<font color=\"red\"...>", "hint": "已替换为 <span style=...>"}
],
"original_blocked": [
{"rule_id": "TAG_SCRIPT_BLOCKED", "severity": "error", "tag_or_attr": "script",
"excerpt": "<script...>", "hint": "已整段删除XSS 风险)"}
]
}
}
```
写入路径**没有 `--no-lint` 总开关**——这是本方案的安全契约。如果想预先看 HTML 是否会被改动,先用 [`+lint-html`](references/lark-mail-lint-html.md) 跑一次。
### 读取邮件:按需控制返回内容
`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。

View File

@@ -1,6 +1,6 @@
---
name: lark-base
version: 1.2.1
version: 1.2.2
description: "当需要用 lark-cli 操作飞书多维表格Base时调用搜索 Base、建表、字段管理、记录读写、记录分享链接、视图配置、历史查询以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
metadata:
requires:
@@ -178,6 +178,7 @@ metadata:
| `+dashboard-list / +dashboard-get` | 列出仪表盘,或获取仪表盘详情 | [`lark-base-dashboard-list.md`](references/lark-base-dashboard-list.md)、[`lark-base-dashboard-get.md`](references/lark-base-dashboard-get.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md) | 进入仪表盘语义后先读 guide`+dashboard-list` 只能串行执行 |
| `+dashboard-create / +dashboard-update / +dashboard-delete` | 创建、更新或删除仪表盘 | [`lark-base-dashboard-create.md`](references/lark-base-dashboard-create.md)、[`lark-base-dashboard-update.md`](references/lark-base-dashboard-update.md)、[`lark-base-dashboard-delete.md`](references/lark-base-dashboard-delete.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md) | 创建前先明确看板目标和展示场景;更新前先读取当前配置;删除前先确认目标 |
| `+dashboard-block-list / +dashboard-block-get` | 列出图表组件,或获取单个 block 详情 | [`lark-base-dashboard-block-list.md`](references/lark-base-dashboard-block-list.md)、[`lark-base-dashboard-block-get.md`](references/lark-base-dashboard-block-get.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | `+dashboard-block-list` 只能串行执行;查看配置细节时读 block config 文档 |
| `+dashboard-block-get-data` | 获取图表组件的计算结果 | [`lark-base-dashboard-block-get-data.md`](references/lark-base-dashboard-block-get-data.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | 适合读取图表组件的最终计算结果;此命令不返回 block 元数据,只返回计算结果 |
| `+dashboard-block-create / +dashboard-block-update / +dashboard-block-delete` | 创建、更新或删除图表组件 | [`lark-base-dashboard-block-create.md`](references/lark-base-dashboard-block-create.md)、[`lark-base-dashboard-block-update.md`](references/lark-base-dashboard-block-update.md)、[`lark-base-dashboard-block-delete.md`](references/lark-base-dashboard-block-delete.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | 涉及 `data_config`、图表类型、filter 时要读 block config 文档;删除前先确认目标 |
### 2.8 表单模块

View File

@@ -0,0 +1,717 @@
# base +dashboard-block-get-data
> **前置条件:** 先阅读 [lark-base-dashboard.md](lark-base-dashboard.md) 了解 dashboard 整体工作流。
获取仪表盘图表组件block的**最终计算结果**,返回一份适合 AI 直接消费的图表协议 JSON。
这个命令适合以下场景:
1. 读取柱状图 / 条形图 / 折线图 / 饼图 / 环形图 / 面积图 / 组合图 / 散点图 / 漏斗图 / 雷达图 / 词云 / 指标卡的**实际计算结果**
2. 把图表结果交给 AI 做后续总结、趋势解释、同比/环比说明、异常点提取;
3. 在**不读取原始记录**的前提下,直接消费图表层已经聚合好的结果;
4. 验证某个图表当前展示的数据是否符合预期。
> [!IMPORTANT]
> - 本命令返回的是**图表结果协议**,不是 block 元数据;
> - 如果你需要 `name`、`type`、`layout`、`data_config` 等配置,请先用 [`+dashboard-block-get`](lark-base-dashboard-block-get.md)
> - 文本组件(`text`)不涉及计算,不适用本命令;
## 一句话理解
`+dashboard-block-get-data` = **拿图表“算出来的结果”**,而不是拿图表“怎么配置的”。
---
## 支持的图表类型
当前支持以下图表类型的数据计算与返回:
### 二维图表10 种)
- 柱状图
- 条形图
- 折线图
- 饼图
- 环形图
- 面积图
- 组合图
- 散点图
- 漏斗图
- 雷达图
### 特殊类型2 种)
- 词云
- 指标卡statistics
> [!CAUTION]
> 文本组件虽然也属于 dashboard block但它不产生可计算数据因此不会返回本协议。
---
## 推荐命令
```bash
lark-cli base +dashboard-block-get-data \
--base-token bascn***************CtadY \
--block-id chtxxxxxxxx
```
如果你还不知道目标 block 的 ID典型顺序是
```bash
# 先看仪表盘里有哪些组件
lark-cli base +dashboard-block-list \
--base-token bascn***************CtadY \
--dashboard-id blkxxxxxxxx
# 再读取某个组件的最终计算结果
lark-cli base +dashboard-block-get-data \
--base-token bascn***************CtadY \
--block-id chtxxxxxxxx
```
如果你需要先确认组件类型、名称或 `data_config`,请先执行:
```bash
lark-cli base +dashboard-block-get \
--base-token bascn***************CtadY \
--dashboard-id blkxxxxxxxx \
--block-id chtxxxxxxxx
```
---
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token标识目标多维表格 |
| `--block-id <id>` | 是 | 图表 Block ID即目标组件的唯一标识 |
| `--format <fmt>` | 否 | 输出格式,遵循 CLI 全局输出格式规则 |
| `--dry-run` | 否 | 只预览 API 调用,不真正执行 |
> [!TIP]
> 这个命令**不需要** `--dashboard-id`。只要 `base_token + block_id` 即可定位并读取图表结果。
---
## 返回结构总览
服务端响应外层仍然是标准 OpenAPI 包装:
```json
{
"code": 0,
"msg": "success",
"data": {
"dimensions": [...],
"measures": [...],
"main_data": [...]
}
}
```
其中 `data` 就是 CLI 图表协议本体。不同图表类型的 `data` 结构略有不同:
| 图表类型 | 一定有 | 可能有 |
|----------|--------|--------|
| 二维图表 | `dimensions` / `measures` / `main_data` | 无 |
| 词云 | `dimensions` / `measures` / `main_data` | 无 |
| 指标卡 | `dimensions` / `measures` / `main_data` | `comparison_data` / `trend_data` |
---
## 协议字段说明
### 1) `dimensions`
维度定义数组,告诉你主结果里每个 `dim_*` key 代表什么字段。
```json
[
{
"field_name": "文本",
"alias": "dim_5bKp"
}
]
```
字段含义:
| 字段 | 说明 |
|------|------|
| `field_name` | 维度字段显示名称 |
| `alias` | 维度别名,在 `main_data` / `trend_data` 中作为 key 使用 |
### 2) `measures`
指标定义数组,告诉你每个 `me_*` key 代表什么聚合指标。
```json
[
{
"field_name": "Count",
"aggregation": "count_all",
"alias": "me_Y291bnRfYWxsX0NvdW50"
}
]
```
字段含义:
| 字段 | 说明 |
|------|------|
| `field_name` | 统计该指标时所使用的字段名称;当 `aggregation = count_all` 时固定为 `Count`,表示统计记录总数 |
| `aggregation` | 聚合方式,常见值:`count_all` / `count` / `sum` / `avg` / `min` / `max` |
| `alias` | 指标别名,在 `main_data` / `comparison_data` / `trend_data` 中作为 key 使用 |
例如:
- 如果统计“销售额”的求和,则 `field_name = 销售额``aggregation = sum`
- 如果统计记录总数,则 `field_name = Count``aggregation = count_all`
### 3) `main_data`
主结果集。每一行都是一个对象key 不是字段名本身,而是 `dimensions` / `measures` 中声明过的 `alias`
```json
[
{
"dim_5bKp": {"value": "A"},
"me_Y291bnRfYWxsX0NvdW50": {"value": 3}
}
]
```
### 4) `comparison_data`
仅指标卡可能返回。表示同/环比的两个值,顺序固定为:
1. 当前周期值
2. 对比周期值
> [!NOTE]
> 原始协议里通常**不直接展示周期名称**,只提供对应的值。因此解释“同比”还是“环比”、以及比较窗口具体是什么,通常要结合组件配置或 UI 上下文理解。
### 5) `trend_data`
仅指标卡可能返回。表示时间序列趋势,每一行通常包含一个时间维度和一个指标值。
---
## alias 规则与读取方式
你不应该把 alias 当成人类可读字段名,而应把它视为**结果表里的列 ID**。
常见生成规则:
- 维度 alias`dim_` + `base64(field_name)`
- 指标 alias`me_` + `base64(aggregation + "_" + field_name)`
> [!NOTE]
> 为了便于阅读,本文档中的部分示例会使用**简化后的 alias**(例如 `dim_xxx`、`me_xxx` 或较短的示例值),不保证和真实返回值逐字符一致。
> 在实际读取结果时,应始终以 `dimensions` / `measures` 中声明的 alias 为准,而不要假设所有示例都严格展开成完整编码值。
例如:
```json
{
"dimensions": [
{"field_name": "文本", "alias": "dim_5bKp"}
],
"measures": [
{"field_name": "Count", "aggregation": "count_all", "alias": "me_xxx"}
],
"main_data": [
{
"dim_5bKp": {"value": "A"},
"me_xxx": {"value": 3}
}
]
}
```
应解读为:
- `dim_5bKp` 对应字段“文本”,取值是 `A`
- `me_xxx` 对应指标 `count_all(Count)`,取值是 `3`
> [!TIP]
> 读取结果时,**先看 `dimensions` / `measures`,再解 `main_data`**。不要仅凭 alias 名字猜含义。
---
## 各图表类型的协议细节
### 一、二维图表
适用于:柱状图、条形图、折线图、饼图、环形图、面积图、组合图、散点图、漏斗图、雷达图。
#### 结构特征
- `dimensions`:通常有 `1~2` 个维度
- 不分组聚合时:通常 1 个维度
- 开启分组聚合时:通常 2 个维度
- `measures`:指标定义数组
- `main_data`:按“维度组合”展开后的行数据
#### 这类数据代表什么
二维图表返回的本质上是一张**聚合结果表**
- 每一行代表一个维度值,或一组维度组合;
- 每一个 measure 值代表该维度下算出来的指标结果;
- 如果图表开启了分组聚合,那么每一行表示“主维度 + 分组维度”的一个组合结果;
- 如果图表是折线图、面积图这类带时间轴的图,通常可以把第一维理解为横轴、把 measure 理解为纵轴数值;
- 如果图表是饼图、环形图这类占比图,通常可以把每一行理解为一个扇区对应的分类及其数值。
换句话说AI 在读取这类结果时可以把它当作“按某些维度聚合后的统计明细表”适合进一步做排序、Top N、占比解释、分组对比和趋势总结。
#### 示例 1普通二维图表无分组聚合
```json
{
"dimensions": [
{
"field_name": "文本",
"alias": "dim_5bKp"
}
],
"measures": [
{
"aggregation": "count_all",
"field_name": "Count",
"alias": "me_Y291bnRfYWxsX0NvdW50"
}
],
"main_data": [
{
"dim_5bKp": {"value": "A"},
"me_Y291bnRfYWxsX0NvdW50": {"value": 3}
},
{
"dim_5bKp": {"value": "B"},
"me_Y291bnRfYWxsX0NvdW50": {"value": 2}
},
{
"dim_5bKp": {"value": "C"},
"me_Y291bnRfYWxsX0NvdW50": {"value": 2}
}
]
}
```
可解读为:
- 维度字段是“文本”
- 指标是“按记录总数统计”
- 当“文本”字段为 `A` 时,对应的 `Count` 指标值是 `3`
- 当“文本”字段为 `B` 时,对应的 `Count` 指标值是 `2`
- 当“文本”字段为 `C` 时,对应的 `Count` 指标值是 `2`
#### 示例 2二维图表开启分组聚合
```json
{
"dimensions": [
{
"field_name": "文本",
"alias": "dim_5bKp"
},
{
"field_name": "单选",
"alias": "dim_5aSl"
}
],
"measures": [
{
"aggregation": "count_all",
"field_name": "Count",
"alias": "me_YW91bnR"
}
],
"main_data": [
{
"dim_5bKp": {"value": "A"},
"dim_5aSl": {"value": "a-1"},
"me_YW91bnR": {"value": 2}
},
{
"dim_5bKp": {"value": "A"},
"dim_5aSl": {"value": "a-2"},
"me_YW91bnR": {"value": 1}
},
{
"dim_5bKp": {"value": "B"},
"dim_5aSl": {"value": "b-1"},
"me_YW91bnR": {"value": 1}
},
{
"dim_5bKp": {"value": "C"},
"dim_5aSl": {"value": "c-1"},
"me_YW91bnR": {"value": 2}
}
]
}
```
可解读为:
- 第一维是“文本”,第二维是“单选”,指标是“按记录总数统计”
- 当“文本”字段为 `A`、且“单选”字段为 `a-1` 时,对应的指标值是 `2`
- 当“文本”字段为 `A`、且“单选”字段为 `a-2` 时,对应的指标值是 `1`
- 当“文本”字段为 `B`、且“单选”字段为 `b-1` 时,对应的指标值是 `1`
- 当“文本”字段为 `C`、且“单选”字段为 `c-1` 时,对应的指标值是 `2`
- 如果按“文本”字段汇总,那么“文本”字段为 `A` 时总指标值是 `3`;为 `B` 时总指标值是 `1`;为 `C` 时总指标值是 `2`
---
### 二、词云
#### 结构特征
词云协议仍然沿用 `dimensions + measures + main_data` 的结构,但语义稍有不同:
- `dimensions` 对应被分词的字段;
- `main_data` 每一行代表一个词;
- `measure` 的 value 表示按该词分组后计算出来的统计值。
#### 这类数据代表什么
词云返回的不是“原文列表”,而是**按词分组后的聚合统计结果**
- `dimensions` 定义的是被分词的来源字段;
- `measure` 对应的是该词在当前图表统计范围内对应的统计值,具体含义取决于聚合方式和指标字段;
- `main_data` 的每一行都可以理解成“某个词 + 该词对应的统计结果”,其中该维度的具体 value 就是拆分出来的词;
- 返回结果通常已经结合图表当前过滤条件、时间范围、数据权限等上下文计算完成。
因此AI 读取词云数据时,更适合做“关键词排序”“热点词解释”“按词聚合结果分析”“主题归纳”,而不是把它当成逐条文本记录去理解。
#### 示例
```json
{
"dimensions": [
{
"field_name": "文本",
"alias": "dim_5bKp"
}
],
"measures": [
{
"aggregation": "count_all",
"field_name": "Count",
"alias": "me_YW91bnR"
}
],
"main_data": [
{
"dim_5bKp": {"value": "A"},
"me_YW91bnR": {"value": 3}
},
{
"dim_5bKp": {"value": "B"},
"me_YW91bnR": {"value": 2}
},
{
"dim_5bKp": {"value": "C"},
"me_YW91bnR": {"value": 2}
}
]
}
```
可解读为:
- 被统计的分词字段是“文本”
- 当前示例里的 measure 是 `count_all(Count)`,所以这里的统计值可以理解为“按词分组后的记录总数”
- 当分词结果为 `A` 时,对应的统计值是 `3`
- 当分词结果为 `B` 时,对应的统计值是 `2`
- 当分词结果为 `C` 时,对应的统计值是 `2`
- 按统计值排序,分词结果 `A` 对应的值最高
- 分词结果 `B``C` 的统计值相同,说明它们处于同一梯队
---
### 三、指标卡statistics
指标卡除了主值外,还可能包含同/环比与趋势结果,是本命令里结构最特殊的一类。
#### 结构特征
- `measures`**有且仅有一个指标**
- `main_data`:通常只有一行,表示总指标值
- `comparison_data`:可选,表示当前周期值与对比周期值
- `trend_data`:可选,表示趋势序列
- `dimensions`:可能包含同/环比日期字段、趋势日期字段
#### 这类数据代表什么
指标卡返回的核心是一个**主指标摘要**,外加可选的比较信息和趋势信息:
- `main_data` 表示当前卡片最核心、最醒目的那个主值;它通常是某个表的记录总数,或某个字段的聚合值,本身**不带时间周期概念**
- `comparison_data` 表示用于同/环比展示的两个数值,通常是“当前周期值”和“对比周期值”;它们表示某个时间周期下的记录总数,或某个字段的聚合值;
- `trend_data` 表示这个指标在一段时间内的变化轨迹,用来支持走势判断;
- `dimensions` 在指标卡里通常不是拿来做主分组展示,而是给 `trend_data` 或同/环比相关日期字段提供语义说明。
例如:
- `main_data = 7` 可以理解为当前卡片展示的主数据,比如某张表当前总记录数是 `7`
- `comparison_data[0] = 6` 则表示某个比较周期下的当前值,比如“本月记录总数 = 6”
- 因此,`main_data``comparison_data[0]` **不一定相等**,因为两者表达的口径并不完全相同。
因此AI 在解读指标卡时,应该优先回答这几个问题:
1. 当前主值是多少;
2. 和对比周期相比是上升、下降还是持平;
3. 趋势整体是增长、波动还是下滑;
4. 是否存在明显的异常峰值或低谷。
> [!NOTE]
> 当指标卡**同时指定同/环比和趋势**时,`dimensions` 中日期维度的顺序是固定的:
> 1. 第一个元素是**趋势**对应的日期维度;
> 2. 第二个元素是**同/环比**对应的日期维度。
>
> 另外要注意:`comparison_data` 自身通常**不直接携带日期字段**,它只给出“当前周期值 / 对比周期值”。
> `dimensions` 中的第一个日期维度会直接出现在 `trend_data` 中,作为趋势序列的时间列;
> 第二个日期维度则主要用于补充“该卡片配置了哪类比较相关日期字段”的语义。
#### 示例
```json
{
"dimensions": [
{
"field_name": "日期",
"alias": "dim_ZGF0ZQ"
},
{
"field_name": "日期2",
"alias": "dim_ZGF0ZTI"
}
],
"measures": [
{
"aggregation": "count_all",
"field_name": "Count",
"alias": "me_YW91b"
}
],
"main_data": [
{
"me_YW91b": {"value": 7}
}
],
"comparison_data": [
{
"me_YW91b": {"value": 6}
},
{
"me_YW91b": {"value": 0}
}
],
"trend_data": [
{
"dim_ZGF0ZQ": {"value": "2026-01-15"},
"me_YW91b": {"value": 1}
},
{
"dim_ZGF0ZQ": {"value": "2026-01-17"},
"me_YW91b": {"value": 1}
},
{
"dim_ZGF0ZQ": {"value": "2026-03-22"},
"me_YW91b": {"value": 1}
},
{
"dim_ZGF0ZQ": {"value": "2026-04-24"},
"me_YW91b": {"value": 2}
},
{
"dim_ZGF0ZQ": {"value": "2026-05-01"},
"me_YW91b": {"value": 1}
}
]
}
```
可解读为:
- 当前主指标值 = `7`
- 当前主指标值不带时间周期概念,可理解为当前卡片主数据
- comparison_data[0] = 当前周期值 `6`,例如某个时间周期(如本月)下的统计值
- comparison_data[1] = 对比周期值 `0`
- `dimensions[0]` 对应趋势日期维度,因此实际出现在 `trend_data`
- `dimensions[1]` 对应同/环比相关的日期维度,用来补充比较语义
- trend_data 展示该指标随时间的变化序列
- 从 comparison_data 看,当前周期相较对比周期是上升的,并且对比周期值为 0
- 从 trend_data 看,这个指标并不是每天都有值,而是在若干离散日期出现
- 趋势序列里的最高点出现在 `2026-04-24`,值为 `2`
- 其余出现的日期大多为 `1`,说明整体上有波动,但暂时没有持续快速增长的趋势
> [!NOTE]
> `comparison_data` 只告诉你“当前值 / 对比值”,**不额外标出日期区间文本**。如果用户需要完整说明“和上周比”还是“和上月比”,通常要结合组件配置或界面上下文进一步判断。
---
## 如何正确解读返回值
建议按下面顺序阅读:
1. **先看 `dimensions`**:确认每个 `dim_*` alias 对应哪个字段;
2. **再看 `measures`**:确认每个 `me_*` alias 是什么聚合方式;
3. **最后读 `main_data` / `comparison_data` / `trend_data`**:把 alias 还原成“字段名 + 指标名”再做解释。
### 推荐解释模板
如果要把结果转成自然语言,建议不要只“复述数值”,而应尽量覆盖下面几个层次:
1. **先解释指标含义**:说明 measure 代表“记录总数”“某字段求和”“平均值”等;
2. **再给出核心结果**:明确当前主值、主要分类、主要组合或主要词项;
3. **做排序或 Top N 提炼**:指出最高、最低、前几名、同一梯队;
4. **补充分组/对比关系**:如果有第二维或 comparison_data就说明比较对象和差异
5. **分析趋势或异常点**:如果有时间序列,指出上升、下降、波动、峰值、低谷;
6. **最后给一句结论**:总结最值得关注的信息。
可参考下面模板:
- 二维图表:
- 基础模板:`按 <维度字段> 统计,当前指标 <指标含义>;其中 <维度值1>=<指标值1><维度值2>=<指标值2> ...`
- 增强模板:`按 <维度字段> 统计,当前指标表示 <指标含义>。从结果看,<Top1维度值> 的值最高,为 <Top1值><Top2维度值> 和 <Top3维度值> 紧随其后。若按 Top N 看,前 <N> 项合计贡献了 ...;若看低值项,<低值维度值> 最低,为 <低值>。整体上,<一句总结>`
- 分组聚合图表:
- 基础模板:`按 <维度1> 统计,并以 <维度2> 分组,得到 <组合1>=<值1><组合2>=<值2> ...`
- 增强模板:`当前指标表示 <指标含义>。按 <维度1> 拆分后,不同 <维度2> 组之间存在明显差异:例如 <组合1> = <值1><组合2> = <值2>。如果按 <维度1> 汇总,<Top1维度1值> 总值最高,为 <汇总值>;如果看组内对比,<某组> 在 <某维度1值> 下表现最强 / 最弱。整体说明 <一句总结>`
- 词云:
- 基础模板:`按分词结果统计,当前指标表示 <指标含义>;其中 <词1>=<统计值1><词2>=<统计值2> ...`
- 增强模板:`当前词云反映的是“按词分组后的 <指标含义>”。从结果看,<Top1词> 的值最高,为 <值1>,说明它是当前最突出的关键词;<Top2词>、<Top3词> 处于第二梯队。如果按 Top N 看,主要关注词集中在 <主题A>、<主题B>;如果有多个词数值接近,可归为同一热点层级。整体上,这组词更适合用来总结 <主题/热点/关注点>`
- 指标卡:
- 基础模板:`当前主指标值为 <main_data>;当前周期值为 <comparison_data[0]>;对比周期值为 <comparison_data[1]>;趋势上 ...`
- 增强模板:`当前主指标表示 <指标含义>,主值为 <main_data>。若看周期比较,当前周期值为 <comparison_data[0]>,对比周期值为 <comparison_data[1]>,因此整体表现为 <上升/下降/持平>。若看趋势序列,最高点出现在 <日期>,值为 <峰值>;最低点出现在 <日期>,值为 <低值>;整体走势表现为 <持续增长/阶段波动/明显回落>。如果需要给出结论,可总结为:<一句总结>`
> [!TIP]
> 当用户明确要求“帮我分析”“帮我总结”“帮我找异常 / Top N / 趋势”时,优先采用增强模板,而不是只逐条复述原始数值。
---
## 常见工作流
### 场景 1用户要“拿这个图表当前展示的数据”
```bash
# 如果已知 block_id直接读结果
lark-cli base +dashboard-block-get-data \
--base-token xxx \
--block-id chtxxxxxxxx
```
### 场景 2用户说“帮我分析这个图表”但你还不知道它是什么组件
```bash
# 先看组件配置,确认它是不是支持计算的图表类型
lark-cli base +dashboard-block-get \
--base-token xxx \
--dashboard-id blk_xxx \
--block-id chtxxxxxxxx
# 再读最终计算结果
lark-cli base +dashboard-block-get-data \
--base-token xxx \
--block-id chtxxxxxxxx
```
### 场景 3用户要找“仪表盘里哪个图的结果异常”
```bash
# 先列组件
lark-cli base +dashboard-block-list \
--base-token xxx \
--dashboard-id blk_xxx
# 再针对可疑 block 逐个取结果
lark-cli base +dashboard-block-get-data \
--base-token xxx \
--block-id chtxxxxxxxx
```
---
## 何时优先用这个命令
- 用户说“帮我拿这个图表算出来的数据 / 结果 / 指标”
- 用户已经知道 `block_id`,目标是**读取结果**而不是看配置
- 用户后续还要让 AI 对图表结果做解释、归纳、比较、总结
- 你只关心图表层的聚合产出,不需要回到底表逐条读记录
## 何时不要误用
- 想看 block 的 `data_config`、名称、类型、布局 → 用 `+dashboard-block-get`
- 想列出仪表盘里有哪些组件 → 用 `+dashboard-block-list`
- 想修改或新建组件 → 用 `+dashboard-block-update` / `+dashboard-block-create`
- 想看原始记录明细,而不是图表聚合结果 → 回到 `record-*`
- 目标是文本组件 → 本命令不适用
---
## 常见误区
### 误区 1把这个命令当成“获取 block 详情”
不是。这个命令不返回:
- block 名称
- block 类型
- layout
- `data_config`
- 所属 dashboard 信息
这些都应该通过 [`+dashboard-block-get`](lark-base-dashboard-block-get.md) 获取。
### 误区 2以为它返回的是原始记录
不是。它返回的是**图表聚合后的最终结果**。如果图表本身做了过滤、分组、聚合、时间窗口限制,返回值反映的是图表视角,不是原始表全量明细。
### 误区 3直接把 alias 当真实字段名读
不应该。alias 只是协议里的键,必须结合 `dimensions` / `measures` 还原语义。
### 误区 4看到指标卡的 `comparison_data` 就以为已经知道“同比/环比周期文本”
不一定。它只给出比较值,不一定给出周期标签。若要精确解释比较窗口,通常还需要组件配置或 UI 上下文。
---
## dry-run 用途
可用来确认最终会调用的接口路径:
```bash
lark-cli base +dashboard-block-get-data \
--base-token bascn_example_token \
--block-id chtxxxxxxxx \
--dry-run \
--format pretty
```
你应能看到类似:
```text
GET /open-apis/base/v3/bases/bascn_example_token/dashboards/blocks/chtxxxxxxxx/data
```
适合在以下场景使用:
- 校验 `base_token` / `block_id` 是否传对;
- 调试 agent 生成的命令;
- 编写自动化测试时确认请求结构。
---
## 参考
- [lark-base-dashboard.md](lark-base-dashboard.md) — dashboard 模块总指引
- [lark-base-dashboard-block-get.md](lark-base-dashboard-block-get.md) — 获取 block 元数据
- [dashboard-block-data-config.md](dashboard-block-data-config.md) — data_config 结构和组件类型说明

View File

@@ -18,6 +18,7 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成**
| 在仪表盘里添加组件 | `+dashboard-block-create` | 先读 [lark-base-dashboard-block-create.md](lark-base-dashboard-block-create.md),再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) |
| 修改组件 | `+dashboard-block-update` | 先读 [lark-base-dashboard-block-update.md](lark-base-dashboard-block-update.md),再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) |
| 查看仪表盘有哪些组件 | `+dashboard-get``+dashboard-block-list` | 本页下方「查看仪表盘」 |
| 读取图表最终计算结果 | `+dashboard-block-get-data` | [lark-base-dashboard-block-get-data.md](lark-base-dashboard-block-get-data.md) |
| 智能重排组件布局 | `+dashboard-arrange` | [lark-base-dashboard-arrange.md](lark-base-dashboard-arrange.md) |
## 典型场景工作流
@@ -151,6 +152,7 @@ lark-cli base +dashboard-arrange \
- 想看仪表盘整体结构(含主题、所有组件名称和类型)→ 用 **方式 A**
- 只想快速查看有哪些组件 → 用 **方式 B**
- 想看某个组件的详细 data_config 配置 → 用 **方式 C**
- 想看某个图表/指标卡实际算出来的数据 → 用 **方式 D**
```bash
# 第 1 步:列出仪表盘,定位到当前仪表盘
@@ -167,6 +169,9 @@ lark-cli base +dashboard-block-list --base-token xxx --dashboard-id blk_xxx
# 方式 C查看某个组件的详细配置
lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --block-id chtxxxxxxxx
# 方式 D查看某个图表组件的计算结果AI 友好的 chart protocol
lark-cli base +dashboard-block-get-data --base-token xxx --block-id chtxxxxxxxx
# 最后:把获取到的现状信息整理好告诉用户
```
@@ -223,6 +228,9 @@ A: 在「添加新组件」或「编辑组件」前查看已有组件可以:
- 避免重复创建相似的组件
- 参考已有组件的 data_config 结构作为模板
**Q: 我想直接拿图表算好的结果给 AI 分析,应该用什么?**
A: 用 `+dashboard-block-get-data`。它直接返回图表协议 JSON`dimensions / measures / main_data`,指标卡还可能有 `comparison_data / trend_data`),适合让 AI 做后续总结、比对和解释。注意它不返回 block 名称、类型、layout、data_config 等元数据;如果你还需要这些信息,先用 `+dashboard-block-get`
## 命令详细文档
| CLI 命令 | 说明 | 详细文档 |
@@ -235,6 +243,7 @@ A: 在「添加新组件」或「编辑组件」前查看已有组件可以:
| `+dashboard-arrange` | 智能重排布局 | [lark-base-dashboard-arrange.md](lark-base-dashboard-arrange.md) |
| `+dashboard-block-list` | 列出组件 | [lark-base-dashboard-block-list.md](lark-base-dashboard-block-list.md) |
| `+dashboard-block-get` | 获取单个组件详情 | [lark-base-dashboard-block-get.md](lark-base-dashboard-block-get.md) |
| `+dashboard-block-get-data` | 获取图表组件计算结果 | [lark-base-dashboard-block-get-data.md](lark-base-dashboard-block-get-data.md) |
| `+dashboard-block-create` | 创建组件 | [lark-base-dashboard-block-create.md](lark-base-dashboard-block-create.md) |
| `+dashboard-block-update` | 更新组件 | [lark-base-dashboard-block-update.md](lark-base-dashboard-block-update.md) |
| `+dashboard-block-delete` | 删除组件 | [lark-base-dashboard-block-delete.md](lark-base-dashboard-block-delete.md) |

View File

@@ -56,6 +56,7 @@
| 场景 | 步骤组合 | 示例 |
|------|---------|------|
| 新增触发+通知 | AddRecordTrigger → LarkMessageAction | [下方](#示例1-新增记录触发--发送消息) |
| 按钮点击+调用外部接口+写入日志 | ButtonTrigger → HTTPClientAction → AddRecordAction | [下方](#示例-6-按钮触发--调用外部接口--写入同步日志) |
| 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例2-定时触发--查找记录--循环遍历--发送消息) |
| 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例3-条件分支-ifelsebranch) |
| 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例4-多路分支-switchbranch) |
@@ -628,6 +629,119 @@
---
### 示例 6: 按钮触发 + 调用外部接口 + 写入同步日志
**场景**: 在「客户线索表」里给每条记录配置一个“同步到 CRM”按钮。销售点击按钮后Workflow 调用外部 CRM 接口同步当前线索,再在「同步日志表」新增一条记录,方便后续审计和排查。
```json
{
"client_token": "1704067206",
"title": "线索一键同步到 CRM",
"steps": [
{
"id": "step_button_trigger",
"type": "ButtonTrigger",
"title": "点击同步到 CRM 按钮时触发",
"next": "step_call_crm_api",
"data": {
"button_type": "buttonField",
"table_name": "客户线索表"
}
},
{
"id": "step_call_crm_api",
"type": "HTTPClientAction",
"title": "调用 CRM 同步接口",
"next": "step_add_sync_log",
"data": {
"method": "POST",
"url": [
{ "value_type": "text", "value": "https://api.example-crm.com/v1/leads/sync" }
],
"headers": [
{ "key": "Content-Type", "value": [{ "value_type": "text", "value": "application/json" }] },
{ "key": "X-System", "value": [{ "value_type": "text", "value": "lark_base_workflow" }] }
],
"body_type": "raw",
"raw_body": [
{ "value_type": "text", "value": "{\"lead_name\":\"" },
{ "value_type": "ref", "value": "$.step_button_trigger.fldLeadName" },
{ "value_type": "text", "value": "\",\"mobile\":\"" },
{ "value_type": "ref", "value": "$.step_button_trigger.fldMobile" },
{ "value_type": "text", "value": "\",\"company\":\"" },
{ "value_type": "ref", "value": "$.step_button_trigger.fldCompany" },
{ "value_type": "text", "value": "\",\"owner\":\"" },
{ "value_type": "ref", "value": "$.step_button_trigger.fldOwner" },
{ "value_type": "text", "value": "\",\"source_record_id\":\"" },
{ "value_type": "ref", "value": "$.step_button_trigger.recordId" },
{ "value_type": "text", "value": "\"}" }
],
"response_type": "json",
"response_value": "{\"success\":true,\"message\":\"lead synced successfully\"}"
}
},
{
"id": "step_add_sync_log",
"type": "AddRecordAction",
"title": "写入同步日志",
"next": null,
"data": {
"table_name": "同步日志表",
"field_values": [
{
"field_name": "线索名称",
"value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldLeadName" }]
},
{
"field_name": "手机号",
"value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldMobile" }]
},
{
"field_name": "公司名称",
"value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldCompany" }]
},
{
"field_name": "负责人",
"value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldOwner" }]
},
{
"field_name": "来源记录ID",
"value": [{ "value_type": "ref", "value": "$.step_button_trigger.recordId" }]
},
{
"field_name": "同步状态",
"value": [{ "value_type": "text", "value": "已提交 CRM 同步" }]
},
{
"field_name": "同步是否成功",
"value": [{ "value_type": "ref", "value": "$.step_call_crm_api.body.success" }]
},
{
"field_name": "同步结果说明",
"value": [{ "value_type": "ref", "value": "$.step_call_crm_api.body.message" }]
},
{
"field_name": "备注",
"value": [{ "value_type": "text", "value": "由按钮触发自动发起同步请求" }]
}
]
}
}
]
}
```
**关键点**:
- `ButtonTrigger` 适合“人工确认后再执行”的场景,比如同步 CRM、推送 ERP、发起审批等
- `button_type: "buttonField"` 表示按钮挂在记录上,因此可以直接引用当前记录的字段和值
- `HTTPClientAction.raw_body` 可以通过 `text + ref + text` 的方式动态拼接 JSON 请求体
- `HTTPClientAction` 的输出引用规则是:`response_type=none` 时不可引用;`response_type=text` 时只能用 `$.stepId` 引整个文本;`response_type=json` 时用 `$.stepId.body` 引整个 body、用 `$.stepId.body.字段名` 引 body 中字段,同时 `$.stepId.status_code` 表示 HTTP 返回状态码
- `HTTPClientAction.response_value` 中声明了哪些字段,后续节点就只能引用这些字段;例如 `$.step_call_crm_api.body.success``$.step_call_crm_api.body.message`
- `AddRecordAction` 常用于写日志表、操作审计表、同步结果表,便于追踪谁在什么时候触发了外部调用
- 示例里的 `fldLeadName` / `fldMobile` / `fldCompany` / `fldOwner` 只是占位的 fieldId请以实际表字段 ID 为准
---
## 构造技巧
### Loop 构造要点

View File

@@ -98,6 +98,7 @@
| `ChangeRecordTrigger` | 记录满足条件时触发 |
| `TimerTrigger` | 定时触发 |
| `ReminderTrigger` | 日期提醒触发 |
| `ButtonTrigger` | 按钮点击触发 |
| `LarkMessageTrigger` | 接收飞书消息触发 |
> 所有 Trigger 节点**请勿设置** `children` ,通过 `next` 串联后继。
@@ -120,6 +121,7 @@
| `AddRecordAction` | 新增记录 |
| `SetRecordAction` | 更新记录 |
| `FindRecordAction` | 查找记录 |
| `HTTPClientAction` | HTTP 请求 |
| `Delay` | 延迟 |
| `LarkMessageAction` | 发送飞书消息 |
| `GenerateAiTextAction` | AI 生成文本 |
@@ -256,6 +258,23 @@
| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 |
### ButtonTrigger
```json
{
"button_type": "buttonField",
"table_name": "审批表"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `button_type` | 是 | 按钮类型:`buttonField`(表格里的按钮,可操作当前记录数据)/ `buttonElement`(仪表盘、应用页面上的按钮,可执行整体操作) |
| `table_name` | 否 | 绑定的数据表名,仅 `button_type=buttonField` 时填写 |
> `buttonField` 和 `buttonElement` 的输出能力不同详见下方「ButtonTrigger按钮触发器」输出说明。
### LarkMessageTrigger
```json
@@ -351,6 +370,48 @@
| `filter_info` | 否* | RecordFilterInfo`ref_info` 互斥) |
| `ref_info` | 否* | RefInfo`filter_info` 互斥) |
### HTTPClientAction
```json
{
"method": "POST",
"url": [{ "value_type": "text", "value": "https://api.example.com/webhook" }],
"queries": [
{ "key": "source", "value": [{ "value_type": "text", "value": "workflow" }] }
],
"headers": [
{ "key": "Content-Type", "value": [{ "value_type": "text", "value": "application/json" }] }
],
"body_type": "raw",
"raw_body": [
{ "value_type": "text", "value": "{\"record_id\":\"" },
{ "value_type": "ref", "value": "$.step_1.recordId" },
{ "value_type": "text", "value": "\"}" }
],
"response_type": "json",
"response_value": "{\"success\":true,\"message\":\"data fetched successfully\"}"
}
```
| 字段 | 必填 | 说明 |
|------|-----|------|
| `method` | 否 | 请求方法:`GET` / `POST` / `PUT` / `PATCH` / `DELETE`,默认 `POST` |
| `url` | 是 | ValueInfo[],请求 URL支持 `text` / `ref` 拼接 |
| `queries` | 否 | KeyValue[],查询参数 |
| `headers` | 否 | KeyValue[],请求头 |
| `body_type` | 否 | 请求体类型:`none` / `raw` / `form-data` / `form-urlencoded`,默认 `raw` |
| `raw_body` | 否 | ValueInfo[],原始请求体,仅 `body_type=raw` 时使用 |
| `form_body` | 否 | KeyValue[],表单数据,仅 `body_type=form-data``body_type=form-urlencoded` 时使用 |
| `response_type` | 否 | 响应类型:`none` / `text` / `json`,默认 `json` |
| `response_value` | 否 | stringJSON 字符串形式的响应结果示例;仅当 `response_type=json` 时必填 |
`KeyValue`
| 字段 | 类型 | 说明 |
|------|------|------|
| `key` | string | 参数名 / 请求头名 |
| `value` | ValueInfo[] | 参数值 / 请求头值,支持 `text` / `ref` |
### Delay
```json
@@ -564,8 +625,8 @@ $.{stepId}.{pathId}.{childPathId}.{grandChildPathId}
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `{fieldId}` | 字段id从配置表的所有字段或者指定字段id生成可下钻字段属性 | `$.{stepId}.{fieldId}` |
| `{fieldId}.fieldId` | 字段id属性 | `$.{stepId}.{fieldId}.fieldId}` |
| `{fieldId}.fieldName` | 字段名属性 | `$.{stepId}.{fieldId}.fieldName}` |
| `{fieldId}.fieldId` | 字段id属性 | `$.{stepId}.{fieldId}.fieldId` |
| `{fieldId}.fieldName` | 字段名属性 | `$.{stepId}.{fieldId}.fieldName` |
| `startTime` | 触发时间戳 | `$.{stepId}.startTime` |
| `recordId` | 记录 ID | `$.{stepId}.recordId` |
| `recordLink` | 记录链接 | `$.{stepId}.recordLink` |
@@ -583,6 +644,34 @@ $.{stepId}.{pathId}.{childPathId}.{grandChildPathId}
**recordLink 的 children**:如果配置了数据表,则为该表所有视图的列表,每个视图 `{ pathId: viewId, pathName: viewName, pathType: 'string' }`。引用示例:`$.{stepId}.recordLink.{viewId}`
##### ButtonTrigger按钮触发器
`ButtonTrigger` 的输出取决于 `button_type`
#### `button_type = buttonField`
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `{fieldId}` | 字段id从配置表的所有字段或者指定字段id生成可下钻字段属性 | `$.{stepId}.{fieldId}` |
| `{fieldId}.fieldId` | 字段id属性 | `$.{stepId}.{fieldId}.fieldId` |
| `{fieldId}.fieldName` | 字段名属性 | `$.{stepId}.{fieldId}.fieldName` |
| `recordId` | 记录 ID | `$.{stepId}.recordId` |
| `recordLink` | 记录链接 | `$.{stepId}.recordLink` |
| `recordCreatedUser` | 记录创建者 | `$.{stepId}.recordCreatedUser` |
| `recordModifiedUser` | 最后修改者 | `$.{stepId}.recordModifiedUser` |
| `recordModifiedTime` | 最后修改时间 | `$.{stepId}.recordModifiedTime` |
| `time` | 触发时间 | `$.{stepId}.time` |
| `user` | 触发人 | `$.{stepId}.user` |
| `buttonName` | 触发的按钮名称 | `$.{stepId}.buttonName` |
#### `button_type = buttonElement`
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `time` | 触发时间 | `$.{stepId}.time` |
| `user` | 触发人 | `$.{stepId}.user` |
| `buttonName` | 触发的按钮名称 | `$.{stepId}.buttonName` |
##### TimerTrigger定时触发器
| pathId | 说明 | 引用示例 |
@@ -633,8 +722,8 @@ $.{stepId}.{pathId}.{childPathId}.{grandChildPathId}
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `{fieldId}` | 用户配置的字段值,可下钻字段属性 | `$.{stepId}.{fieldId}` |
| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId}` |
| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName}` |
| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId` |
| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName` |
| `recordId` | 新增的记录 ID | `$.{stepId}.recordId` |
| `recordLink` | 新增的记录 URL | `$.{stepId}.recordLink` |
@@ -643,10 +732,56 @@ $.{stepId}.{pathId}.{childPathId}.{grandChildPathId}
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `{fieldId}` | 用户配置的字段值,可下钻字段属性 | `$.{stepId}.{fieldId}` |
| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId}` |
| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName}` |
| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId` |
| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName` |
| `recordId` | 记录 ID 数组(因可能更新多条记录) | `$.{stepId}.recordId` |
##### HTTPClientActionHTTP 请求)
HTTPClientAction 的输出取决于 `response_type`
| response_type | 是否可引用 | 输出说明 | 引用示例 |
|--------------|-----------|----------|----------|
| `none` | 否 | 无任何可引用输出 | 不支持引用 |
| `text` | 是 | 整个响应文本作为节点整体输出 | `$.{stepId}` |
| `json` | 是 | 响应体整体挂在 `body` 下,同时返回 `status_code`;仅可引用 `response_value` 中声明的字段 | `$.{stepId}.body``$.{stepId}.body.success``$.{stepId}.body.message``$.{stepId}.status_code` |
**补充说明**
-`response_type = none` 时,后续节点无法引用 HTTPClientAction 的任何输出
-`response_type = text` 时,`$.{stepId}` 表示整个响应文本
-`response_type = json` 时,`$.{stepId}.body` 表示整个 JSON body`$.{stepId}.body.字段名` 表示 body 中某个字段
- 仅当 `response_type = json` 时,`$.{stepId}.status_code` 表示请求该 HTTP URL 后返回的 HTTP 状态码
- 仅当 `response_type = json` 时,`response_value` 必填
-`response_type = json` 时,后续节点只能引用 `response_value` 中声明过的字段
**案例**
假设某个 `HTTPClientAction` 的配置如下:
```json
{
"id": "step_http_1",
"type": "HTTPClientAction",
"data": {
"response_type": "json",
"response_value": "{\"success\":true,\"message\":\"ok\"}"
}
}
```
则后续节点仅可以引用:
- `$.step_http_1.body`
- `$.step_http_1.body.success`
- `$.step_http_1.body.message`
- `$.step_http_1.status_code`
但**不能**引用未在 `response_value` 中声明的字段,例如:
- `$.step_http_1.body.data`
- `$.step_http_1.body.request_id`
##### GenerateAiTextActionAI 生成文本)
| pathId | 说明 | 引用示例 |
@@ -744,11 +879,13 @@ $.{stepId}.{fieldId}.fileToken → 文件 Token 列表array<string>,仅
| ChangeRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) |
| SetRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) |
| ReminderTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) |
| ButtonTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性buttonElement 仅基础触发属性) |
| TimerTrigger | 触发器 | ✅ | 静态(仅 scheduleTime |
| LarkMessageTrigger | 触发器 | ✅ | 静态(消息属性列表) |
| FindRecordAction | 动作 | ✅ | 动态(用户选择的字段) |
| AddRecordAction | 动作 | ✅ | 动态(用户配置的字段) |
| SetRecordAction | 动作 | ✅ | 动态(用户配置的字段) |
| HTTPClientAction | 动作 | ✅ | 动态(取决于用户配置的 HTTP 响应输出) |
| GenerateAiTextAction | 动作 | ✅ | 静态(单 string |
| Delay | 动作 | ❌ | 无输出 |
| LarkMessageAction | 动作 | ❌ | 无输出 |

View File

@@ -151,7 +151,7 @@ PUT /open-apis/base/v3/bases/:base_token/workflows/:workflow_id
## 坑点
- ⚠️ **PUT 是全量覆盖**:传什么就写什么;如果只传 `title` 不传 `steps`,原有 steps 会被清空;如需只改标题,使用 PATCH 接口(目前无对应 shortcut可参考 API 文档直接调用)
- ⚠️ **PUT 是全量覆盖**:传什么就写什么;如果只传 `title` 不传 `steps`,原有 steps 会被清空
- ⚠️ **workflow_id 前缀**:以 `wkf` 开头,从 URL 的 `?table=wkf...` 提取;和 table_id`tbl` 开头)混淆会导致 `[2200] Internal Error`
- ⚠️ **steps 中 id 字段必须唯一**:每个步骤的 `id` 在同一工作流内必须唯一;`next``children.links[].to` 引用的 ID 必须在 steps 数组中存在
- ⚠️ **更新不影响 enabled 状态**`+workflow-update` 不会改变工作流的 `enabled/disabled` 状态;需要另外调用 `+workflow-enable` / `+workflow-disable`

View File

@@ -143,5 +143,5 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
| Topic | Reference | Coverage |
|---|---|---|
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 11 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender) |
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 1 VC EventKey (`vc.meeting.participant_meeting_ended_v1`) + field reference + time conversion gotchas (unix seconds → local RFC3339) |
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + enrichment & degradation semantics (minute detail API fills `title`; `minute_source` from event payload survives enrichment failure) |
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 2 VC EventKeys (`vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) |

View File

@@ -2,21 +2,23 @@
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
## Key catalog (1)
## Key catalog (2)
| EventKey | Purpose |
|---|---|
| `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended |
| `vc.note.generated_v1` | A note has been generated (meeting, recording, upload, etc.) |
This key uses a **Custom schema** (flat output at `.xxx`) and carries a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer.
Both keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. Both require `--as user`.
## Scopes & auth
| EventKey | Scope | Auth |
|---|---|---|
| `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user |
| `vc.note.generated_v1` | `vc:note:read` | user |
Requires `--as user`.
---
## `vc.meeting.participant_meeting_ended_v1`
@@ -48,3 +50,45 @@ lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user
lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user \
--jq '{meeting: .meeting_id, topic: .topic, ended: .end_time}'
```
---
## `vc.note.generated_v1`
Fires when a note is generated — not just from meetings, but also from realtime recordings and local file uploads.
### Output fields
| Field | Type | Description |
|---|---|---|
| `type` | string | Event type; always `vc.note.generated_v1` |
| `event_id` | string | Globally unique event ID; safe for deduplication |
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
| `note_id` | string | Note ID |
| `note_token` | string | Note document token; may be empty if detail is not yet available |
| `verbatim_token` | string | Verbatim document token; may be empty if detail is not yet available |
| `note_source` | object | Source metadata; only present when source is a meeting |
| `note_source.source_type` | string | Source type; only present when source is a meeting (value: `meeting`) |
| `note_source.source_entity_id` | string | Source entity ID (meeting ID); only present when source is a meeting |
### Source type semantics
| `source_type` | Trigger |
|---|---|
| `meeting` | Note generated from a meeting |
`note_source` (and its sub-fields) are only populated when `source_type` is `meeting`. For other sources the field is absent.
### Example
```bash
lark-cli event consume vc.note.generated_v1 --as user
# Only notes with enriched tokens, skip incomplete ones
lark-cli event consume vc.note.generated_v1 --as user \
--jq 'select(.note_token != "") | {note_id, note_token, verbatim_token}'
# Filter to meeting-sourced notes only
lark-cli event consume vc.note.generated_v1 --as user \
--jq 'select(.note_source.source_type == "meeting") | {note_id, meeting_id: .note_source.source_entity_id}'
```

View File

@@ -1,7 +1,7 @@
---
name: lark-im
version: 1.0.0
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据时使用。"
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -73,7 +73,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| Shortcut | 说明 |
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
| [`+chat-list`](references/lark-im-chat-list.md) | List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only) |
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
@@ -116,6 +116,9 @@ lark-cli im <resource> <method> [flags] # 调用 API
- `forward` — 转发消息。Identity: supports `user` and `bot`.
- `merge_forward` — 合并转发消息。Identity: `bot` only (`tenant_access_token`).
- `read_users` — 查询消息已读信息。Identity: `bot` only (`tenant_access_token`); the bot must be in the chat, and can only query read status for messages it sent within the last 7 days.
- `urgent_app` — 发送应用内加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
- `urgent_phone` — 发送电话加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
- `urgent_sms` — 发送短信加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
### reactions
@@ -154,11 +157,14 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `messages.forward` | `im:message` |
| `messages.merge_forward` | `im:message` |
| `messages.read_users` | `im:message:readonly` |
| `threads.forward` | `im:message` |
| `messages.urgent_app` | `im:message.urgent` |
| `messages.urgent_phone` | `im:message.urgent:phone` |
| `messages.urgent_sms` | `im:message.urgent:sms` |
| `reactions.batch_query` | `im:message.reactions:read` |
| `reactions.create` | `im:message.reactions:write_only` |
| `reactions.delete` | `im:message.reactions:write_only` |
| `reactions.list` | `im:message.reactions:read` |
| `threads.forward` | `im:message` |
| `images.create` | `im:resource` |
| `pins.create` | `im:message.pins:write_only` |
| `pins.delete` | `im:message.pins:write_only` |

View File

@@ -2,7 +2,9 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
List groups the current user (or bot, with `--as bot`) is a member of. Useful for enumerating "my chats" without a search keyword, or for bulk operations against the caller's chats. Supports pagination, sort order, and (user identity only) muted-chat filtering.
List chats the current user (or bot, with `--as bot`) is a member of. **Not a search API — there is no `--query` parameter; the call always returns the full member list, paginated.** For keyword-based lookup (e.g. find a group by name or by member), use [`+chat-search`](lark-im-chat-search.md) instead.
**Defaults to groups only**; pass `--types=p2p,group` (or `--types p2p --types group`) to also include p2p single chats (user identity only — see ["Bot identity and p2p"](#bot-identity-and-p2p)). Supports pagination, sort order, and (user identity only) muted-chat filtering.
This skill maps to the shortcut: `lark-cli im +chat-list` (internally calls `GET /open-apis/im/v1/chats`).
@@ -29,6 +31,15 @@ lark-cli im +chat-list --format json
# Preview the request without executing it
lark-cli im +chat-list --dry-run
# Include p2p single chats (user identity only) — comma form
lark-cli im +chat-list --as user --types p2p,group
# Same, using repeat flag instead of CSV
lark-cli im +chat-list --as user --types p2p --types group
# Only p2p single chats (user identity only)
lark-cli im +chat-list --as user --types p2p
```
## Parameters
@@ -36,6 +47,7 @@ lark-cli im +chat-list --dry-run
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--user-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response |
| `--types <strings>` | No | `group`, `p2p` (comma-separated or repeated) | Chat types to include. Omitted = groups only (backward compatible). `p2p` requires user identity (`--as user`); under `--as bot`, `--types=p2p` alone is rejected and `--types=p2p,group` is silently downgraded to `group` |
| `--sort-type <type>` | No | `ByCreateTimeAsc` (default), `ByActiveTimeDesc` | Result ordering |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
@@ -55,6 +67,44 @@ lark-cli im +chat-list --dry-run
| `owner_id` | Owner ID (type controlled by `--user-id-type`) |
| `external` | Whether the chat is external |
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
| `chat_mode` | Chat mode discriminator: `group` (regular) / `topic` (topic group) / `p2p` (single chat) |
| `p2p_target_type` | Peer type, e.g., `user` |
| `p2p_target_id` | Peer ID (type controlled by `--user-id-type`) |
## Including p2p single chats
Default behavior lists groups only — same as before this feature. To include p2p, pass `--types`:
| User intent | Call | Identity |
|---|---|---|
| "list my groups" / 我的群 / 我加入了哪些群 | (default, omit `--types`) | user or bot |
| "list my p2p chats" / 我的单聊 / 我跟谁有 1v1 | `--types p2p` | **user only** |
| "all my chats" / 全部聊天 / 所有会话 (ambiguous) | `--types p2p,group` | **user only** |
For p2p rows in the response: `name` is the peer's display name, `owner_id` follows group semantics, `chat_mode = "p2p"`, and `p2p_target_type` / `p2p_target_id` identify the peer.
## Bot identity and p2p
`tenant_access_token` cannot list p2p chats — to protect user privacy, bot identity is not permitted to enumerate p2p single chats. Behavior under `--as bot`:
- `--as bot --types=p2p` → rejected at validation time with an actionable error; no request is sent.
- `--as bot --types=p2p,group` → CLI strips `p2p` and sends `types=group`. Request proceeds; only groups are returned. The strip is a **request-level adjustment**, surfaced two ways so neither humans nor agents miss it:
- **stderr**: `warning: bot_strip_p2p: To protect user privacy, bot identity cannot list p2p chats; --types=p2p,group was sent as types=group. Use --as user to include p2p.` (matches the `warning: <code>: <message>` convention in `shortcuts/common/runner.go`)
- **stdout JSON**: a top-level `notices` array gains a structured entry:
```json
{
"chats": [...],
"notices": [
{ "code": "bot_strip_p2p", "message": "To protect user privacy, bot identity cannot list p2p chats; …" }
]
}
```
- The `filter` slot stays scoped to `--exclude-muted`; `notices` is a separate top-level key, so the two never collide and no priority is needed when both fire.
- DryRun emits the same stderr warning so a previewed request truthfully reflects what Execute will send (parity with `shortcuts/drive/drive_search.go`).
- `--as bot --types=group` → accepted, returns groups normally.
- `--as bot` (no `--types`) → unchanged, returns groups.
To include p2p single chats, switch to user identity: `--as user --types=p2p,group`.
## Filtering muted chats
@@ -111,3 +161,6 @@ done
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
| `--exclude-muted` returns all chats unfiltered and `hint` says "no effect under bot identity" | Running under `--as bot` (mute API is UAT-only) | Switch to `--as user` for mute filtering |
| `--types=p2p (single chats) is only supported with user identity` | `--as bot` + `--types=p2p` (single-value only; mixed `--types=p2p,group` is downgraded to `group` and surfaces a `bot_strip_p2p` notice via stderr + `outData["notices"]` — see "Bot identity and p2p") | Use `--as user`, or include `group` in `--types` (the bot proceeds with `group` only and emits the `bot_strip_p2p` notice) |
> Full error message of the row above: `--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`

View File

@@ -4,10 +4,21 @@
This is the single source of truth for the automatic message-enrichment contract shared by the four message-pulling shortcuts — [`+messages-mget`](lark-im-messages-mget.md), [`+chat-messages-list`](lark-im-chat-messages-list.md), [`+messages-search`](lark-im-messages-search.md), [`+threads-messages-list`](lark-im-threads-messages-list.md). They automatically attach `reactions` and `update_time` to each returned message, so callers do **not** need to invoke the raw [`im.reactions.batch_query`](lark-im-reactions.md) API separately.
- **`reactions`** — populated from one batched `im.reactions.batch_query` call as `{counts, details}`. The field is only attached when the server actually returns data; messages with no reactions omit it. Replies inside `thread_replies` are enriched in the **same batched call** as their parent, so outer and inner messages follow identical semantics.
- **`reactions`** — populated from `im.reactions.batch_query` as `{counts, details}`. The field is only attached when the server actually returns data; messages with no reactions omit it. Replies inside `thread_replies` are enriched alongside their parent (collected into the same id set), so outer and inner messages follow identical semantics. The id set is split into batches of <= 20 (server-side cap) and the batches are dispatched with bounded concurrency (up to 4 in flight), so high-N pulls — e.g. page 50 + ~500 expanded thread replies = 550 ids → ⌈550 / 20⌉ = **28 batches** — finish in a few round-trips instead of serializing into tens of seconds.
- **`update_time`** — emitted only when `updated == true` (message was actually edited). The server echoes `update_time == create_time` for unedited messages too, but the CLI gates that output away so consumers don't misread every message as "edited".
- **Opt-out** — each shortcut accepts `--no-reactions` to skip the extra round-trip when the caller only needs message bodies.
## Thread replies expansion
`+messages-mget` and `+chat-messages-list` also auto-expand thread replies: any returned message that carries a `thread_id` triggers a fetch of that thread's replies, which are attached as a `thread_replies` array on the host. Fetches across distinct threads run with bounded concurrency (up to 4 in flight). Two caps gate the result:
- **`perThread` (default 50)** — max replies fetched for any single thread.
- **`totalLimit` (default 500)** — max cumulative replies across all threads on the page.
`totalLimit` is enforced **post-fetch against actual returned reply counts**, not against the planned per-thread ceiling — so a chat with many short threads (e.g. 12 threads × 3 actual replies = 36 ≪ 500) attaches every thread, even though the planned sum (12 × 50 = 600) would exceed the budget. When a thread's actual replies push the running total across `totalLimit`, that thread is truncated to fit the remaining budget and its host is flagged with `thread_has_more: true` so consumers know the server has more.
On per-thread fetch failure the host gets `thread_replies_error: true` (mirrors the reactions data contract); budget-truncated or budget-skipped threads do NOT carry that flag.
## Scope requirement
The default enrichment requires `im:message.reactions:read`, already declared in each shortcut's `UserScopes` / `BotScopes` (or `Scopes` for the user-only search command), so the framework's pre-flight check surfaces a `missing_scope` error before the request is sent. Bots that were registered before this scope was added need an incremental authorization in the Feishu developer console; users can run:

View File

@@ -12,6 +12,8 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
## 核心概念
- **邮件Message**:一封具体的邮件,包含发件人、收件人、主题、正文(纯文本/HTML、附件。每封邮件有唯一 `message_id`
@@ -99,9 +101,10 @@ metadata:
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
8. **编辑草稿**`+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
9. **已读回执**
7. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op已内置 autofix普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
8. **确认投递**立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
9. **编辑草稿** `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
10. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -336,6 +339,12 @@ lark-cli mail +send --to alice@example.com --subject '周报' \
lark-cli mail +reply --message-id <id> --body '收到,谢谢'
```
## 邮件书写规范
- 写信时**必须**遵守 [邮件 HTML 写法规范](references/lark-mail-html.md) — **CRITICAL** 飞书邮箱已验证的最纯净美观写法集合
- [`+lint-html` 用法](references/lark-mail-lint-html.md) — 创建草稿前自检 / 修复 HTML 输出
- **官方模板库** [`assets/templates/`](assets/templates/) — 提供部分场景模板,可供参考
### 读取邮件:按需控制返回内容
`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。
@@ -354,6 +363,8 @@ lark-cli mail +message --message-id <id>
模板的创建 / 更新由专用 shortcut 处理(自动做 Drive 上传 + `<img src>` 改写成 `cid:`);发信类 shortcut 通过 `--template-id <id>` 套用模板。
> **跟仓库 `assets/templates/` 的区别**:本节讲的是**飞书 OAPI 的个人邮件模板系统**(用户邮箱里的"我的模板"),可在飞书客户端管理;上面"仓库内置 HTML 模板库"是 lark-cli 仓库里预制的飞书原生 HTML 文件,可供写信参考。
**管理模板**
- [`+template-create`](references/lark-mail-template-create.md) — 创建新模板。`--name` 必填;正文通过 `--template-content` 或 `--template-content-file` 二选一;支持 HTML 内嵌图片自动上传到 Drive。
@@ -472,6 +483,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`
| [`+share-to-chat`](references/lark-mail-share-to-chat.md) | Share an email or thread as a card to a Lark IM chat. |
| [`+template-create`](references/lark-mail-template-create.md) | Create a personal mail template. Scans HTML <img src> local paths (reusing draft inline-image detection), uploads inline images and non-inline attachments to Drive, rewrites HTML to cid: references, and POSTs a Template payload to mail.user_mailbox.templates.create. |
| [`+template-update`](references/lark-mail-template-update.md) | Update an existing mail template. Supports --inspect (read-only projection), --print-patch-template (prints a JSON skeleton for --patch-file), and flat flags (--set-subject / --set-name / etc). Internally it GETs the template, applies the patch, rewrites <img> local paths to cid: refs, and PUTs a full-replace update (no optimistic locking: last-write-wins). |
| [`+lint-html`](references/lark-mail-lint-html.md) | Lint mail HTML body for compatibility / safety / Feishu-native rules. Returns warnings/errors and (default) auto-fixed HTML. Read-only: no draft, no API call. Use this BEFORE creating a draft to preview what the writing-path lint would change, or as a CI gate for static HTML templates. |
## API Resources

View File

@@ -0,0 +1,33 @@
<!--
SUBJECT 模板lark-cli mail --subject 用):
应聘 [期望职位] · [姓名]
-->
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[招聘负责人称呼,如 HR / 团队负责人 / 招聘组],您好:</span></span></div></div>
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">我是 [姓名],关注到贵司 [期望职位] 岗位,结合 [简短亮点:领域 / 经验 / 项目] 投递简历,期待进一步沟通。</span></span></div></div>
<div style="margin-top:24px;margin-bottom:8px;line-height:1.6"><div dir="auto" style="text-align:center;font-size:14px"><b><span style="font-size:22px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(36,91,219)">[姓名]</span></span></span></b></div></div>
<div style="margin-top:0px;margin-bottom:20px;line-height:1.6"><div dir="auto" style="text-align:center;font-size:14px"><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:14px">应聘 [期望职位][工作年限] 工作经验</span></span></div></div>
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">基本信息</span></span></span></b></div></div>
<table style="border-collapse:collapse;width:100%;font-size:13px"><tbody><tr><td style="padding:4px 8px 4px 0;width:18%;color:rgb(143,149,158);vertical-align:top">姓名</td><td style="padding:4px 8px 4px 0;width:32%;color:rgb(31,35,41);vertical-align:top">[姓名]</td><td style="padding:4px 8px 4px 0;width:18%;color:rgb(143,149,158);vertical-align:top">性别</td><td style="padding:4px 0;width:32%;color:rgb(31,35,41);vertical-align:top">[性别]</td></tr><tr><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">电话</td><td style="padding:4px 8px 4px 0;color:rgb(31,35,41);vertical-align:top">[+86 1XX-XXXX-XXXX]</td><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">邮箱</td><td style="padding:4px 0;color:rgb(31,35,41);vertical-align:top"><a class="not-doclink" href="mailto:[your@email]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">[your@email]</a></td></tr><tr><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">生日</td><td style="padding:4px 8px 4px 0;color:rgb(31,35,41);vertical-align:top">[YYYY-MM-DD][N] 岁)</td><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">工作年限</td><td style="padding:4px 0;color:rgb(31,35,41);vertical-align:top">[N] 年</td></tr><tr><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">家乡</td><td style="padding:4px 8px 4px 0;color:rgb(31,35,41);vertical-align:top">[城市]</td><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">当前城市</td><td style="padding:4px 0;color:rgb(31,35,41);vertical-align:top">[城市]</td></tr><tr><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">意向城市</td><td style="padding:4px 8px 4px 0;color:rgb(31,35,41);vertical-align:top">[城市 1] / [城市 2] / 不限</td><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">期望职位</td><td style="padding:4px 0;color:rgb(31,35,41);vertical-align:top">[期望职位]</td></tr></tbody></table>
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">教育经历</span></span></span></b></div></div>
<ol data-list-number="true" style="margin:0px;padding-left:0px;list-style-position:inside"><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="edu" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[学校名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [学历,如 本科 / 硕士 / 博士] · [专业]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM]</span></span></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="edu" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[学校名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [学历] · [专业]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM]</span></span></li></ol>
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">工作经历</span></span></span></b></div></div>
<ol data-list-number="true" style="margin:0px;padding-left:0px;list-style-position:inside"><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="work" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[公司名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [职位] · [全职 / 实习 / 兼职]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM 或 至今]</span></span><ul data-list-bullet="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[工作职责描述 1聚焦动作 + 产出,附数据 / 影响范围]</span></span></li><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[工作职责描述 2核心成果 + 关键技术 / 方法]</span></span></li><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[工作职责描述 3]</span></span></li></ul></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="work" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[公司名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [职位] · [全职 / 实习 / 兼职]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM]</span></span><ul data-list-bullet="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[工作职责描述 1]</span></span></li><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[工作职责描述 2]</span></span></li></ul></li></ol>
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">项目经历</span></span></span></b></div></div>
<ol data-list-number="true" style="margin:0px;padding-left:0px;list-style-position:inside"><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="proj" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[项目名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [角色,如 负责人 / 核心开发 / 设计主导]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM]</span></span><ul data-list-bullet="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[项目背景 / 业务价值 1 句话]</span></span></li><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[关键贡献 / 技术栈]</span></span></li><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[项目成果 / 数据指标]</span></span></li></ul></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="proj" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[项目名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [角色]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM]</span></span><ul data-list-bullet="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[项目描述 + 关键贡献 + 成果数据]</span></span></li></ul></li></ol>
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">技能</span></span></span></b></div></div>
<div style="margin-top:8px;margin-bottom:4px;line-height:2"><div dir="auto" style="font-size:14px"><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 1]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 2]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 3]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 4]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 5]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 6]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 7]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 8]</span></div></div>
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">证书</span></span></span></b></div></div>
<ul style="margin-top:0px;margin-bottom:0px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[证书名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [一句话描述:颁发机构 / 等级 / 用途]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[证书名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [描述]</span></span></li></ul>
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">语言能力</span></span></span></b></div></div>
<ul style="margin-top:0px;margin-bottom:0px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[语言,如 中文 / 英文 / 日语]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [精通程度:母语 / 流利 / 商务 / 日常 / 入门]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[语言]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [精通程度] / [证书或考试成绩,如 CET-6 590、TOEFL 105、JLPT N1]</span></span></li></ul>
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">竞赛信息</span></span></span></b></div></div>
<ul style="margin-top:0px;margin-bottom:0px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[竞赛名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [名次 / 角色 + 一句话描述]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[竞赛名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [描述]</span></span></li></ul>
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">获奖信息</span></span></span></b></div></div>
<ul style="margin-top:0px;margin-bottom:0px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[获奖名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [颁发机构 / 评选范围 + 一句话描述]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[获奖名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [描述]</span></span></li></ul>
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">自我评价</span></span></span></b></div></div>
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[2-3 句话简评:技术深度 / 协作风格 / 长期方向,与岗位要求高度契合的方向。建议聚焦"为什么我适合这个岗位",避免"努力踏实诚信"这种通用形容词。]</span></span></div></div>
<div style="margin-top:32px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">如需作品集 / 实习证明 / 推荐信等其它材料,欢迎进一步沟通面谈。期待您的回复。</span></span></div></div>
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">谢谢您的时间!</span></span></div></div>
<div style="margin-top:16px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">此致</span></span></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)"><b>[姓名]</b></span></span></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[+86 1XX-XXXX-XXXX]<a class="not-doclink" href="mailto:[your@email]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">[your@email]</a>[YYYY-MM-DD]</span></span></div></div>

View File

@@ -0,0 +1,50 @@
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="text-align:center;font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(143,149,158)">WEEKLY DIGEST · 资讯周报</span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="text-align:center;font-size:14px"><b><span style="font-size:24px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(36,91,219)">[YYYY 第 NN 周] 资讯周报</span></span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="text-align:center;font-size:14px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[团队 / 订阅源] · 编辑 [姓名] · 周期 [YYYY-MM-DD] ~ [YYYY-MM-DD]</span></span></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">本周共精选 <b><span style="color:rgb(36,91,219)">[N]</span></b> 条值得关注的信息,其中重点 <b><span style="color:rgb(216,57,49)">[M]</span></b> 条,覆盖 <b>行业动态 / 技术前沿 / 内部动态</b> 三个方向。下方为按主题归类的速读版,标题点开即原文。</span></span></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><br></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">本周关键词</span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px">
<span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;margin-right:6px;font-size:12px"><b>[关键词 1]</b></span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;margin-right:6px;font-size:12px"><b>[关键词 2]</b></span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;margin-right:6px;font-size:12px"><b>[关键词 3]</b></span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;margin-right:6px;font-size:12px"><b>[关键词 4]</b></span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;margin-right:6px;font-size:12px"><b>[关键词 5]</b></span>
</div></div>
<div style="margin-top:16px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-size:15px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(255,255,255)"><span style="background-color:rgb(36,91,219)">&nbsp; 行业动态 &nbsp;</span></span></span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[news-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">1. [行业资讯标题 1建议 ≤ 30 字]</a></span></span></b><span style="background-color:rgb(254,241,241);color:rgb(216,57,49);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:6px"><b>重点</b></span></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要 12-3 句话核心信息,介绍这条资讯讲了什么、为什么本周值得关注、与团队工作的关联]</span></span></div></div>
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[来源] · [发布日期] · </span></span><a class="not-doclink" href="https://[news-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看原文</a></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[news-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">2. [行业资讯标题 2]</a></span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要 2]</span></span></div></div>
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[来源] · [发布日期] · </span></span><a class="not-doclink" href="https://[news-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看原文</a></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[news-url-3]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">3. [行业资讯标题 3]</a></span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要 3]</span></span></div></div>
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[来源] · [发布日期] · </span></span><a class="not-doclink" href="https://[news-url-3]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看原文</a></div></div>
<div style="margin-top:16px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-size:15px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(255,255,255)"><span style="background-color:rgb(0,180,42)">&nbsp; 技术前沿 &nbsp;</span></span></span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[tech-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">4. [技术资讯标题 1]</a></span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要]</span></span></div></div>
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[来源] · [发布日期] · </span></span><a class="not-doclink" href="https://[tech-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看原文</a></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[tech-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">5. [技术资讯标题 2]</a></span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要]</span></span></div></div>
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[来源] · [发布日期] · </span></span><a class="not-doclink" href="https://[tech-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看原文</a></div></div>
<div style="margin-top:16px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-size:15px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(255,255,255)"><span style="background-color:rgb(124,77,255)">&nbsp; 内部动态 &nbsp;</span></span></span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[internal-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">6. [内部资讯标题 1]</a></span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要]</span></span></div></div>
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[团队 / 系统] · [发布日期] · </span></span><a class="not-doclink" href="https://[internal-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看详情</a></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[internal-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">7. [内部资讯标题 2]</a></span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要]</span></span></div></div>
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[团队 / 系统] · [发布日期] · </span></span><a class="not-doclink" href="https://[internal-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看详情</a></div></div>
<div style="margin-top:24px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><br></div></div>
<blockquote style="padding-left:0px;color:rgb(100,106,115);border-left:2px solid rgb(187,191,196);margin:0px"><div dir="auto" style="font-size:13px;padding-left:12px"><span style="font-family:inherit"><span style="color:rgb(100,106,115)"><b>本期编辑:</b>[姓名]<b>下期预告:</b>[下期重点话题或筹备信息]<b>反馈与投稿:</b>欢迎在 reply 中留言或邮件 <a class="not-doclink" href="mailto:[owner@example.com]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">[订阅 Owner]</a></span></span></div></blockquote>
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:11px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">订阅 / 退订请访问 <a class="not-doclink" href="https://[subscribe-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">订阅管理</a>。本周报为内部资讯整理,所有摘要均来自公开报道;不构成投资建议、不代表本团队立场。</span></span></div></div>

View File

@@ -0,0 +1,256 @@
<!--
=============================================================================
SUBJECT 模板lark-cli mail --subject 用):
[调研主题] 市场调研报告 ([YYYY-MM-DD])
字段说明:
· [调研主题]:调研对象赛道,例 "AI Mail Agent" / "向量数据库" / "前端构建工具"
· [YYYY-MM-DD]调研完成日期ISO 格式)
=============================================================================
-->
<style>
.research-root { font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width:800px; margin:0 auto; color:#1a1a1a; line-height:1.6; background-color:#f8f9fa; padding:20px; }
.gradient-header { background:linear-gradient(135deg, #1a73e8, #4285f4); border-radius:12px; padding:32px; color:white; text-align:center; }
.card { background-color:white; border-radius:8px; padding:20px; margin:16px 0; box-shadow:0 1px 3px rgba(0,0,0,0.1); }
.stat-row { display:flex; gap:10px; margin:16px 0; }
.stat-card { flex:1; background-color:white; border-radius:8px; padding:14px; text-align:center; box-shadow:0 1px 3px rgba(0,0,0,0.1); }
.player-row { display:flex; gap:12px; margin:0 0 12px; flex-wrap:wrap; }
.player-card { flex:1; min-width:200px; background-color:#f1f3f4; border-radius:6px; padding:14px; }
.callout-error { background-color:#fce8e6; border-left:4px solid #ea4335; padding:10px 14px; margin-top:12px; border-radius:0 4px 4px 0; font-size:12px; }
.tbl { width:100%; border-collapse:collapse; font-size:13px; }
.tbl th { padding:8px; text-align:left; border-bottom:2px solid #ddd; background-color:#f1f3f4; }
.tbl td { padding:8px; border-bottom:1px solid #eee; }
.tbl tr.alt td { background-color:#fafafa; }
.tbl-bug { width:100%; border-collapse:collapse; font-size:13px; }
.tbl-bug th { padding:10px; border-bottom:2px solid #ddd; background-color:#f1f3f4; text-align:left; }
.tbl-bug td { padding:10px; border-bottom:1px solid #eee; }
.tbl-bug tr.alt td { background-color:#fafafa; }
.badge-info { background-color:#e8f0fe; color:#1a73e8; padding:2px 8px; border-radius:4px; font-size:12px; white-space:nowrap; display:inline-block; }
.badge-success { background-color:#e6f4ea; color:#137333; padding:2px 8px; border-radius:4px; font-size:12px; white-space:nowrap; display:inline-block; }
.badge-warn { background-color:#fff3e0; color:#e65100; padding:2px 8px; border-radius:4px; font-size:12px; white-space:nowrap; display:inline-block; }
.badge-error { background-color:#fce8e6; color:#ea4335; padding:2px 8px; border-radius:4px; font-size:12px; white-space:nowrap; display:inline-block; }
.pri-p0 { background-color:#fce8e6; color:#c5221f; padding:2px 10px; border-radius:4px; font-size:11px; font-weight:600; white-space:nowrap; display:inline-block; }
.pri-p1 { background-color:#fff3e0; color:#b06000; padding:2px 10px; border-radius:4px; font-size:11px; font-weight:600; white-space:nowrap; display:inline-block; }
.pri-p2 { background-color:#e8f0fe; color:#185abc; padding:2px 10px; border-radius:4px; font-size:11px; font-weight:600; white-space:nowrap; display:inline-block; }
</style>
<div class="research-root">
<div class="gradient-header">
<h1 style="margin:0;font-size:24px;font-weight:600">[调研主题] 市场调研报告</h1>
<div style="margin:8px 0 0;font-size:14px">[YYYY-MM-DD] | 调研者:[姓名] · [团队] | [关联系统 / 版本]</div>
</div>
<div class="card">
<h2 style="margin:0 0 12px;font-size:16px;color:#555">调研背景</h2>
<div style="font-size:13px;margin:0">[一段话描述:本轮调研聚焦的赛道 / 行业背景 / 触发动机]。本轮调研覆盖 <b>[N] 类玩家</b>[类别 1] / [类别 2] / [类别 3] / [类别 4]),重点评估 [自家产品 / 团队] 在 [赛道名] 的位置、对外摩擦点,以及结合 [关联工作 / PR / 本期目标] 的待补能力。所有结论基于 [数据来源 1公开资料 / 厂商文档 / 行业报告] + [数据来源 2自有实测 / 内部调研笔记] + [数据来源 3访谈 / 体验]。</div>
</div>
<div class="stat-row">
<div class="stat-card">
<div style="font-size:26px;font-weight:700;color:#1a73e8">[N]</div>
<div style="font-size:11px;color:#666">调研对象</div>
</div>
<div class="stat-card">
<div style="font-size:26px;font-weight:700;color:#137333">[N]</div>
<div style="font-size:11px;color:#666">已就绪能力</div>
</div>
<div class="stat-card">
<div style="font-size:26px;font-weight:700;color:#fbbc04">[N]</div>
<div style="font-size:11px;color:#666">明确缺口</div>
</div>
<div class="stat-card">
<div style="font-size:26px;font-weight:700;color:#ea4335">[N]</div>
<div style="font-size:11px;color:#666">高优待办</div>
</div>
</div>
<div class="card">
<h2 style="margin:0 0 4px;font-size:16px">1. [章节标题:例 "全球市场态势"]</h2>
<div style="font-size:12px;color:#888;margin:0 0 12px">[一句话描述本节切分维度,例 "把市场按 '为谁设计' 切四象限"]</div>
<table class="tbl">
<thead><tr>
<th>玩家 / 对象</th>
<th>定位 / 类型</th>
<th style="text-align:center">[关键评分维度]</th>
<th>关键观察</th>
</tr></thead>
<tbody>
<tr>
<td>[玩家 1]</td>
<td>[类别]</td>
<td style="text-align:center"><span class="badge-info">[标签]</span></td>
<td>[一句话观察]</td>
</tr>
<tr class="alt">
<td>[玩家 2]</td>
<td>[类别]</td>
<td style="text-align:center"><span class="badge-success">[标签]</span></td>
<td>[一句话观察]</td>
</tr>
<tr>
<td>[玩家 3]</td>
<td>[类别]</td>
<td style="text-align:center"><span class="badge-warn">[标签]</span></td>
<td>[一句话观察]</td>
</tr>
<tr class="alt">
<td>[玩家 4]</td>
<td>[类别]</td>
<td style="text-align:center"><span class="badge-error">[标签]</span></td>
<td>[一句话观察]</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2 style="margin:0 0 4px;font-size:16px">2. [章节标题:例 "接入摩擦点"] <span class="badge-warn" style="vertical-align:middle;margin-left:8px">⚠️ 风险</span></h2>
<div style="font-size:12px;color:#888;margin:0 0 12px">[一句话描述:从哪里观察 / 案例 / 数据来源]</div>
<table class="tbl">
<thead><tr>
<th>摩擦类型 / 维度</th>
<th>具体表现</th>
<th>业务影响</th>
</tr></thead>
<tbody>
<tr>
<td><b>[摩擦 1]</b></td>
<td>[具体表现 / 案例]</td>
<td>[对业务 / 团队的影响]</td>
</tr>
<tr class="alt">
<td><b>[摩擦 2]</b></td>
<td>[具体表现]</td>
<td>[影响]</td>
</tr>
<tr>
<td><b>[摩擦 3]</b></td>
<td>[具体表现]</td>
<td>[影响]</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2 style="margin:0 0 12px;font-size:16px">3. [章节标题:例 "新势力玩家详情" / "重点对象详细比较"]</h2>
<div class="player-row">
<div class="player-card">
<div style="font-size:13px;font-weight:700;color:#1a73e8;margin-bottom:6px">[玩家 / 对象 1]</div>
<div style="font-size:12px;color:#444">[一句话产品定位 / 核心能力 / 差异化]</div>
<div style="font-size:11px;color:#888;margin-top:6px">关键差异:[一句话提炼]</div>
</div>
<div class="player-card">
<div style="font-size:13px;font-weight:700;color:#1a73e8;margin-bottom:6px">[玩家 / 对象 2]</div>
<div style="font-size:12px;color:#444">[产品定位]</div>
<div style="font-size:11px;color:#888;margin-top:6px">关键差异:[一句话]</div>
</div>
<div class="player-card">
<div style="font-size:13px;font-weight:700;color:#1a73e8;margin-bottom:6px">[玩家 / 对象 3]</div>
<div style="font-size:12px;color:#444">[产品定位]</div>
<div style="font-size:11px;color:#888;margin-top:6px">关键差异:[一句话]</div>
</div>
</div>
<div style="font-size:12px;color:#666;margin:8px 0 0">[小结一句话:玩家共性 / 自家路线对比]</div>
</div>
<div class="card">
<h2 style="margin:0 0 4px;font-size:16px">4. [章节标题:例 "安全风险全景" / "潜在隐患"] <span class="badge-error" style="vertical-align:middle;margin-left:8px">⚠️ 高危</span></h2>
<div style="font-size:12px;color:#888;margin:0 0 12px">[一句话描述:风险来源 / 关联前期工作]</div>
<table class="tbl">
<thead><tr>
<th>威胁 / 风险</th>
<th>案例 / 来源</th>
<th style="text-align:center">自家现状</th>
</tr></thead>
<tbody>
<tr>
<td>[风险 1]</td>
<td>[案例 / 来源链接 / 引用前期报告]</td>
<td style="text-align:center"><span class="badge-error">[标签]</span></td>
</tr>
<tr class="alt">
<td>[风险 2]</td>
<td>[案例 / 来源]</td>
<td style="text-align:center"><span class="badge-success">[标签]</span></td>
</tr>
<tr>
<td><b>[风险 3](重点)</b></td>
<td>[案例 / 来源]</td>
<td style="text-align:center"><span class="badge-error">[标签]</span></td>
</tr>
</tbody>
</table>
<div class="callout-error">
<b>结论:</b>[一段话,提炼本章节最关键的判断 / 行动建议]
</div>
</div>
<div class="card">
<h2 style="margin:0 0 4px;font-size:16px">5. [章节标题:例 "自家已就绪能力"] <span class="badge-success" style="vertical-align:middle;margin-left:8px">✓ 优势</span></h2>
<div style="font-size:12px;color:#888;margin:0 0 12px">[一句话描述:基于哪些 PR / 已交付的工作得出]</div>
<ul style="font-size:13px;padding-left:20px;margin:0;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[能力 1]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [简述 + 关联 PR / 文档链接]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[能力 2]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [简述]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[能力 3]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [简述]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[能力 4]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [简述]</span></span></li></ul>
</div>
<div class="card">
<h2 style="margin:0 0 4px;font-size:16px">6. [章节标题:例 "待补能力 / 机会清单"]</h2>
<div style="font-size:12px;color:#888;margin:0 0 12px">[一句话描述:清单口径 / 优先级判定依据]</div>
<table class="tbl-bug">
<thead><tr>
<th style="text-align:center;width:30px">#</th>
<th style="text-align:center;width:50px">优先级</th>
<th>能力 / 缺口</th>
<th>建议落地</th>
</tr></thead>
<tbody>
<tr>
<td style="text-align:center">1</td>
<td style="text-align:center"><span class="pri-p0">P0</span></td>
<td>[能力 / 缺口 1]</td>
<td style="font-size:12px">[具体落地路径 / Owner / 估算]</td>
</tr>
<tr class="alt">
<td style="text-align:center">2</td>
<td style="text-align:center"><span class="pri-p0">P0</span></td>
<td>[能力 / 缺口 2]</td>
<td style="font-size:12px">[具体落地路径]</td>
</tr>
<tr>
<td style="text-align:center">3</td>
<td style="text-align:center"><span class="pri-p1">P1</span></td>
<td>[能力 / 缺口 3]</td>
<td style="font-size:12px">[具体落地路径]</td>
</tr>
<tr class="alt">
<td style="text-align:center">4</td>
<td style="text-align:center"><span class="pri-p1">P1</span></td>
<td>[能力 / 缺口 4]</td>
<td style="font-size:12px">[具体落地路径]</td>
</tr>
<tr>
<td style="text-align:center">5</td>
<td style="text-align:center"><span class="pri-p2">P2</span></td>
<td>[能力 / 缺口 5]</td>
<td style="font-size:12px">[具体落地路径]</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2 style="margin:0 0 12px;font-size:16px;border-bottom:2px solid #137333;padding-bottom:8px">关联工作产出佐证</h2>
<div style="font-size:12px;color:#666;margin:0 0 10px">本调研报告中部分章节的依据来自下列在执行中的工作:</div>
<ul style="font-size:13px;padding-left:20px;margin:0;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:6px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><a class="not-doclink" href="https://[pr-1-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)" rel="nofollow noopener noreferrer">[PR / Issue 1 标题]</a><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [一句话描述跟本调研的关联]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:6px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><a class="not-doclink" href="https://[pr-2-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)" rel="nofollow noopener noreferrer">[PR / Issue 2 标题]</a><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [一句话描述]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:6px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><a class="not-doclink" href="https://[pr-3-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)" rel="nofollow noopener noreferrer">[PR / Issue 3 标题]</a><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [一句话描述]</span></span></li></ul>
</div>
<div class="card">
<h2 style="margin:0 0 12px;font-size:16px">建议与下一步</h2>
<ol start="1" style="font-size:13px;padding-left:20px;margin:0;list-style-position:inside" data-list-number="true"><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="a1b2c3d4" data-start="1" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[行动 1]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [具体路径 + 时间窗 + Owner]</span></span></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="a1b2c3d4" data-start="2" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[行动 2]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [具体路径 + 时间窗]</span></span></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="a1b2c3d4" data-start="3" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[行动 3]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [具体路径]</span></span></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="a1b2c3d4" data-start="4" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[行动 4]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [具体路径]</span></span></li></ol>
</div>
<div style="text-align:center;padding:16px;color:#999;font-size:11px">
<div style="margin:4px 0">调研者:<a class="not-doclink" href="mailto:[your@email]" style="cursor:pointer;color:rgb(20,86,240);padding:2px;text-decoration:none;border-radius:999em;margin:0px 2px" rel="nofollow noopener noreferrer">[your@email]</a> · [团队]|整合于 [YYYY-MM-DD]</div>
<div style="margin:4px 0">关联材料:[文档 / 笔记路径 / 前期报告]</div>
</div>
</div>

View File

@@ -0,0 +1,43 @@
<!--
=============================================================================
SUBJECT 模板lark-cli mail --subject 用):
[姓名] 个人工作周报 · [YYYY 第 NN 周] · [团队]
字段说明:
· [姓名]:发件人中文名(不带 @
· [YYYY 第 NN 周]:年份 + ISO 周数
· [团队]:所属团队(部门 / 二级团队 / 项目组)
=============================================================================
-->
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="text-align:left;font-size:14px"><b><span style="font-size:18px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(31,35,41)">[姓名] 个人工作周报 · [YYYY 第 NN 周]</span></span></span></b></div></div>
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="text-align:left;font-size:14px"><span style="font-size:13px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[团队] · [角色]|周期 [YYYY-MM-DD] ~ [YYYY-MM-DD]</span></span></span></div></div>
<div style="margin-top:20px;margin-bottom:8px;line-height:1.6"><div dir="auto" style="text-align:left;font-size:14px;border-left:3px solid rgb(36,91,219);padding-left:10px"><b><span style="font-size:16px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(31,35,41)">本周工作内容</span></span></span></b></div></div>
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">1. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(232,247,236);color:rgb(0,180,42);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>已完成</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · <a class="not-doclink" href="https://[doc-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">📄 文档</a> · <a class="not-doclink" href="https://[pr-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">PR 链接</a></span></span></div></div>
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.1:动作描述,附数据 / 链接]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.2:动作描述]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.3:动作描述,含具体数字 / 占比 / 时长]</span></span></li></ul></div>
<div style="margin-top:14px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">2. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(255,247,236);color:rgb(190,107,0);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>进行中</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · <a class="not-doclink" href="https://[doc-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">📄 文档</a></span></span></div></div>
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 2.1:动作 + 当前进度 + 数据]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 2.2:动作 + 当前进度]</span></span></li></ul></div>
<div style="margin-top:14px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">3. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(232,247,236);color:rgb(0,180,42);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>已完成</b></span></div></div>
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 3.1]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 3.2]</span></span></li></ul></div>
<div style="margin-top:24px;margin-bottom:8px;line-height:1.6"><div dir="auto" style="text-align:left;font-size:14px;border-left:3px solid rgb(0,180,42);padding-left:10px"><b><span style="font-size:16px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(31,35,41)">下周工作内容</span></span></span></b></div></div>
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">1. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(254,241,241);color:rgb(216,57,49);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>P0</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · 预计 [YYYY-MM-DD]</span></span></div></div>
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.1:具体动作 + 推进方式,例「先 spike POC再发 RFC 同协作方对齐方案」]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.2:里程碑 / 关键产出 + 完成方式]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.3:依赖 / 协作方 / 验收标准]</span></span></li></ul></div>
<div style="margin-top:14px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">2. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(254,241,241);color:rgb(216,57,49);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>P0</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · 预计 [YYYY-MM-DD]</span></span></div></div>
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 2.1:动作 + 推进方式]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 2.2:里程碑 / 关键产出]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 2.3:依赖 / 验收]</span></span></li></ul></div>
<div style="margin-top:14px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">3. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(255,247,236);color:rgb(190,107,0);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>P1</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · 预计 [YYYY-MM-DD]</span></span></div></div>
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 3.1:动作 + 推进方式]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 3.2:里程碑]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 3.3:协作方]</span></span></li></ul></div>
<div style="margin-top:14px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">4. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>P2</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · 预计 [YYYY-MM-DD]</span></span></div></div>
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 4.1:动作 + 推进方式]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 4.2:依赖 / 关键产出]</span></span></li></ul></div>
<div style="margin-top:24px;margin-bottom:8px;line-height:1.6"><div dir="auto" style="text-align:left;font-size:14px;border-left:3px solid rgb(216,57,49);padding-left:10px"><b><span style="font-size:16px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(31,35,41)">风险与疑问</span></span></span></b></div></div>
<ul style="margin-top:8px;margin-bottom:0px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)"><b>[风险 / 疑问 1]</b> — [背景:描述风险来源 / 触发场景][影响:会延期 / 阻塞哪些工作][建议:希望得到的支持 / 决策方向 / 期望响应方(@姓名 / 团队)]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)"><b>[风险 / 疑问 2]</b> — [背景][影响][建议]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)"><b>[风险 / 疑问 3]</b> — [背景][影响][建议]</span></span></li></ul>
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">(若本周无风险 / 疑问,整段替换为:<b></b>。)</span></span></div></div>
<div style="margin-top:32px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">— [姓名] / [团队] / [日期]<a class="not-doclink" href="mailto:[your@email]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">[your@email]</a></span></span></div></div>

Some files were not shown because too many files have changed in this diff Show More