mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 07:31:22 +08:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d126ea2f92 | ||
|
|
1ba107da2e | ||
|
|
0e6274d947 | ||
|
|
e18ea9a2e8 | ||
|
|
365e0a2880 | ||
|
|
0a2c3202cb | ||
|
|
176d452cc1 | ||
|
|
a2cc5e124e | ||
|
|
a2dde84158 | ||
|
|
21998b9ca8 | ||
|
|
ce2abff8ae | ||
|
|
893555a1b1 | ||
|
|
8d496b8a48 | ||
|
|
01fe71d7db | ||
|
|
3b770558e5 | ||
|
|
3cd84fca90 | ||
|
|
c2e737434c | ||
|
|
b91f6a23f3 | ||
|
|
bbef3cbfb1 | ||
|
|
cdae999541 | ||
|
|
36ff632a13 | ||
|
|
ab94ee9f54 | ||
|
|
30327abacb | ||
|
|
70081f62b1 | ||
|
|
17cbc13fcb | ||
|
|
e98471ce26 | ||
|
|
9e2be14301 | ||
|
|
367cfc9d06 | ||
|
|
e182b01f68 | ||
|
|
1135fc2767 | ||
|
|
68d78d5067 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# Build output
|
||||
/lark-cli
|
||||
/lark-cli*
|
||||
.cache/
|
||||
dist/
|
||||
bin/
|
||||
|
||||
65
CHANGELOG.md
65
CHANGELOG.md
@@ -2,6 +2,68 @@
|
||||
|
||||
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
|
||||
|
||||
- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017)
|
||||
- **im**: Enrich messages with reactions and output `update_time` (#1095)
|
||||
- **schema**: Output JSON spec envelope for all API commands (#1048)
|
||||
- **event**: Support `vc` / `note` / `minute` events (#1113)
|
||||
- **drive**: Add secure label shortcuts (#985)
|
||||
- **affordance**: Use description and command in affordance example schema (#1126)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **docs**: Remove unsupported `fetch` text format (#1109)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **auth**: Drop duplicate top-level user fields in `status` (#1128)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Document block anchor URLs in `lark-doc` skill (#1120)
|
||||
- **whiteboard**: Improve SVG/Mermaid instructions (#1097)
|
||||
|
||||
## [v1.0.41] - 2026-05-26
|
||||
|
||||
### Features
|
||||
@@ -886,6 +948,9 @@ 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
|
||||
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -61,7 +61,6 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addLegacyUserFields(result, diagnostics.User)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics)
|
||||
|
||||
@@ -86,29 +85,6 @@ func effectiveIdentity(d identitydiag.Result) string {
|
||||
}
|
||||
}
|
||||
|
||||
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
|
||||
if user.OpenID == "" {
|
||||
return
|
||||
}
|
||||
result["userName"] = user.UserName
|
||||
result["userOpenId"] = user.OpenID
|
||||
if user.TokenStatus != "" {
|
||||
result["tokenStatus"] = user.TokenStatus
|
||||
}
|
||||
if user.Scope != "" {
|
||||
result["scope"] = user.Scope
|
||||
}
|
||||
if user.ExpiresAt != "" {
|
||||
result["expiresAt"] = user.ExpiresAt
|
||||
}
|
||||
if user.RefreshExpiresAt != "" {
|
||||
result["refreshExpiresAt"] = user.RefreshExpiresAt
|
||||
}
|
||||
if user.GrantedAt != "" {
|
||||
result["grantedAt"] = user.GrantedAt
|
||||
}
|
||||
}
|
||||
|
||||
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch result["identity"] {
|
||||
case identityUser:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "飞书"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
})
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -24,7 +25,8 @@ type SchemaOptions struct {
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string
|
||||
Path string // first positional, when only one is given
|
||||
ExtraArgs []string // 2nd+ positional args (space-separated form)
|
||||
|
||||
// Flags
|
||||
Format string
|
||||
@@ -359,13 +361,16 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
opts := &SchemaOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path]",
|
||||
Use: "schema [path | service resource method]",
|
||||
Short: "View API method parameters, types, and scopes",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Args: cobra.MaximumNArgs(8),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
opts.ExtraArgs = args[1:]
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -380,60 +385,108 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||
// It handles dotted resource names (e.g. app.table.fields) by iterating all
|
||||
// resources and classifying each as a prefix-match or fully-matched.
|
||||
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
|
||||
// newer space-separated form (e.g. `schema im messages reply`).
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
|
||||
parts := strings.Split(toComplete, ".")
|
||||
|
||||
// Level 1: complete service names
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
// Case 1: legacy "single dotted arg" path — no previous args yet
|
||||
if len(args) == 0 {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
}
|
||||
|
||||
serviceName := parts[0]
|
||||
// Case 2: space-form, args already has segments
|
||||
// Walk down service -> resource(s) -> method based on existing args
|
||||
serviceName := args[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
// args[1:] are resource path segments (possibly partial); current
|
||||
// toComplete is the next segment under cursor.
|
||||
consumed := args[1:]
|
||||
resource, _, remaining := findResourceByPath(resources, consumed)
|
||||
if resource == nil {
|
||||
// Suggest top-level resource names that match toComplete
|
||||
var completions []string
|
||||
for resName := range resources {
|
||||
if strings.HasPrefix(resName, toComplete) {
|
||||
completions = append(completions, resName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
// Already typed past the resource — suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
// Resource matched exactly, suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,94 +522,231 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
if opts.Path == "" {
|
||||
printServices(out)
|
||||
return nil
|
||||
// args may have arrived as a single string (legacy single-arg path) or
|
||||
// split into multiple — normalize to a single args slice.
|
||||
var rawArgs []string
|
||||
if opts.Path != "" {
|
||||
rawArgs = []string{opts.Path}
|
||||
}
|
||||
|
||||
parts := strings.Split(opts.Path, ".")
|
||||
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", serviceName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
if opts.Format == "pretty" {
|
||||
printResourceList(out, spec, mode)
|
||||
if len(opts.ExtraArgs) > 0 {
|
||||
if opts.Path != "" {
|
||||
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
|
||||
} else {
|
||||
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
|
||||
rawArgs = append([]string(nil), opts.ExtraArgs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
parts := schema.ParsePath(rawArgs)
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
return runPrettyMode(out, parts, mode)
|
||||
}
|
||||
return runJSONMode(out, parts, mode)
|
||||
}
|
||||
|
||||
// runJSONMode dispatches list/single envelope output based on parts.
|
||||
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
|
||||
// output is deterministic across machines.
|
||||
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
filter := strictModeFilter(mode)
|
||||
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
envs := schema.AssembleAll(filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
case 1:
|
||||
spec := registry.EmbeddedSpec(parts[0])
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(parts[0])
|
||||
}
|
||||
envs := schema.AssembleService(parts[0], spec, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
default:
|
||||
return runJSONForPath(out, parts, filter)
|
||||
}
|
||||
}
|
||||
|
||||
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
|
||||
// to single-method match. Uses embedded data only.
|
||||
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
|
||||
serviceName := parts[0]
|
||||
spec := registry.EmbeddedSpec(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(serviceName)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var resNames []string
|
||||
var names []string
|
||||
for k := range resources {
|
||||
resNames = append(resNames, k)
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(resNames, ", ")))
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
|
||||
if len(remaining) == 0 {
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
} else {
|
||||
// For JSON output, filter methods in a copy to avoid mutating the registry.
|
||||
if mode.IsActive() {
|
||||
filtered := make(map[string]interface{})
|
||||
for k, v := range resource {
|
||||
filtered[k] = v
|
||||
}
|
||||
if methods, ok := resource["methods"].(map[string]interface{}); ok {
|
||||
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
|
||||
}
|
||||
output.PrintJson(out, filtered)
|
||||
} else {
|
||||
output.PrintJson(out, resource)
|
||||
}
|
||||
}
|
||||
// Resource-scoped envelope array
|
||||
envs := assembleResource(serviceName, resName, resource, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
// Method exists but caller appended extra segments — reject so they
|
||||
// don't silently get this method's schema when they typo'd the path.
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
// Method exists in spec but filtered out by strict mode
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
|
||||
"Use --as user / --as bot to switch")
|
||||
}
|
||||
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
|
||||
output.PrintJson(out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
resourcePath := []string{resName}
|
||||
var envs []schema.Envelope
|
||||
for methodName, raw := range methods {
|
||||
method, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
continue
|
||||
}
|
||||
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
}
|
||||
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
|
||||
return envs
|
||||
}
|
||||
|
||||
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
|
||||
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
|
||||
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
if len(parts) == 0 {
|
||||
printServices(out)
|
||||
return nil
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownService(serviceName)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
printResourceList(out, spec, mode)
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var mNames []string
|
||||
var names []string
|
||||
for k := range methods {
|
||||
mNames = append(mNames, k)
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(mNames, ", ")))
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
} else {
|
||||
output.PrintJson(out, method)
|
||||
if len(remaining) > 1 {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
|
||||
// nil if strict mode is not active.
|
||||
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
|
||||
if !mode.IsActive() {
|
||||
return nil
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
return func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
return true // permissive when meta_data lacks accessTokens
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
|
||||
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
|
||||
// overlay-only services would mislead callers when those services subsequently
|
||||
// fail to resolve in envelope output.
|
||||
func errUnknownEmbeddedService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
|
||||
@@ -5,6 +5,7 @@ package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -33,17 +34,165 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs(t *testing.T) {
|
||||
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
cmd.SetArgs([]string{"--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Available services") {
|
||||
t.Error("expected service list output")
|
||||
t.Error("expected service list in pretty mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{}) // default --format json
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if !strings.HasPrefix(out, "[") {
|
||||
head := out
|
||||
if len(head) > 80 {
|
||||
head = head[:80]
|
||||
}
|
||||
t.Errorf("expected JSON array root, first 80 chars:\n%s", head)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if len(envs) < 193 {
|
||||
t.Errorf("envelopes count = %d, want >= 193", len(envs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env["name"] != "im images create" {
|
||||
t.Errorf("name = %v, want \"im images create\"", env["name"])
|
||||
}
|
||||
for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} {
|
||||
if _, ok := env[key]; !ok {
|
||||
t.Errorf("missing top-level key: %s", key)
|
||||
}
|
||||
}
|
||||
meta, _ := env["_meta"].(map[string]interface{})
|
||||
if meta["envelope_version"] != "1.0" {
|
||||
t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) {
|
||||
f1, out1, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd1 := NewCmdSchema(f1, nil)
|
||||
cmd1.SetArgs([]string{"im", "images", "create"})
|
||||
if err := cmd1.Execute(); err != nil {
|
||||
t.Fatalf("space form failed: %v", err)
|
||||
}
|
||||
|
||||
f2, out2, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd2 := NewCmdSchema(f2, nil)
|
||||
cmd2.SetArgs([]string{"im.images.create"})
|
||||
if err := cmd2.Execute(); err != nil {
|
||||
t.Fatalf("dotted form failed: %v", err)
|
||||
}
|
||||
|
||||
if out1.String() != out2.String() {
|
||||
t.Errorf("space and dotted forms produced different output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_ServiceListIsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(envs) == 0 {
|
||||
t.Fatal("expected non-empty array for service im")
|
||||
}
|
||||
for _, e := range envs {
|
||||
name, _ := e["name"].(string)
|
||||
if !strings.HasPrefix(name, "im ") {
|
||||
t.Errorf("envelope name %q does not start with \"im \"", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_HighRiskYesInjection(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.messages.delete"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; !ok {
|
||||
t.Errorf("inputSchema.properties.yes missing for high-risk-write command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.reactions.list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; ok {
|
||||
t.Errorf("yes property should not appear for risk=read command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Existing pretty rendering surfaces these markers — they must still appear
|
||||
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing marker %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
116
events/minutes/minute_generated.go
Normal file
116
events/minutes/minute_generated.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
minutesDetailRetryDelay = 500 * time.Millisecond
|
||||
minutesDetailMaxRetries = 2
|
||||
)
|
||||
|
||||
// MinutesMinuteSourceOutput is the flattened minute source payload.
|
||||
type MinutesMinuteSourceOutput struct {
|
||||
SourceType string `json:"source_type,omitempty" desc:"Minute source type"`
|
||||
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
|
||||
}
|
||||
|
||||
// MinutesMinuteGeneratedOutput is the flattened shape for minutes.minute.generated_v1.
|
||||
type MinutesMinuteGeneratedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always minutes.minute.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"`
|
||||
MinuteToken string `json:"minute_token,omitempty" desc:"Minute token"`
|
||||
Title string `json:"title,omitempty" desc:"Minute title"`
|
||||
MinuteSource *MinutesMinuteSourceOutput `json:"minute_source,omitempty" desc:"Minute source metadata"`
|
||||
}
|
||||
|
||||
func processMinutesMinuteGenerated(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 {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
MinuteSource struct {
|
||||
SourceType string `json:"source_type"`
|
||||
SourceEntityID string `json:"source_entity_id"`
|
||||
} `json:"minute_source"`
|
||||
} `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 := &MinutesMinuteGeneratedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MinuteToken: envelope.Event.MinuteToken,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" {
|
||||
out.MinuteSource = &MinutesMinuteSourceOutput{
|
||||
SourceType: src.SourceType,
|
||||
SourceEntityID: src.SourceEntityID,
|
||||
}
|
||||
}
|
||||
|
||||
if rt != nil && out.MinuteToken != "" {
|
||||
fillMinutesMinuteGeneratedDetails(ctx, rt, out)
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func fillMinutesMinuteGeneratedDetails(ctx context.Context, rt event.APIClient, out *MinutesMinuteGeneratedOutput) {
|
||||
if rt == nil || out == nil || out.MinuteToken == "" {
|
||||
return
|
||||
}
|
||||
|
||||
path := fmt.Sprintf(pathMinuteDetailFmt, validate.EncodePathSegment(out.MinuteToken))
|
||||
|
||||
type minuteDetailResp struct {
|
||||
Data struct {
|
||||
Minute struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"minute"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
for attempt := 0; attempt <= minutesDetailMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(minutesDetailRetryDelay)
|
||||
}
|
||||
|
||||
raw, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var resp minuteDetailResp
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.Data.Minute.Title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out.Title = resp.Data.Minute.Title
|
||||
return
|
||||
}
|
||||
}
|
||||
353
events/minutes/minute_generated_test.go
Normal file
353
events/minutes/minute_generated_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
if s.callFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.callFn(ctx, method, path, body)
|
||||
}
|
||||
|
||||
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
|
||||
t.Helper()
|
||||
want := map[string]string{"event_type": wantEventType}
|
||||
if !reflect.DeepEqual(gotBody, want) {
|
||||
t.Fatalf("request body = %#v, want %#v", gotBody, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMinuteGenerated)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
|
||||
}
|
||||
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] != "minutes:minutes.basic:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated(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": {
|
||||
"minute": {
|
||||
"token": "<doc_token_001>",
|
||||
"title": "产品周会的视频会议",
|
||||
"note_id": "7616590025794260496"
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_001",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_001>",
|
||||
"minute_source": {
|
||||
"source_type": "meeting",
|
||||
"source_entity_id": "6911188411934433028"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if gotMethod != "GET" {
|
||||
t.Errorf("detail method = %q, want GET", gotMethod)
|
||||
}
|
||||
if gotPath != fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment("<doc_token_001>")) {
|
||||
t.Errorf("detail path = %q", gotPath)
|
||||
}
|
||||
if out.Type != eventTypeMinuteGenerated {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_minute_001" || out.Timestamp != "1608725989000" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.MinuteToken != "<doc_token_001>" {
|
||||
t.Errorf("MinuteToken = %q", out.MinuteToken)
|
||||
}
|
||||
if out.Title != "产品周会的视频会议" {
|
||||
t.Errorf("Title = %q", out.Title)
|
||||
}
|
||||
if out.MinuteSource == nil {
|
||||
t.Fatal("MinuteSource should not be nil")
|
||||
}
|
||||
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "6911188411934433028" {
|
||||
t.Errorf("MinuteSource = %+v", out.MinuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_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 := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_002",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_004>",
|
||||
"minute_source": {
|
||||
"source_type": "meeting",
|
||||
"source_entity_id": "7641156270787481117"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
wantCalls := 1 + minutesDetailMaxRetries
|
||||
if called != wantCalls {
|
||||
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
|
||||
}
|
||||
if out.MinuteToken != "<doc_token_004>" {
|
||||
t.Errorf("MinuteToken = %q", out.MinuteToken)
|
||||
}
|
||||
if out.Title != "" {
|
||||
t.Errorf("Title = %q, want empty", out.Title)
|
||||
}
|
||||
if out.MinuteSource == nil {
|
||||
t.Fatal("MinuteSource should remain from event payload")
|
||||
}
|
||||
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "7641156270787481117" {
|
||||
t.Errorf("MinuteSource = %+v", out.MinuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_EmptyTitleRetriesAndSucceeds(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": {
|
||||
"minute": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
}
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"title": "delayed title"
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_retry",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_003>"
|
||||
}
|
||||
}`)
|
||||
|
||||
if called != 2 {
|
||||
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
|
||||
}
|
||||
if out.Title != "delayed title" {
|
||||
t.Errorf("Title = %q, want delayed title", out.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(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": {
|
||||
"minute": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_exhaust",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_002>"
|
||||
}
|
||||
}`)
|
||||
|
||||
wantCalls := 1 + minutesDetailMaxRetries
|
||||
if called != wantCalls {
|
||||
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
|
||||
}
|
||||
if out.Title != "" {
|
||||
t.Errorf("Title = %q, want empty after exhausted retries", out.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMinuteGenerated)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
|
||||
}
|
||||
|
||||
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 != pathMinuteSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, eventTypeMinuteGenerated)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathMinuteUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, eventTypeMinuteGenerated)
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processMinutesMinuteGenerated(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 runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out MinutesMinuteGeneratedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid MinutesMinuteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
33
events/minutes/preconsume.go
Normal file
33
events/minutes/preconsume.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
42
events/minutes/register.go
Normal file
42
events/minutes/register.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package minutes registers Minutes-domain EventKeys.
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMinuteGenerated = "minutes.minute.generated_v1"
|
||||
|
||||
pathMinuteSubscribe = "/open-apis/minutes/v1/minutes/subscription"
|
||||
pathMinuteUnsubscribe = "/open-apis/minutes/v1/minutes/unsubscription"
|
||||
|
||||
pathMinuteDetailFmt = "/open-apis/minutes/v1/minutes/%s"
|
||||
)
|
||||
|
||||
// Keys returns all Minutes-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMinuteGenerated,
|
||||
DisplayName: "Minute generated",
|
||||
Description: "Triggered when a minute has been generated",
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})},
|
||||
},
|
||||
Process: processMinutesMinuteGenerated,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
|
||||
Scopes: []string{"minutes:minutes.basic:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMinuteGenerated},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,17 @@ package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Mail is intentionally omitted: only IM is wired up this phase.
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
vc.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
|
||||
156
events/vc/note_generated.go
Normal file
156
events/vc/note_generated.go
Normal 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
|
||||
}
|
||||
328
events/vc/note_generated_test.go
Normal file
328
events/vc/note_generated_test.go
Normal 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
|
||||
}
|
||||
77
events/vc/participant_meeting_ended.go
Normal file
77
events/vc/participant_meeting_ended.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1.
|
||||
type VCParticipantMeetingEndedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_ended_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"`
|
||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||
EndTime string `json:"end_time,omitempty" desc:"Meeting end time in RFC3339, converted to the local timezone"`
|
||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||
}
|
||||
|
||||
func processVCParticipantMeetingEnded(_ context.Context, _ 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 {
|
||||
Meeting struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
MeetingNo string `json:"meeting_no"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CalendarEventID string `json:"calendar_event_id"`
|
||||
} `json:"meeting"`
|
||||
} `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
|
||||
}
|
||||
|
||||
meeting := envelope.Event.Meeting
|
||||
out := &VCParticipantMeetingEndedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MeetingID: meeting.ID,
|
||||
Topic: meeting.Topic,
|
||||
MeetingNo: meeting.MeetingNo,
|
||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||
EndTime: unixSecondsToLocalRFC3339(meeting.EndTime),
|
||||
CalendarEventID: meeting.CalendarEventID,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func unixSecondsToLocalRFC3339(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
secs, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(secs, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
203
events/vc/participant_meeting_ended_test.go
Normal file
203
events/vc/participant_meeting_ended_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMeetingEnded)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded)
|
||||
}
|
||||
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:meeting.meetingevent:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_end_001",
|
||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
||||
"create_time": "1608725989000",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "6911188411934433028",
|
||||
"topic": "my meeting",
|
||||
"meeting_no": "235812466",
|
||||
"start_time": "1608883322",
|
||||
"end_time": "1608883899",
|
||||
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingEnded(t, payload)
|
||||
|
||||
if out.Type != eventTypeMeetingEnded {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_vc_end_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.Timestamp != "1608725989000" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
if out.MeetingID != "6911188411934433028" {
|
||||
t.Errorf("MeetingID = %q", out.MeetingID)
|
||||
}
|
||||
if out.Topic != "my meeting" || out.MeetingNo != "235812466" {
|
||||
t.Errorf("Topic/MeetingNo = %q/%q", out.Topic, out.MeetingNo)
|
||||
}
|
||||
if out.CalendarEventID != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
|
||||
t.Errorf("CalendarEventID = %q", out.CalendarEventID)
|
||||
}
|
||||
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out.StartTime != want {
|
||||
t.Errorf("StartTime = %q, want %q", out.StartTime, want)
|
||||
}
|
||||
if want := time.Unix(1608883899, 0).Local().Format(time.RFC3339); out.EndTime != want {
|
||||
t.Errorf("EndTime = %q, want %q", out.EndTime, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded_InvalidMeetingTimes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_end_002",
|
||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "meeting_invalid_time",
|
||||
"start_time": "bad",
|
||||
"end_time": ""
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingEnded(t, payload)
|
||||
if out.StartTime != "" || out.EndTime != "" {
|
||||
t.Errorf("StartTime/EndTime = %q/%q, want empty strings", out.StartTime, out.EndTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processVCParticipantMeetingEnded(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 TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1")
|
||||
if !ok {
|
||||
t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()")
|
||||
}
|
||||
|
||||
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 != pathMeetingSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, eventTypeMeetingEnded)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, eventTypeMeetingEnded)
|
||||
}
|
||||
|
||||
func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out VCParticipantMeetingEndedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid VCParticipantMeetingEndedOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
33
events/vc/preconsume.go
Normal file
33
events/vc/preconsume.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
61
events/vc/register.go
Normal file
61
events/vc/register.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package vc registers VC-domain EventKeys.
|
||||
package vc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
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.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMeetingEnded,
|
||||
DisplayName: "Participant meeting ended",
|
||||
Description: "Triggered when a meeting the current user participates in has ended",
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})},
|
||||
},
|
||||
Process: processVCParticipantMeetingEnded,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
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},
|
||||
},
|
||||
}
|
||||
}
|
||||
30
events/vc/test_helpers_test.go
Normal file
30
events/vc/test_helpers_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
if s.callFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.callFn(ctx, method, path, body)
|
||||
}
|
||||
|
||||
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
|
||||
t.Helper()
|
||||
want := map[string]string{"event_type": wantEventType}
|
||||
if !reflect.DeepEqual(gotBody, want) {
|
||||
t.Fatalf("request body = %#v, want %#v", gotBody, want)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
52
internal/client/dostream_test.go
Normal file
52
internal/client/dostream_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func RequireConfirmation(action string) error {
|
||||
Message: fmt.Sprintf("%s requires confirmation", action),
|
||||
Hint: "add --yes to confirm",
|
||||
Risk: &output.RiskDetail{
|
||||
Level: "high-risk-write",
|
||||
Level: RiskHighRiskWrite,
|
||||
Action: action,
|
||||
},
|
||||
},
|
||||
|
||||
27
internal/cmdutil/lang.go
Normal file
27
internal/cmdutil/lang.go
Normal 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
|
||||
}
|
||||
@@ -7,11 +7,20 @@ import "github.com/spf13/cobra"
|
||||
|
||||
const riskLevelAnnotationKey = "risk_level"
|
||||
|
||||
// Risk level constants — the three-tier convention used across the CLI.
|
||||
// Use these in place of string literals so the typo radius is one place,
|
||||
// not every call site.
|
||||
const (
|
||||
RiskRead = "read"
|
||||
RiskWrite = "write"
|
||||
RiskHighRiskWrite = "high-risk-write"
|
||||
)
|
||||
|
||||
// SetRisk stores a command's static risk level on cobra annotations so the
|
||||
// help renderer (cmd/root.go) can surface a Risk: line without importing
|
||||
// shortcuts/common. Levels follow the three-tier convention: "read" | "write"
|
||||
// | "high-risk-write". Framework-level confirmation gating only acts on
|
||||
// "high-risk-write".
|
||||
// shortcuts/common. Levels follow the three-tier convention: RiskRead |
|
||||
// RiskWrite | RiskHighRiskWrite. Framework-level confirmation gating only
|
||||
// acts on RiskHighRiskWrite.
|
||||
func SetRisk(cmd *cobra.Command, level string) {
|
||||
if level == "" {
|
||||
return
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
76
internal/i18n/lang.go
Normal 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
|
||||
}
|
||||
96
internal/i18n/lang_test.go
Normal file
96
internal/i18n/lang_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,19 @@ const (
|
||||
|
||||
// IM resource ownership mismatch.
|
||||
LarkErrOwnershipMismatch = 231205
|
||||
|
||||
// Mail send: account / mailbox-level failures returned by
|
||||
// POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send.
|
||||
// Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code
|
||||
// because ErrAPI preserves Detail.Code exactly as returned by the server.
|
||||
// These codes indicate the entire batch will keep failing identically and
|
||||
// are consumed by shortcuts/mail.isFatalSendErr to abort early.
|
||||
LarkErrMailboxNotFound = 1234013 // mailbox not found or not active
|
||||
LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded
|
||||
LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded
|
||||
LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded
|
||||
LarkErrMailQuota = 1236010 // mail quota limit
|
||||
LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded
|
||||
)
|
||||
|
||||
// legacyHints supplies the per-code actionable hint string for the legacy
|
||||
|
||||
@@ -91,6 +91,32 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013},
|
||||
{name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007},
|
||||
{name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008},
|
||||
{name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009},
|
||||
{name: "mail quota", got: LarkErrMailQuota, want: 1236010},
|
||||
{name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if tt.got != tt.want {
|
||||
t.Fatalf("code=%d, want %d", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
|
||||
// contention error (131009) maps to an actionable retry hint instead of
|
||||
// a generic "api_error". Surfaces during concurrent wiki +node-create
|
||||
|
||||
@@ -22,6 +22,64 @@ var registryFS embed.FS
|
||||
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
|
||||
var embeddedMetaJSON []byte
|
||||
|
||||
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
|
||||
// that need to parse key order or other JSON-level structure not exposed by
|
||||
// LoadFromMeta (which loses map insertion order).
|
||||
func EmbeddedMetaJSON() []byte {
|
||||
return embeddedMetaJSON
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
|
||||
embeddedServiceNames []string // sorted
|
||||
embeddedParseOnce sync.Once
|
||||
)
|
||||
|
||||
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
|
||||
// without touching mergedServices. Safe to call multiple times (sync.Once).
|
||||
func parseEmbeddedServices() {
|
||||
embeddedParseOnce.Do(func() {
|
||||
embeddedServicesMap = make(map[string]map[string]interface{})
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return
|
||||
}
|
||||
var wrapper struct {
|
||||
Services []map[string]interface{} `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil {
|
||||
return
|
||||
}
|
||||
for _, svc := range wrapper.Services {
|
||||
name, _ := svc["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
embeddedServicesMap[name] = svc
|
||||
}
|
||||
embeddedServiceNames = make([]string, 0, len(embeddedServicesMap))
|
||||
for name := range embeddedServicesMap {
|
||||
embeddedServiceNames = append(embeddedServiceNames, name)
|
||||
}
|
||||
sort.Strings(embeddedServiceNames)
|
||||
})
|
||||
}
|
||||
|
||||
// EmbeddedSpec returns the embedded spec for one service, or nil if unknown.
|
||||
// Bypasses remote overlay — used for deterministic envelope output.
|
||||
func EmbeddedSpec(serviceName string) map[string]interface{} {
|
||||
parseEmbeddedServices()
|
||||
return embeddedServicesMap[serviceName]
|
||||
}
|
||||
|
||||
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
|
||||
// Returns a defensive copy — callers must not mutate the package-level slice.
|
||||
func EmbeddedServiceNames() []string {
|
||||
parseEmbeddedServices()
|
||||
out := make([]string, len(embeddedServiceNames))
|
||||
copy(out, embeddedServiceNames)
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
|
||||
mergedProjectList []string // sorted project names
|
||||
|
||||
874
internal/schema/assembler.go
Normal file
874
internal/schema/assembler.go
Normal file
@@ -0,0 +1,874 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// MethodKeyOrder records the natural meta_data.json key order for one method's
|
||||
// parameters / requestBody / responseBody. Nested object key orders are stored
|
||||
// under NestedKeys, keyed by dotted path from the method root
|
||||
// (e.g. "responseBody.items.properties").
|
||||
type MethodKeyOrder struct {
|
||||
Parameters []string
|
||||
RequestBody []string
|
||||
ResponseBody []string
|
||||
NestedKeys map[string][]string
|
||||
}
|
||||
|
||||
var (
|
||||
keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order
|
||||
keyOrderInitOnce sync.Once
|
||||
)
|
||||
|
||||
// lookupKeyOrder returns the key-order record for service.resourcePath.method,
|
||||
// or nil if the method is not in the embedded data (e.g. remote-cached).
|
||||
func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder {
|
||||
keyOrderInitOnce.Do(buildKeyOrderIndex)
|
||||
if keyOrderIndex == nil {
|
||||
return nil
|
||||
}
|
||||
dotted := dottedPath(service, resourcePath, method)
|
||||
return keyOrderIndex[dotted]
|
||||
}
|
||||
|
||||
func dottedPath(service string, resourcePath []string, method string) string {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(service)
|
||||
for _, r := range resourcePath {
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(r)
|
||||
}
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(method)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init,
|
||||
// walking services -> resources -> methods -> {parameters,requestBody,responseBody}
|
||||
// and recording each map's key insertion order via json.Decoder.Token().
|
||||
func buildKeyOrderIndex() {
|
||||
raw := registry.EmbeddedMetaJSON()
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
keyOrderIndex = make(map[string]*MethodKeyOrder)
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
// Top-level: { "services": [...], "version": "..." }
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
if key != "services" {
|
||||
skipValue(dec)
|
||||
continue
|
||||
}
|
||||
if !expectDelim(dec, '[') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
parseService(dec)
|
||||
}
|
||||
// closing ]
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
}
|
||||
|
||||
// parseService consumes one service object inside services[].
|
||||
// meta_data.json may emit "resources" before "name", so we first capture both
|
||||
// raw fields, then walk resources with the resolved service name.
|
||||
func parseService(dec *json.Decoder) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
var serviceName string
|
||||
var resourcesRaw json.RawMessage
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "name":
|
||||
tok, _ := dec.Token()
|
||||
if s, ok := tok.(string); ok {
|
||||
serviceName = s
|
||||
}
|
||||
case "resources":
|
||||
if err := dec.Decode(&resourcesRaw); err != nil {
|
||||
skipValue(dec)
|
||||
}
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token() // closing }
|
||||
if serviceName != "" && len(resourcesRaw) > 0 {
|
||||
subDec := json.NewDecoder(bytes.NewReader(resourcesRaw))
|
||||
parseResources(subDec, serviceName, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// parseResources walks a resources map (resName -> resource object).
|
||||
// resourcePath is the accumulated path of parent resources (for nested resources).
|
||||
func parseResources(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
resName, _ := readKey(dec)
|
||||
parseResourceObj(dec, service, append(resourcePath, resName))
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseResourceObj consumes one resource value: { methods: {...}, ... } and may
|
||||
// recurse into nested resources via "resources" key if present.
|
||||
func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "methods":
|
||||
parseMethods(dec, service, resourcePath)
|
||||
case "resources":
|
||||
parseResources(dec, service, resourcePath)
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseMethods consumes the methods map (methodName -> method object).
|
||||
func parseMethods(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
methodName, _ := readKey(dec)
|
||||
mko := parseMethod(dec)
|
||||
dotted := dottedPath(service, resourcePath, methodName)
|
||||
keyOrderIndex[dotted] = mko
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseMethod consumes one method object and records key orders.
|
||||
func parseMethod(dec *json.Decoder) *MethodKeyOrder {
|
||||
mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)}
|
||||
if !expectDelim(dec, '{') {
|
||||
return mko
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "parameters":
|
||||
mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys)
|
||||
case "requestBody":
|
||||
mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys)
|
||||
case "responseBody":
|
||||
mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys)
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
return mko
|
||||
}
|
||||
|
||||
// recordObjectKeysRecursive consumes an object and records the top-level key
|
||||
// order. It also recurses into each child's "properties" submap, recording
|
||||
// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys
|
||||
// in order.
|
||||
func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string {
|
||||
if !expectDelim(dec, '{') {
|
||||
return nil
|
||||
}
|
||||
var order []string
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
order = append(order, key)
|
||||
// Each child value is itself an object; we want its nested "properties" order if present.
|
||||
consumeFieldRecursive(dec, prefix+"."+key, nestedKeys)
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
if prefix != "" && len(order) > 0 {
|
||||
nestedKeys[prefix] = order
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and,
|
||||
// if it contains "properties": {...}, recursively records that submap's order.
|
||||
func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok || delim != '{' {
|
||||
// Not an object — skip the rest of the value
|
||||
skipValueAfterToken(dec, tok)
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
fieldKey, _ := readKey(dec)
|
||||
if fieldKey == "properties" {
|
||||
recordObjectKeysRecursive(dec, path+".properties", nestedKeys)
|
||||
} else {
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// --- json.Decoder helpers ---
|
||||
|
||||
func expectDelim(dec *json.Decoder, want json.Delim) bool {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
return ok && delim == want
|
||||
}
|
||||
|
||||
func readKey(dec *json.Decoder) (string, error) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s, _ := tok.(string)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// skipValue consumes the next complete value (scalar, object, or array).
|
||||
func skipValue(dec *json.Decoder) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
skipValueAfterToken(dec, tok)
|
||||
}
|
||||
|
||||
func skipValueAfterToken(dec *json.Decoder, tok json.Token) {
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// We started inside a container of type `delim` ({ or [) and must eat
|
||||
// tokens until that container closes, tracking nested containers of any
|
||||
// kind. depth counts how many open containers we are currently inside.
|
||||
_ = delim
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if d, ok := t.(json.Delim); ok {
|
||||
switch d {
|
||||
case '{', '[':
|
||||
depth++
|
||||
case '}', ']':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coerceLiteral converts a meta_data literal (default / enum / example) to
|
||||
// the JSON Schema type declared by the field (integer/number/boolean/string).
|
||||
// meta_data stores every literal as a string, so without coercion an
|
||||
// `integer` field would emit string literals and fail any standard validator.
|
||||
// Already-typed values pass through unchanged. Returns (value, true) on
|
||||
// success, or (nil, false) when the literal cannot be coerced (caller should
|
||||
// drop it).
|
||||
func coerceLiteral(fieldType string, raw interface{}) (interface{}, bool) {
|
||||
s, isStr := raw.(string)
|
||||
if !isStr {
|
||||
// Already typed (e.g. meta_data emitted a JSON number/bool directly).
|
||||
return raw, true
|
||||
}
|
||||
switch fieldType {
|
||||
case "integer":
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return v, true
|
||||
}
|
||||
return nil, false
|
||||
case "number":
|
||||
if v, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return v, true
|
||||
}
|
||||
return nil, false
|
||||
case "boolean":
|
||||
switch s {
|
||||
case "true":
|
||||
return true, true
|
||||
case "false":
|
||||
return false, true
|
||||
}
|
||||
return nil, false
|
||||
default: // "string", "" (nested objects), or unknown
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
|
||||
// sortEnum sorts an enum slice in-place using a comparator appropriate for
|
||||
// the declared JSON Schema type, so integer enums end up [1, 2, 10] rather
|
||||
// than the lexicographic [1, 10, 2].
|
||||
func sortEnum(fieldType string, vals []interface{}) {
|
||||
sort.SliceStable(vals, func(i, j int) bool {
|
||||
switch fieldType {
|
||||
case "integer":
|
||||
ai, _ := vals[i].(int64)
|
||||
bi, _ := vals[j].(int64)
|
||||
return ai < bi
|
||||
case "number":
|
||||
af, _ := vals[i].(float64)
|
||||
bf, _ := vals[j].(float64)
|
||||
return af < bf
|
||||
case "boolean":
|
||||
ab, _ := vals[i].(bool)
|
||||
bb, _ := vals[j].(bool)
|
||||
return !ab && bb // false < true
|
||||
default:
|
||||
as, _ := vals[i].(string)
|
||||
bs, _ := vals[j].(string)
|
||||
return as < bs
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// convertProperty recursively converts one meta_data field map into a Property.
|
||||
// nestedPath is the dotted lookup key into the current method's NestedKeys map
|
||||
// (e.g. "responseBody.items.properties"). Empty path = top-level, no nested
|
||||
// lookup needed.
|
||||
func convertProperty(field map[string]interface{}, nestedPath string) Property {
|
||||
var p Property
|
||||
|
||||
rawType, _ := field["type"].(string)
|
||||
switch rawType {
|
||||
case "file":
|
||||
p.Type = "string"
|
||||
p.Format = "binary"
|
||||
case "list":
|
||||
// meta_data uses non-standard "list" on a couple of fields;
|
||||
// translate to JSON Schema "array" so validators accept it.
|
||||
p.Type = "array"
|
||||
default:
|
||||
p.Type = rawType
|
||||
}
|
||||
|
||||
if s, ok := field["description"].(string); ok {
|
||||
p.Description = s
|
||||
}
|
||||
if v, ok := field["default"]; ok {
|
||||
// Coerce default literal to match the declared JSON Schema type so
|
||||
// validators do not reject e.g. {type:"integer", default:"500"}.
|
||||
// When coercion fails (e.g. default:"" on an integer field, which
|
||||
// meta_data uses to mean "no default"), omit the field entirely
|
||||
// instead of emitting a type-mismatched default — the result is a
|
||||
// missing `default` key rather than a contract violation.
|
||||
if coerced, ok := coerceLiteral(p.Type, v); ok {
|
||||
p.Default = coerced
|
||||
}
|
||||
}
|
||||
if v, ok := field["example"]; ok {
|
||||
// meta_data stores examples as strings even when the field is integer/
|
||||
// boolean/number; coerce to the declared type so downstream validators
|
||||
// accept the envelope. Drop on coerce failure (same policy as default).
|
||||
if coerced, ok := coerceLiteral(p.Type, v); ok {
|
||||
p.Example = coerced
|
||||
}
|
||||
}
|
||||
|
||||
// min / max are stored as strings in meta_data; parse on best-effort.
|
||||
if minStr, ok := field["min"].(string); ok && minStr != "" {
|
||||
if v, err := strconv.ParseFloat(minStr, 64); err == nil {
|
||||
p.Minimum = &v
|
||||
}
|
||||
}
|
||||
if maxStr, ok := field["max"].(string); ok && maxStr != "" {
|
||||
if v, err := strconv.ParseFloat(maxStr, 64); err == nil {
|
||||
p.Maximum = &v
|
||||
}
|
||||
}
|
||||
|
||||
// enum: prefer existing "enum" array; else extract from options[].value.
|
||||
// Values are typed per p.Type so integer fields get integer enums, etc.
|
||||
// (JSON Schema 2020-12 requires enum value types to match the declared
|
||||
// type — meta_data stores everything as strings.)
|
||||
if enumRaw, ok := field["enum"].([]interface{}); ok && len(enumRaw) > 0 {
|
||||
for _, e := range enumRaw {
|
||||
if v, ok := coerceLiteral(p.Type, e); ok {
|
||||
p.Enum = append(p.Enum, v)
|
||||
}
|
||||
}
|
||||
// Numeric/boolean enums get sorted (no inherent meaning in meta_data
|
||||
// order); string enums keep meta_data order, which sometimes carries
|
||||
// semantic priority (e.g. image_type ["message","avatar"]).
|
||||
if p.Type != "string" && p.Type != "" {
|
||||
sortEnum(p.Type, p.Enum)
|
||||
}
|
||||
} else if optsRaw, ok := field["options"].([]interface{}); ok && len(optsRaw) > 0 {
|
||||
seen := make(map[string]bool)
|
||||
for _, o := range optsRaw {
|
||||
om, ok := o.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
raw, ok := om["value"].(string)
|
||||
if !ok || seen[raw] {
|
||||
continue
|
||||
}
|
||||
seen[raw] = true
|
||||
if v, ok := coerceLiteral(p.Type, raw); ok {
|
||||
p.Enum = append(p.Enum, v)
|
||||
}
|
||||
}
|
||||
// Same policy as the `enum` branch: numeric/boolean enums get sorted
|
||||
// (no semantic meaning in source order); string enums keep meta_data
|
||||
// order, which may carry semantic priority.
|
||||
if p.Type != "string" && p.Type != "" {
|
||||
sortEnum(p.Type, p.Enum)
|
||||
}
|
||||
}
|
||||
|
||||
// nested properties: recurse
|
||||
if propsRaw, ok := field["properties"].(map[string]interface{}); ok && len(propsRaw) > 0 {
|
||||
nested, nestedRequired := buildOrderedProps(propsRaw, nestedPath)
|
||||
if p.Type == "array" {
|
||||
// meta_data quirk: array element schema is wrapped in "properties".
|
||||
// Unfold into Items: { type: "object", properties: <nested> }
|
||||
p.Items = &Property{
|
||||
Type: "object",
|
||||
Properties: nested,
|
||||
Required: nestedRequired,
|
||||
}
|
||||
// Property.Properties stays nil for arrays
|
||||
} else {
|
||||
if p.Type == "" {
|
||||
p.Type = "object" // infer
|
||||
}
|
||||
p.Properties = nested
|
||||
p.Required = nestedRequired
|
||||
}
|
||||
}
|
||||
|
||||
// array items fallback: emit `items: {}` (any schema) for every array that
|
||||
// meta_data does not describe an element shape for — whether it arrived as
|
||||
// "list" or natively as "array". Without this, typeless arrays (e.g. arrays
|
||||
// of bare ID strings) violate the L1 lint rule and are not JSON Schema valid
|
||||
// for consumers that require `items`.
|
||||
if p.Type == "array" && p.Items == nil {
|
||||
p.Items = &Property{}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// buildOrderedProps converts a map[string]interface{} of field specs into an
|
||||
// OrderedProps plus the alphabetized list of child keys marked `required:true`
|
||||
// in meta_data. Callers attach that list to the enclosing object's `required`,
|
||||
// so nested objects faithfully report their call contract (top-level required
|
||||
// is handled separately by buildInputSchema).
|
||||
func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedProps, []string) {
|
||||
op := &OrderedProps{Map: make(map[string]Property, len(raw))}
|
||||
|
||||
var required []string
|
||||
keys := orderedKeys(raw, nestedPath)
|
||||
for _, k := range keys {
|
||||
fieldRaw, _ := raw[k].(map[string]interface{})
|
||||
op.Order = append(op.Order, k)
|
||||
op.Map[k] = convertProperty(fieldRaw, nestedPath+"."+k+".properties")
|
||||
if req, _ := fieldRaw["required"].(bool); req {
|
||||
required = append(required, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(required)
|
||||
return op, required
|
||||
}
|
||||
|
||||
// currentMethodOrder is the per-method key-order context used by orderedKeys.
|
||||
// It is set inside AssembleEnvelope (under assembleMu) and reset on return.
|
||||
var currentMethodOrder *MethodKeyOrder
|
||||
|
||||
// parseAffordance lifts the affordance overlay from a method's raw meta_data.json
|
||||
// entry into a typed *Affordance. Returns nil when the field is absent, malformed,
|
||||
// or carries no populated subfields.
|
||||
//
|
||||
// Affordance is authored in larksuite-cli-registry's registry-config.yaml under
|
||||
// overrides.<resource>.<method>.affordance and flows through gen-registry.py's
|
||||
// deep_merge into the embedded meta_data.json.
|
||||
func parseAffordance(raw interface{}) *Affordance {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var a Affordance
|
||||
if err := json.Unmarshal(b, &a); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &a
|
||||
}
|
||||
|
||||
// convertAccessTokens translates from_meta accessTokens (uses "tenant") into
|
||||
// CLI --as form (uses "bot"). The result is deduped and sorted alphabetically.
|
||||
// Unknown tokens are dropped. Returns an empty slice for nil/empty input.
|
||||
func convertAccessTokens(raw []interface{}) []string {
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range raw {
|
||||
s, ok := t.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch s {
|
||||
case "tenant":
|
||||
seen["bot"] = true
|
||||
case "user":
|
||||
seen["user"] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// buildMeta produces the _meta extension namespace.
|
||||
func buildMeta(method map[string]interface{}) *Meta {
|
||||
m := &Meta{
|
||||
EnvelopeVersion: "1.0",
|
||||
RequiredScopes: []string{}, // never nil for stable JSON
|
||||
}
|
||||
|
||||
if scopesRaw, ok := method["scopes"].([]interface{}); ok {
|
||||
for _, s := range scopesRaw {
|
||||
if str, ok := s.(string); ok {
|
||||
m.Scopes = append(m.Scopes, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rsRaw, ok := method["requiredScopes"].([]interface{}); ok {
|
||||
for _, s := range rsRaw {
|
||||
if str, ok := s.(string); ok {
|
||||
m.RequiredScopes = append(m.RequiredScopes, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
atRaw, _ := method["accessTokens"].([]interface{})
|
||||
m.AccessTokens = convertAccessTokens(atRaw)
|
||||
|
||||
m.Danger, _ = method["danger"].(bool)
|
||||
|
||||
if risk, _ := method["risk"].(string); risk != "" {
|
||||
m.Risk = risk
|
||||
} else {
|
||||
m.Risk = cmdutil.RiskRead
|
||||
}
|
||||
|
||||
if docURL, _ := method["docUrl"].(string); docURL != "" {
|
||||
m.DocURL = docURL
|
||||
}
|
||||
|
||||
m.Affordance = parseAffordance(method["affordance"])
|
||||
return m
|
||||
}
|
||||
|
||||
// buildInputSchema produces the inputSchema for one API method.
|
||||
//
|
||||
// Top-level shape:
|
||||
//
|
||||
// { type: object,
|
||||
// required: [<"params" if any param required>, <"data" if any body required>],
|
||||
// properties: {
|
||||
// params: { type: object, required: [...], properties: { ...path/query fields } }, // only if method has parameters
|
||||
// data: { type: object, required: [...], properties: { ...body fields } }, // only if method has requestBody
|
||||
// yes: { type: boolean, default: false, ... } // only when risk == "high-risk-write"
|
||||
// } }
|
||||
//
|
||||
// The params / data wrapping mirrors the CLI's actual flag layout:
|
||||
// path+query → --params JSON, body → --data JSON, file → --file. AI consumers
|
||||
// can pluck inputSchema.properties.params and pass it verbatim to --params.
|
||||
//
|
||||
// Caller must set currentMethodOrder for property-order preservation.
|
||||
func buildInputSchema(method map[string]interface{}) *InputSchema {
|
||||
is := &InputSchema{
|
||||
Type: "object",
|
||||
Required: []string{}, // never nil — stable envelope shape
|
||||
Properties: &OrderedProps{Map: make(map[string]Property)},
|
||||
}
|
||||
|
||||
// Build the "params" sub-object from method.parameters (path + query).
|
||||
paramsRaw, _ := method["parameters"].(map[string]interface{})
|
||||
paramsProps := &OrderedProps{Map: make(map[string]Property)}
|
||||
var paramsRequired []string
|
||||
for _, k := range orderedKeys(paramsRaw, "parameters") {
|
||||
field, _ := paramsRaw[k].(map[string]interface{})
|
||||
prop := convertProperty(field, "parameters."+k+".properties")
|
||||
paramsProps.Order = append(paramsProps.Order, k)
|
||||
paramsProps.Map[k] = prop
|
||||
if req, _ := field["required"].(bool); req {
|
||||
paramsRequired = append(paramsRequired, k)
|
||||
}
|
||||
}
|
||||
if len(paramsProps.Order) > 0 {
|
||||
sort.Strings(paramsRequired)
|
||||
is.Properties.Order = append(is.Properties.Order, "params")
|
||||
is.Properties.Map["params"] = Property{
|
||||
Type: "object",
|
||||
Required: paramsRequired,
|
||||
Properties: paramsProps,
|
||||
}
|
||||
if len(paramsRequired) > 0 {
|
||||
is.Required = append(is.Required, "params")
|
||||
}
|
||||
}
|
||||
|
||||
// Split method.requestBody into two buckets:
|
||||
// - data: non-file body fields → corresponds to CLI --data JSON
|
||||
// - file: type:file body fields → corresponds to CLI --file <key>=<path>
|
||||
// File fields are kept *out* of `data` so the schema mirrors the actual
|
||||
// CLI flag dispatch: --file owns one wire format (multipart upload),
|
||||
// --data owns the rest (JSON body).
|
||||
bodyRaw, _ := method["requestBody"].(map[string]interface{})
|
||||
dataProps := &OrderedProps{Map: make(map[string]Property)}
|
||||
fileProps := &OrderedProps{Map: make(map[string]Property)}
|
||||
var dataRequired []string
|
||||
var fileRequired []string
|
||||
for _, k := range orderedKeys(bodyRaw, "requestBody") {
|
||||
field, _ := bodyRaw[k].(map[string]interface{})
|
||||
prop := convertProperty(field, "requestBody."+k+".properties")
|
||||
isFile := false
|
||||
if t, _ := field["type"].(string); t == "file" {
|
||||
isFile = true
|
||||
}
|
||||
if isFile {
|
||||
fileProps.Order = append(fileProps.Order, k)
|
||||
fileProps.Map[k] = prop
|
||||
if req, _ := field["required"].(bool); req {
|
||||
fileRequired = append(fileRequired, k)
|
||||
}
|
||||
} else {
|
||||
dataProps.Order = append(dataProps.Order, k)
|
||||
dataProps.Map[k] = prop
|
||||
if req, _ := field["required"].(bool); req {
|
||||
dataRequired = append(dataRequired, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dataProps.Order) > 0 {
|
||||
sort.Strings(dataRequired)
|
||||
is.Properties.Order = append(is.Properties.Order, "data")
|
||||
is.Properties.Map["data"] = Property{
|
||||
Type: "object",
|
||||
Required: dataRequired,
|
||||
Properties: dataProps,
|
||||
}
|
||||
if len(dataRequired) > 0 {
|
||||
is.Required = append(is.Required, "data")
|
||||
}
|
||||
}
|
||||
if len(fileProps.Order) > 0 {
|
||||
sort.Strings(fileRequired)
|
||||
is.Properties.Order = append(is.Properties.Order, "file")
|
||||
is.Properties.Map["file"] = Property{
|
||||
Type: "object",
|
||||
Description: "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.",
|
||||
Required: fileRequired,
|
||||
Properties: fileProps,
|
||||
}
|
||||
if len(fileRequired) > 0 {
|
||||
is.Required = append(is.Required, "file")
|
||||
}
|
||||
}
|
||||
|
||||
// high-risk-write injects a top-level `yes` confirmation flag — sibling
|
||||
// of params/data. It is a CLI gate (consumed by lark-cli, not sent to
|
||||
// the backend), not an API field.
|
||||
if risk, _ := method["risk"].(string); risk == cmdutil.RiskHighRiskWrite {
|
||||
is.Properties.Order = append(is.Properties.Order, "yes")
|
||||
falseVal := false
|
||||
is.Properties.Map["yes"] = Property{
|
||||
Type: "boolean",
|
||||
Default: falseVal,
|
||||
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
|
||||
}
|
||||
// yes is intentionally NOT added to top-level Required; the gate is
|
||||
// enforced semantically (yes==true) by the CLI, not structurally.
|
||||
}
|
||||
|
||||
sort.Strings(is.Required) // alphabetical
|
||||
return is
|
||||
}
|
||||
|
||||
// buildOutputSchema produces the outputSchema for one API method.
|
||||
func buildOutputSchema(method map[string]interface{}) *OutputSchema {
|
||||
os := &OutputSchema{
|
||||
Type: "object",
|
||||
Properties: &OrderedProps{Map: make(map[string]Property)},
|
||||
}
|
||||
respRaw, _ := method["responseBody"].(map[string]interface{})
|
||||
for _, k := range orderedKeys(respRaw, "responseBody") {
|
||||
field, _ := respRaw[k].(map[string]interface{})
|
||||
os.Properties.Order = append(os.Properties.Order, k)
|
||||
os.Properties.Map[k] = convertProperty(field, "responseBody."+k+".properties")
|
||||
}
|
||||
return os
|
||||
}
|
||||
|
||||
// assembleMu serializes AssembleEnvelope calls so that the package-level
|
||||
// currentMethodOrder pointer is safe for concurrent callers.
|
||||
var assembleMu sync.Mutex
|
||||
|
||||
// AssembleEnvelope is the main entry point: takes a service / resource path /
|
||||
// method name plus its meta_data spec, and produces a fully assembled MCP
|
||||
// envelope. Output is fully determined by inputs (same arguments → same
|
||||
// envelope), but assembly briefly publishes the per-method key-order context
|
||||
// through the package-level currentMethodOrder so orderedKeys can reach it
|
||||
// without threading it through every helper. assembleMu serializes that
|
||||
// publish, which is why concurrent callers are still safe — they queue
|
||||
// rather than run in parallel.
|
||||
//
|
||||
// If parallelism becomes a bottleneck, replace currentMethodOrder with an
|
||||
// assembler struct or pass *MethodKeyOrder explicitly down the call chain.
|
||||
func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope {
|
||||
assembleMu.Lock()
|
||||
defer assembleMu.Unlock()
|
||||
currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName)
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
name := serviceName
|
||||
for _, r := range resourcePath {
|
||||
name += " " + r
|
||||
}
|
||||
name += " " + methodName
|
||||
|
||||
desc, _ := method["description"].(string)
|
||||
|
||||
return Envelope{
|
||||
Name: name,
|
||||
Description: desc,
|
||||
InputSchema: buildInputSchema(method),
|
||||
OutputSchema: buildOutputSchema(method),
|
||||
Meta: buildMeta(method),
|
||||
}
|
||||
}
|
||||
|
||||
// MethodFilter is an optional predicate used by AssembleService and
|
||||
// AssembleAll to filter methods (e.g. by access token for strict mode).
|
||||
// Pass nil to include all methods.
|
||||
type MethodFilter func(method map[string]interface{}) bool
|
||||
|
||||
// AssembleService assembles all methods under one service into a sorted
|
||||
// envelope slice (sorted by Envelope.Name ascending).
|
||||
func AssembleService(serviceName string, spec map[string]interface{}, filter MethodFilter) []Envelope {
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
var out []Envelope
|
||||
walkMethods(resources, nil, func(resourcePath []string, methodName string, method map[string]interface{}) {
|
||||
if filter != nil && !filter(method) {
|
||||
return
|
||||
}
|
||||
out = append(out, AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// AssembleAll assembles every embedded service into one big sorted slice.
|
||||
// Uses embedded data only (bypasses remote overlay) so envelope output is
|
||||
// deterministic across machines (CI vs dev vs different user brands).
|
||||
func AssembleAll(filter MethodFilter) []Envelope {
|
||||
var out []Envelope
|
||||
for _, svc := range registry.EmbeddedServiceNames() {
|
||||
spec := registry.EmbeddedSpec(svc)
|
||||
out = append(out, AssembleService(svc, spec, filter)...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// walkMethods recursively walks resources -> methods, calling visit for each
|
||||
// terminal method. It supports nested resources via the optional "resources"
|
||||
// key inside a resource value (matches meta_data.json structure).
|
||||
func walkMethods(resources map[string]interface{}, parentPath []string,
|
||||
visit func(resourcePath []string, methodName string, method map[string]interface{})) {
|
||||
for resName, resRaw := range resources {
|
||||
resMap, ok := resRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
curPath := append(append([]string(nil), parentPath...), resName)
|
||||
if methods, ok := resMap["methods"].(map[string]interface{}); ok {
|
||||
for mName, mRaw := range methods {
|
||||
if m, ok := mRaw.(map[string]interface{}); ok {
|
||||
visit(curPath, mName, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if nested, ok := resMap["resources"].(map[string]interface{}); ok {
|
||||
walkMethods(nested, curPath, visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// orderedKeys returns the keys of raw in their meta_data natural order if
|
||||
// the current per-method key-order context has them recorded; otherwise
|
||||
// alphabetical fallback.
|
||||
func orderedKeys(raw map[string]interface{}, nestedPath string) []string {
|
||||
if currentMethodOrder != nil && nestedPath != "" {
|
||||
if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok {
|
||||
// Filter to keys that actually exist in raw (defensive)
|
||||
out := make([]string, 0, len(order))
|
||||
seen := make(map[string]bool)
|
||||
for _, k := range order {
|
||||
if _, ok := raw[k]; ok {
|
||||
out = append(out, k)
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
// Append any keys present in raw but missing from order (defensive),
|
||||
// alphabetically for determinism.
|
||||
var extra []string
|
||||
for k := range raw {
|
||||
if !seen[k] {
|
||||
extra = append(extra, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(extra)
|
||||
out = append(out, extra...)
|
||||
return out
|
||||
}
|
||||
}
|
||||
// Fallback: alphabetical
|
||||
keys := make([]string, 0, len(raw))
|
||||
for k := range raw {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
782
internal/schema/assembler_test.go
Normal file
782
internal/schema/assembler_test.go
Normal file
@@ -0,0 +1,782 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
|
||||
// the suite gives the same answer on every machine. Without this, a stale
|
||||
// local remote_meta.json could surface methods that aren't in the embedded
|
||||
// snapshot (or alter their data) depending on the contributor's environment.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup is done explicitly
|
||||
// after m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "schema-test-cfg-*")
|
||||
if err != nil {
|
||||
// Surface the failure rather than silently running against the host
|
||||
// cache — that defeats the whole purpose of this isolation.
|
||||
println("schema test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
os.Setenv("LARKSUITE_CLI_REMOTE_META", "off") // never touch network
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
|
||||
// We only assert key-set membership, not absolute order — the upstream
|
||||
// meta_data API does not guarantee a stable JSON key sequence across
|
||||
// fetches, so hard-coding the order makes CI flaky. Order preservation
|
||||
// from input to output is tested separately in TestBuildInputSchema_*.
|
||||
order := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.reactions.list, got nil")
|
||||
}
|
||||
wantParams := map[string]bool{
|
||||
"message_id": true, "reaction_type": true, "page_token": true,
|
||||
"page_size": true, "user_id_type": true,
|
||||
}
|
||||
if got, want := len(order.Parameters), len(wantParams); got != want {
|
||||
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
|
||||
}
|
||||
for _, k := range order.Parameters {
|
||||
if !wantParams[k] {
|
||||
t.Errorf("unexpected parameter key %q", k)
|
||||
}
|
||||
}
|
||||
// im.reactions.list 是 GET,没有 requestBody
|
||||
if len(order.RequestBody) != 0 {
|
||||
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
|
||||
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
|
||||
order := lookupKeyOrder("im", []string{"images"}, "create")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.images.create, got nil")
|
||||
}
|
||||
wantBody := map[string]bool{"image_type": true, "image": true}
|
||||
if got, want := len(order.RequestBody), len(wantBody); got != want {
|
||||
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
|
||||
}
|
||||
for _, k := range order.RequestBody {
|
||||
if !wantBody[k] {
|
||||
t.Errorf("unexpected requestBody key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
|
||||
// 远端缓存的命令(不在 embedded 内)查不到 key order,返回 nil 走字母序兜底
|
||||
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
|
||||
if order != nil {
|
||||
t.Errorf("expected nil for unknown path, got %+v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_BasicTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
wantType string
|
||||
}{
|
||||
{"string", map[string]interface{}{"type": "string"}, "string"},
|
||||
{"integer", map[string]interface{}{"type": "integer"}, "integer"},
|
||||
{"boolean", map[string]interface{}{"type": "boolean"}, "boolean"},
|
||||
{"number", map[string]interface{}{"type": "number"}, "number"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := convertProperty(tt.input, "")
|
||||
if got.Type != tt.wantType {
|
||||
t.Errorf("Type = %q, want %q", got.Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_FileBinary(t *testing.T) {
|
||||
input := map[string]interface{}{"type": "file", "description": "upload"}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "string" {
|
||||
t.Errorf("Type = %q, want \"string\"", got.Type)
|
||||
}
|
||||
if got.Format != "binary" {
|
||||
t.Errorf("Format = %q, want \"binary\"", got.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_OptionsToEnum(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "banana"},
|
||||
map[string]interface{}{"value": "apple"},
|
||||
map[string]interface{}{"value": "banana"}, // duplicate
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
// string enums preserve source order (deduped), matching the `enum`
|
||||
// branch. Numeric/boolean enums would still be sorted by value.
|
||||
want := []interface{}{"banana", "apple"}
|
||||
if !reflect.DeepEqual(got.Enum, want) {
|
||||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_EnumPassThrough(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []interface{}{"x", "y"},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
want := []interface{}{"x", "y"} // pass through, no sort
|
||||
if !reflect.DeepEqual(got.Enum, want) {
|
||||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_EnumIntegerCoerce(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "integer",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "10"},
|
||||
map[string]interface{}{"value": "1"},
|
||||
map[string]interface{}{"value": "2"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
want := []interface{}{int64(1), int64(2), int64(10)} // typed + numerically sorted
|
||||
if !reflect.DeepEqual(got.Enum, want) {
|
||||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_ListTypeFallback(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "list",
|
||||
"description": "ids",
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "array" {
|
||||
t.Errorf("Type = %q, want %q", got.Type, "array")
|
||||
}
|
||||
if got.Items == nil {
|
||||
t.Fatalf("Items = nil, want non-nil (any-schema fallback)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_MinMaxParsing(t *testing.T) {
|
||||
input := map[string]interface{}{"type": "integer", "min": "10", "max": "50"}
|
||||
got := convertProperty(input, "")
|
||||
if got.Minimum == nil || *got.Minimum != 10.0 {
|
||||
t.Errorf("Minimum = %v, want 10", got.Minimum)
|
||||
}
|
||||
if got.Maximum == nil || *got.Maximum != 50.0 {
|
||||
t.Errorf("Maximum = %v, want 50", got.Maximum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_MinMaxInvalid(t *testing.T) {
|
||||
input := map[string]interface{}{"type": "integer", "min": "not_a_number"}
|
||||
got := convertProperty(input, "")
|
||||
if got.Minimum != nil {
|
||||
t.Errorf("Minimum = %v, want nil for unparseable min", got.Minimum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_ArrayWithProperties(t *testing.T) {
|
||||
// meta_data quirk: array element schema is in "properties" not "items"
|
||||
input := map[string]interface{}{
|
||||
"type": "array",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "string"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "array" {
|
||||
t.Fatalf("Type = %q, want \"array\"", got.Type)
|
||||
}
|
||||
if got.Items == nil {
|
||||
t.Fatal("Items is nil, want non-nil")
|
||||
}
|
||||
if got.Items.Type != "object" {
|
||||
t.Errorf("Items.Type = %q, want \"object\"", got.Items.Type)
|
||||
}
|
||||
if got.Items.Properties == nil || len(got.Items.Properties.Map) != 2 {
|
||||
t.Errorf("Items.Properties did not contain both id and name")
|
||||
}
|
||||
if got.Properties != nil {
|
||||
t.Error("array Property must not have top-level Properties after unfold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_ObjectWithProperties(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"x": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", got.Type)
|
||||
}
|
||||
if got.Properties == nil || got.Properties.Map["x"].Type != "string" {
|
||||
t.Errorf("nested Properties not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_InferObjectFromProperties(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"y": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\" (inferred)", got.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_DropsRefAndAnnotations(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"ref": "operator",
|
||||
"annotations": []interface{}{"readOnly"},
|
||||
"enumName": "FooEnum",
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
// 这些字段直接被丢弃;Property 结构里也没存这些字段,断言只有 type 设置即可
|
||||
if got.Type != "string" {
|
||||
t.Errorf("Type = %q", got.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "hello\nworld",
|
||||
"default": "",
|
||||
"example": "ex",
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Description != "hello\nworld" {
|
||||
t.Errorf("Description not preserved verbatim")
|
||||
}
|
||||
if got.Default != "" {
|
||||
t.Errorf("Default = %v, want empty string (preserved)", got.Default)
|
||||
}
|
||||
if got.Example != "ex" {
|
||||
t.Errorf("Example = %v, want \"ex\"", got.Example)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
if is.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", is.Type)
|
||||
}
|
||||
// top-level required: ["params"] because message_id is a required path param
|
||||
if !reflect.DeepEqual(is.Required, []string{"params"}) {
|
||||
t.Errorf("Required = %v, want [params]", is.Required)
|
||||
}
|
||||
// top-level properties only contains "params" (no body fields, no high-risk-write)
|
||||
if !reflect.DeepEqual(is.Properties.Order, []string{"params"}) {
|
||||
t.Errorf("top-level properties order = %v, want [params]", is.Properties.Order)
|
||||
}
|
||||
// params sub-object: required + property order
|
||||
params := is.Properties.Map["params"]
|
||||
if params.Type != "object" {
|
||||
t.Errorf("params.Type = %q, want \"object\"", params.Type)
|
||||
}
|
||||
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
|
||||
t.Errorf("params.Required = %v, want [message_id]", params.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
|
||||
t.Errorf("params.properties order = %v, want (from key index) %v",
|
||||
params.Properties.Order, mko.Parameters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
|
||||
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
// top-level required: ["data", "file"] — image_type body required + image file required
|
||||
if !reflect.DeepEqual(is.Required, []string{"data", "file"}) {
|
||||
t.Errorf("Required = %v, want [data, file]", is.Required)
|
||||
}
|
||||
// top-level properties: data (for non-file body) + file (for binary upload)
|
||||
if !reflect.DeepEqual(is.Properties.Order, []string{"data", "file"}) {
|
||||
t.Errorf("top-level properties order = %v, want [data, file]", is.Properties.Order)
|
||||
}
|
||||
// data sub-object carries only non-file body fields (image_type)
|
||||
data := is.Properties.Map["data"]
|
||||
if !reflect.DeepEqual(data.Required, []string{"image_type"}) {
|
||||
t.Errorf("data.Required = %v, want [image_type]", data.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(data.Properties.Order, []string{"image_type"}) {
|
||||
t.Errorf("data.properties order = %v, want [image_type]", data.Properties.Order)
|
||||
}
|
||||
if it := data.Properties.Map["image_type"]; !reflect.DeepEqual(it.Enum, []interface{}{"message", "avatar"}) {
|
||||
t.Errorf("image_type unexpected: %+v", it)
|
||||
}
|
||||
if _, isFile := data.Properties.Map["image"]; isFile {
|
||||
t.Errorf("image (file field) should NOT appear in data sub-object")
|
||||
}
|
||||
|
||||
// file sub-object carries the binary upload field
|
||||
file := is.Properties.Map["file"]
|
||||
if file.Type != "object" {
|
||||
t.Errorf("file.Type = %q, want \"object\"", file.Type)
|
||||
}
|
||||
if !reflect.DeepEqual(file.Required, []string{"image"}) {
|
||||
t.Errorf("file.Required = %v, want [image]", file.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(file.Properties.Order, []string{"image"}) {
|
||||
t.Errorf("file.properties order = %v, want [image]", file.Properties.Order)
|
||||
}
|
||||
img := file.Properties.Map["image"]
|
||||
if img.Type != "string" {
|
||||
t.Errorf("image.Type = %q, want \"string\"", img.Type)
|
||||
}
|
||||
if img.Format != "binary" {
|
||||
t.Errorf("image.Format = %q, want \"binary\"", img.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||||
// Synthesized method to avoid registry-overlay variance (remote cache may
|
||||
// strip `risk` field); buildInputSchema only cares about the method map.
|
||||
method := map[string]interface{}{
|
||||
"risk": "high-risk-write",
|
||||
"parameters": map[string]interface{}{
|
||||
"message_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"location": "path",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
currentMethodOrder = nil
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
// yes lives at inputSchema.properties.yes (sibling of params/data)
|
||||
yes, ok := is.Properties.Map["yes"]
|
||||
if !ok {
|
||||
t.Fatal("expected top-level `yes` property in high-risk-write envelope, not found")
|
||||
}
|
||||
if yes.Type != "boolean" {
|
||||
t.Errorf("yes.Type = %q, want \"boolean\"", yes.Type)
|
||||
}
|
||||
if v, _ := yes.Default.(bool); v != false {
|
||||
t.Errorf("yes.Default = %v, want false", yes.Default)
|
||||
}
|
||||
// yes must NOT be in top-level required
|
||||
for _, r := range is.Required {
|
||||
if r == "yes" {
|
||||
t.Errorf("`yes` should not appear in top-level required")
|
||||
}
|
||||
}
|
||||
// yes is appended to properties.Order
|
||||
last := is.Properties.Order[len(is.Properties.Order)-1]
|
||||
if last != "yes" {
|
||||
t.Errorf("`yes` should be last in properties.Order, got: %v", is.Properties.Order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
if _, ok := is.Properties.Map["yes"]; ok {
|
||||
t.Errorf("`yes` must not be injected for risk=read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
os := buildOutputSchema(method)
|
||||
|
||||
if os.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||||
}
|
||||
// Top-level response: has_more, page_token, items
|
||||
if _, ok := os.Properties.Map["items"]; !ok {
|
||||
t.Fatal("items not found in outputSchema")
|
||||
}
|
||||
items := os.Properties.Map["items"]
|
||||
if items.Type != "array" {
|
||||
t.Errorf("items.Type = %q, want \"array\"", items.Type)
|
||||
}
|
||||
if items.Items == nil {
|
||||
t.Fatal("items.Items is nil (array unfold failed)")
|
||||
}
|
||||
if items.Items.Type != "object" {
|
||||
t.Errorf("items.Items.Type = %q, want \"object\"", items.Items.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAccessTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []interface{}
|
||||
want []string
|
||||
}{
|
||||
{"tenant only", []interface{}{"tenant"}, []string{"bot"}},
|
||||
{"user only", []interface{}{"user"}, []string{"user"}},
|
||||
{"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}},
|
||||
{"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}},
|
||||
{"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}},
|
||||
{"empty", []interface{}{}, []string{}},
|
||||
{"nil", nil, []string{}},
|
||||
{"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := convertAccessTokens(tt.input)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_FullFields(t *testing.T) {
|
||||
// Synthesized method to avoid runtime variance from remote-cache overlay
|
||||
// (which strips `risk` from merged services). All other field semantics
|
||||
// match the real im.images.create entry in meta_data.json.
|
||||
method := map[string]interface{}{
|
||||
"risk": "write",
|
||||
"danger": true,
|
||||
"scopes": []interface{}{
|
||||
"im:resource:upload",
|
||||
"im:resource",
|
||||
},
|
||||
"accessTokens": []interface{}{"tenant"},
|
||||
"docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create",
|
||||
}
|
||||
m := buildMeta(method)
|
||||
|
||||
if m.EnvelopeVersion != "1.0" {
|
||||
t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion)
|
||||
}
|
||||
if m.Risk != "write" {
|
||||
t.Errorf("Risk = %q, want \"write\"", m.Risk)
|
||||
}
|
||||
if !m.Danger {
|
||||
t.Errorf("Danger = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(m.AccessTokens, []string{"bot"}) {
|
||||
t.Errorf("AccessTokens = %v, want [bot]", m.AccessTokens)
|
||||
}
|
||||
if m.DocURL == "" {
|
||||
t.Errorf("DocURL should be present for im.images.create")
|
||||
}
|
||||
if !reflect.DeepEqual(m.Scopes, []string{"im:resource:upload", "im:resource"}) {
|
||||
t.Errorf("Scopes = %v, want [im:resource:upload, im:resource] (meta_data natural order)", m.Scopes)
|
||||
}
|
||||
if m.RequiredScopes == nil {
|
||||
t.Errorf("RequiredScopes should be empty slice, not nil")
|
||||
}
|
||||
if len(m.RequiredScopes) != 0 {
|
||||
t.Errorf("RequiredScopes should be empty for this method, got %v", m.RequiredScopes)
|
||||
}
|
||||
if m.Affordance != nil {
|
||||
t.Errorf("Affordance must be nil when method has no affordance field, got %+v", m.Affordance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
"accessTokens": []interface{}{"user"},
|
||||
// no risk field
|
||||
}
|
||||
m := buildMeta(method)
|
||||
if m.Risk != "read" {
|
||||
t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_RequiredScopesPresent(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "mail", []string{"user_mailbox", "messages"}, "get")
|
||||
m := buildMeta(method)
|
||||
if len(m.RequiredScopes) == 0 {
|
||||
t.Errorf("RequiredScopes should be non-empty for mail.user_mailbox.messages.get")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAffordance_NilOrEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw interface{}
|
||||
}{
|
||||
{"nil", nil},
|
||||
{"empty object", map[string]interface{}{}},
|
||||
{"all-five-empty-arrays", map[string]interface{}{
|
||||
"use_when": []interface{}{},
|
||||
"do_not_use_when": []interface{}{},
|
||||
"prerequisites": []interface{}{},
|
||||
"examples": []interface{}{},
|
||||
"related": []interface{}{},
|
||||
}},
|
||||
{"malformed (string)", "not an object"},
|
||||
{"malformed (number)", 42},
|
||||
{"malformed (nested type mismatch)", map[string]interface{}{
|
||||
"examples": "should be a list, not a string",
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := parseAffordance(c.raw); got != nil {
|
||||
t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAffordance_FullPopulated(t *testing.T) {
|
||||
raw := map[string]interface{}{
|
||||
"use_when": []interface{}{"需要拿到当前用户的主日历 ID"},
|
||||
"do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"},
|
||||
"prerequisites": []interface{}{"user 身份登录"},
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"},
|
||||
},
|
||||
"related": []interface{}{"calendars.list"},
|
||||
}
|
||||
a := parseAffordance(raw)
|
||||
if a == nil {
|
||||
t.Fatal("parseAffordance returned nil, want populated")
|
||||
}
|
||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||
}
|
||||
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" ||
|
||||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
||||
t.Errorf("Examples = %+v", a.Examples)
|
||||
}
|
||||
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
|
||||
t.Errorf("Related = %v", a.Related)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
"accessTokens": []interface{}{"user"},
|
||||
"risk": "read",
|
||||
"affordance": map[string]interface{}{
|
||||
"use_when": []interface{}{"trigger"},
|
||||
},
|
||||
}
|
||||
m := buildMeta(method)
|
||||
if m.Affordance == nil {
|
||||
t.Fatal("Affordance should be populated from method[\"affordance\"]")
|
||||
}
|
||||
if len(m.Affordance.UseWhen) != 1 || m.Affordance.UseWhen[0] != "trigger" {
|
||||
t.Errorf("UseWhen = %v", m.Affordance.UseWhen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
"accessTokens": []interface{}{"user"},
|
||||
"risk": "read",
|
||||
// no docUrl
|
||||
}
|
||||
m := buildMeta(method)
|
||||
if m.DocURL != "" {
|
||||
t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL)
|
||||
}
|
||||
// Verify JSON serialization omits doc_url
|
||||
b, _ := json.Marshal(m)
|
||||
if strings.Contains(string(b), "doc_url") {
|
||||
t.Errorf("doc_url should be omitted from JSON, got: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
|
||||
// 装配器对空 responseBody 应生成 properties = {} (不 nil)
|
||||
method := map[string]interface{}{}
|
||||
currentMethodOrder = nil
|
||||
os := buildOutputSchema(method)
|
||||
if os.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||||
}
|
||||
if os.Properties == nil {
|
||||
t.Fatal("Properties is nil, want empty OrderedProps")
|
||||
}
|
||||
if len(os.Properties.Order) != 0 {
|
||||
t.Errorf("Properties.Order should be empty, got %v", os.Properties.Order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
env := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
|
||||
if env.Name != "im reactions list" {
|
||||
t.Errorf("Name = %q, want \"im reactions list\"", env.Name)
|
||||
}
|
||||
if env.Description == "" {
|
||||
t.Errorf("Description should not be empty for im.reactions.list")
|
||||
}
|
||||
if env.InputSchema == nil || env.OutputSchema == nil || env.Meta == nil {
|
||||
t.Fatal("InputSchema/OutputSchema/Meta must all be non-nil")
|
||||
}
|
||||
if env.Meta.EnvelopeVersion != "1.0" {
|
||||
t.Errorf("Meta.EnvelopeVersion = %q", env.Meta.EnvelopeVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) {
|
||||
// im.chat.members.create — resource path is one element "chat.members" with
|
||||
// an internal dot. Substituted from plan's `bots` because remote-cache
|
||||
// overlay strips `bots` from the loaded method map on this environment;
|
||||
// the assertion is about name joining, not method specifics.
|
||||
method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create")
|
||||
env := AssembleEnvelope("im", []string{"chat.members"}, "create", method)
|
||||
// chat.members resourcePath stays as one element in the slice with a dot;
|
||||
// name should split it to "im chat.members create" — we keep the dot as-is
|
||||
// inside the resource segment to round-trip with completion logic.
|
||||
if env.Name != "im chat.members create" {
|
||||
t.Errorf("Name = %q, want \"im chat.members create\"", env.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleEnvelope_JSONIsStable(t *testing.T) {
|
||||
// Assemble twice; JSON output must be byte-identical (determinism).
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
a := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
b := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
ja, _ := json.MarshalIndent(a, "", " ")
|
||||
jb, _ := json.MarshalIndent(b, "", " ")
|
||||
if string(ja) != string(jb) {
|
||||
t.Errorf("envelope assembly is non-deterministic:\nfirst:\n%s\nsecond:\n%s", ja, jb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleService_Im(t *testing.T) {
|
||||
spec := registry.LoadFromMeta("im")
|
||||
envs := AssembleService("im", spec, nil)
|
||||
if len(envs) == 0 {
|
||||
t.Fatal("expected non-empty envelopes for service im")
|
||||
}
|
||||
// Every envelope.Name starts with "im "
|
||||
for _, e := range envs {
|
||||
if !strings.HasPrefix(e.Name, "im ") {
|
||||
t.Errorf("envelope name %q does not start with \"im \"", e.Name)
|
||||
}
|
||||
}
|
||||
// Sorted by name
|
||||
for i := 1; i < len(envs); i++ {
|
||||
if envs[i-1].Name > envs[i].Name {
|
||||
t.Errorf("envelopes not sorted by name at idx %d: %q > %q", i, envs[i-1].Name, envs[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleService_FilterByAccessToken(t *testing.T) {
|
||||
spec := registry.LoadFromMeta("im")
|
||||
// Filter to bot-only (--as bot, which corresponds to "tenant")
|
||||
envs := AssembleService("im", spec, func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == "tenant" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
// Every envelope's _meta.access_tokens must contain "bot"
|
||||
for _, e := range envs {
|
||||
found := false
|
||||
for _, t := range e.Meta.AccessTokens {
|
||||
if t == "bot" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("envelope %q does not declare bot access", e.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleAll_AtLeast193(t *testing.T) {
|
||||
envs := AssembleAll(nil)
|
||||
// Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the
|
||||
// embedded meta_data.json directly, so the count is stable across machines.
|
||||
if len(envs) < 193 {
|
||||
t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs))
|
||||
}
|
||||
// Spot check: im reactions list should be present
|
||||
found := false
|
||||
for _, e := range envs {
|
||||
if e.Name == "im reactions list" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("im reactions list not found in AssembleAll output")
|
||||
}
|
||||
}
|
||||
|
||||
// loadMethodFromRegistry is a test helper that pulls one method's spec from the
|
||||
// real embedded meta_data.json via the registry package.
|
||||
func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} {
|
||||
t.Helper()
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
t.Fatalf("service %q not found in registry", service)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resKey := strings.Join(resourcePath, ".")
|
||||
res, ok := resources[resKey].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resource %q.%s not found", service, resKey)
|
||||
}
|
||||
methods, _ := res["methods"].(map[string]interface{})
|
||||
m, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("method %q.%s.%s not found", service, resKey, methodName)
|
||||
}
|
||||
return m
|
||||
}
|
||||
233
internal/schema/lint.go
Normal file
233
internal/schema/lint.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
var validJSONSchemaTypes = map[string]bool{
|
||||
"string": true,
|
||||
"integer": true,
|
||||
"number": true,
|
||||
"boolean": true,
|
||||
"array": true,
|
||||
"object": true,
|
||||
}
|
||||
|
||||
var validAccessTokens = map[string]bool{
|
||||
"user": true,
|
||||
"bot": true,
|
||||
}
|
||||
|
||||
// lintEnvelope runs L1-L3 checks and returns a list of errors. Empty slice
|
||||
// means the envelope is compliant.
|
||||
func lintEnvelope(env Envelope) []error {
|
||||
var errs []error
|
||||
|
||||
// ---- L1: structural ----
|
||||
if env.Name == "" {
|
||||
errs = append(errs, errors.New("L1: name must not be empty"))
|
||||
}
|
||||
if env.InputSchema == nil {
|
||||
errs = append(errs, errors.New("L1: inputSchema must not be nil"))
|
||||
} else {
|
||||
if env.InputSchema.Type != "object" {
|
||||
errs = append(errs, fmt.Errorf("L1: inputSchema.type = %q, want \"object\"", env.InputSchema.Type))
|
||||
}
|
||||
if env.InputSchema.Properties == nil {
|
||||
errs = append(errs, errors.New("L1: inputSchema.properties must not be nil"))
|
||||
}
|
||||
}
|
||||
if env.OutputSchema == nil {
|
||||
errs = append(errs, errors.New("L1: outputSchema must not be nil"))
|
||||
} else {
|
||||
if env.OutputSchema.Type != "object" {
|
||||
errs = append(errs, fmt.Errorf("L1: outputSchema.type = %q, want \"object\"", env.OutputSchema.Type))
|
||||
}
|
||||
}
|
||||
if env.Meta == nil {
|
||||
errs = append(errs, errors.New("L1: _meta must not be nil"))
|
||||
// Cannot continue meta-dependent checks
|
||||
return errs
|
||||
}
|
||||
if env.Meta.EnvelopeVersion != "1.0" {
|
||||
errs = append(errs, fmt.Errorf("L1: _meta.envelope_version = %q, want \"1.0\"", env.Meta.EnvelopeVersion))
|
||||
}
|
||||
|
||||
// L1: validate every Property type recursively
|
||||
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
||||
validatePropertyTypes(env.InputSchema.Properties, &errs)
|
||||
}
|
||||
if env.OutputSchema != nil && env.OutputSchema.Properties != nil {
|
||||
validatePropertyTypes(env.OutputSchema.Properties, &errs)
|
||||
}
|
||||
|
||||
// ---- L2: type-level consistency ----
|
||||
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
||||
// Walk the whole property tree so format/min-max checks reach leaf
|
||||
// fields nested under the params/data wrapper.
|
||||
walkForL2(env.InputSchema.Properties, &errs)
|
||||
// Top-level required keys must exist in top-level properties.
|
||||
for _, r := range env.InputSchema.Required {
|
||||
if _, ok := env.InputSchema.Properties.Map[r]; !ok {
|
||||
errs = append(errs, fmt.Errorf("L2: required key %q not found in properties", r))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- L3: cross-field self-consistency ----
|
||||
dangerExpected := env.Meta.Risk == cmdutil.RiskWrite || env.Meta.Risk == cmdutil.RiskHighRiskWrite
|
||||
if env.Meta.Danger != dangerExpected {
|
||||
errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk))
|
||||
}
|
||||
|
||||
// `yes` lives at inputSchema.properties.yes (sibling of params/data),
|
||||
// injected only for risk == RiskHighRiskWrite.
|
||||
hasYes := false
|
||||
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
||||
_, hasYes = env.InputSchema.Properties.Map["yes"]
|
||||
}
|
||||
wantYes := env.Meta.Risk == cmdutil.RiskHighRiskWrite
|
||||
if hasYes != wantYes {
|
||||
errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk))
|
||||
}
|
||||
|
||||
if len(env.Meta.AccessTokens) == 0 {
|
||||
errs = append(errs, errors.New("L3: _meta.access_tokens must not be empty"))
|
||||
}
|
||||
for _, t := range env.Meta.AccessTokens {
|
||||
if !validAccessTokens[t] {
|
||||
errs = append(errs, fmt.Errorf("L3: _meta.access_tokens contains invalid value %q (allowed: user, bot)", t))
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// walkForL2 recursively applies per-field L2 checks (format:binary on
|
||||
// non-string; minimum>=maximum) plus the sub-object required-exists invariant.
|
||||
// Required only matters on object-typed Properties (e.g. the params / data
|
||||
// wrappers); leaf scalars ignore it.
|
||||
func walkForL2(props *OrderedProps, errs *[]error) {
|
||||
if props == nil {
|
||||
return
|
||||
}
|
||||
for _, k := range props.Order {
|
||||
p := props.Map[k]
|
||||
if p.Format == "binary" && p.Type != "string" {
|
||||
*errs = append(*errs, fmt.Errorf("L2: field %q has format: binary but type = %q (want string)", k, p.Type))
|
||||
}
|
||||
if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum {
|
||||
*errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum))
|
||||
}
|
||||
if len(p.Required) > 0 && p.Properties != nil {
|
||||
for _, r := range p.Required {
|
||||
if _, ok := p.Properties.Map[r]; !ok {
|
||||
*errs = append(*errs, fmt.Errorf("L2: required key %q in %q not found in its properties", r, k))
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.Properties != nil {
|
||||
walkForL2(p.Properties, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validatePropertyTypes walks an OrderedProps tree and asserts:
|
||||
// - every Property.Type is in validJSONSchemaTypes (or empty for nested objects with only properties)
|
||||
// - array Properties have Items
|
||||
//
|
||||
// Errors are appended to *errs.
|
||||
func validatePropertyTypes(props *OrderedProps, errs *[]error) {
|
||||
if props == nil {
|
||||
return
|
||||
}
|
||||
for _, k := range props.Order {
|
||||
p := props.Map[k]
|
||||
if p.Type != "" && !validJSONSchemaTypes[p.Type] {
|
||||
*errs = append(*errs, fmt.Errorf("L1: property %q has invalid type %q", k, p.Type))
|
||||
}
|
||||
if p.Type == "array" && p.Items == nil {
|
||||
*errs = append(*errs, fmt.Errorf("L1: array property %q missing items", k))
|
||||
}
|
||||
if p.Properties != nil {
|
||||
validatePropertyTypes(p.Properties, errs)
|
||||
}
|
||||
// Validate the array-element schema itself, not only its child
|
||||
// properties — a primitive element with an invalid type (e.g.
|
||||
// `items.type = "list"`) would otherwise slip past lint.
|
||||
if p.Items != nil {
|
||||
validateItemSchema(k, p.Items, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateItemSchema checks a single array element schema for invalid types,
|
||||
// then recurses into any further nested properties/items.
|
||||
func validateItemSchema(parentKey string, item *Property, errs *[]error) {
|
||||
if item.Type != "" && !validJSONSchemaTypes[item.Type] {
|
||||
*errs = append(*errs, fmt.Errorf("L1: array property %q items has invalid type %q", parentKey, item.Type))
|
||||
}
|
||||
if item.Type == "array" && item.Items == nil {
|
||||
*errs = append(*errs, fmt.Errorf("L1: array property %q items (nested array) missing items", parentKey))
|
||||
}
|
||||
if item.Properties != nil {
|
||||
validatePropertyTypes(item.Properties, errs)
|
||||
}
|
||||
if item.Items != nil {
|
||||
validateItemSchema(parentKey, item.Items, errs)
|
||||
}
|
||||
}
|
||||
|
||||
// coverageBaseline is the per-metric warn threshold for L4 coverage checks.
|
||||
// If the measured rate drops below the baseline, t.Logf emits a warning but
|
||||
// does NOT fail the test. Adjust these constants upward as meta_data quality
|
||||
// improves over time.
|
||||
var coverageBaseline = map[string]float64{
|
||||
"description": 0.99,
|
||||
"scopes": 1.00,
|
||||
"doc_url": 0.98,
|
||||
"risk": 0.96,
|
||||
}
|
||||
|
||||
// measureCoverage returns the non-empty rate for each tracked metric.
|
||||
func measureCoverage(envs []Envelope) map[string]float64 {
|
||||
if len(envs) == 0 {
|
||||
return map[string]float64{
|
||||
"description": 0,
|
||||
"scopes": 0,
|
||||
"doc_url": 0,
|
||||
"risk": 0,
|
||||
}
|
||||
}
|
||||
total := float64(len(envs))
|
||||
var descNonEmpty, scopesNonEmpty, docURLNonEmpty, riskNonEmpty float64
|
||||
for _, e := range envs {
|
||||
if e.Description != "" {
|
||||
descNonEmpty++
|
||||
}
|
||||
if e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if len(e.Meta.Scopes) > 0 {
|
||||
scopesNonEmpty++
|
||||
}
|
||||
if e.Meta.DocURL != "" {
|
||||
docURLNonEmpty++
|
||||
}
|
||||
if e.Meta.Risk != "" {
|
||||
riskNonEmpty++
|
||||
}
|
||||
}
|
||||
return map[string]float64{
|
||||
"description": descNonEmpty / total,
|
||||
"scopes": scopesNonEmpty / total,
|
||||
"doc_url": docURLNonEmpty / total,
|
||||
"risk": riskNonEmpty / total,
|
||||
}
|
||||
}
|
||||
379
internal/schema/lint_test.go
Normal file
379
internal/schema/lint_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// validEnvelope builds a baseline valid envelope used as a starting point in
|
||||
// negative tests below.
|
||||
func validEnvelope() Envelope {
|
||||
props := &OrderedProps{Map: map[string]Property{}}
|
||||
return Envelope{
|
||||
Name: "x y z",
|
||||
Description: "ok",
|
||||
InputSchema: &InputSchema{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
},
|
||||
OutputSchema: &OutputSchema{
|
||||
Type: "object",
|
||||
Properties: &OrderedProps{Map: map[string]Property{}},
|
||||
},
|
||||
Meta: &Meta{
|
||||
EnvelopeVersion: "1.0",
|
||||
AccessTokens: []string{"user"},
|
||||
Risk: "read",
|
||||
Danger: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_Valid(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("expected no errors, got: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_L1_StructuralChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Envelope)
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "empty name",
|
||||
mutate: func(e *Envelope) { e.Name = "" },
|
||||
wantSub: "name",
|
||||
},
|
||||
{
|
||||
name: "nil InputSchema",
|
||||
mutate: func(e *Envelope) { e.InputSchema = nil },
|
||||
wantSub: "inputSchema",
|
||||
},
|
||||
{
|
||||
name: "inputSchema type not object",
|
||||
mutate: func(e *Envelope) { e.InputSchema.Type = "string" },
|
||||
wantSub: "inputSchema.type",
|
||||
},
|
||||
{
|
||||
name: "nil OutputSchema",
|
||||
mutate: func(e *Envelope) { e.OutputSchema = nil },
|
||||
wantSub: "outputSchema",
|
||||
},
|
||||
{
|
||||
name: "nil Meta",
|
||||
mutate: func(e *Envelope) { e.Meta = nil },
|
||||
wantSub: "_meta",
|
||||
},
|
||||
{
|
||||
name: "wrong envelope version",
|
||||
mutate: func(e *Envelope) { e.Meta.EnvelopeVersion = "0.9" },
|
||||
wantSub: "envelope_version",
|
||||
},
|
||||
{
|
||||
name: "invalid property type",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"x"}
|
||||
e.InputSchema.Properties.Map["x"] = Property{Type: "unknown_type"}
|
||||
},
|
||||
wantSub: "invalid type",
|
||||
},
|
||||
{
|
||||
name: "array missing items",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"x"}
|
||||
e.InputSchema.Properties.Map["x"] = Property{Type: "array"} // no Items
|
||||
},
|
||||
wantSub: "items",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
tt.mutate(&env)
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected lint error, got none")
|
||||
}
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Error(), tt.wantSub) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_L2_TypeChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Envelope)
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "format binary on non-string",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"f"}
|
||||
e.InputSchema.Properties.Map["f"] = Property{Type: "integer", Format: "binary"}
|
||||
},
|
||||
wantSub: "format: binary",
|
||||
},
|
||||
{
|
||||
name: "required key not in properties",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Required = []string{"nonexistent"}
|
||||
},
|
||||
wantSub: "required",
|
||||
},
|
||||
{
|
||||
name: "minimum >= maximum",
|
||||
mutate: func(e *Envelope) {
|
||||
min, max := 50.0, 10.0
|
||||
e.InputSchema.Properties.Order = []string{"n"}
|
||||
e.InputSchema.Properties.Map["n"] = Property{Type: "integer", Minimum: &min, Maximum: &max}
|
||||
},
|
||||
wantSub: "minimum",
|
||||
},
|
||||
{
|
||||
// Regression guard: walkForL2 must recurse into the params/data
|
||||
// sub-objects introduced by the 4-bucket inputSchema, not only the
|
||||
// top-level Properties map.
|
||||
name: "format binary on non-string inside params sub-object",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"params"}
|
||||
e.InputSchema.Properties.Map["params"] = Property{
|
||||
Type: "object",
|
||||
Properties: &OrderedProps{
|
||||
Order: []string{"id"},
|
||||
Map: map[string]Property{
|
||||
"id": {Type: "integer", Format: "binary"}, // wrong: binary on integer
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
wantSub: "format: binary",
|
||||
},
|
||||
{
|
||||
name: "sub-object required references missing property",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"data"}
|
||||
e.InputSchema.Properties.Map["data"] = Property{
|
||||
Type: "object",
|
||||
Required: []string{"ghost"}, // not in properties below
|
||||
Properties: &OrderedProps{
|
||||
Order: []string{"real"},
|
||||
Map: map[string]Property{"real": {Type: "string"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
wantSub: "ghost",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
tt.mutate(&env)
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected lint error, got none")
|
||||
}
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Error(), tt.wantSub) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_L3_CrossFieldChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Envelope)
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "danger true but risk read",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.Danger = true
|
||||
e.Meta.Risk = "read"
|
||||
},
|
||||
wantSub: "danger",
|
||||
},
|
||||
{
|
||||
name: "high-risk-write without yes",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.Risk = "high-risk-write"
|
||||
e.Meta.Danger = true
|
||||
// no yes injection
|
||||
},
|
||||
wantSub: "yes",
|
||||
},
|
||||
{
|
||||
name: "yes injected but risk not high-risk-write",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"yes"}
|
||||
e.InputSchema.Properties.Map["yes"] = Property{Type: "boolean"}
|
||||
},
|
||||
wantSub: "yes",
|
||||
},
|
||||
{
|
||||
name: "empty access_tokens",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.AccessTokens = []string{}
|
||||
},
|
||||
wantSub: "access_tokens",
|
||||
},
|
||||
{
|
||||
name: "invalid access_token value",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.AccessTokens = []string{"admin"}
|
||||
},
|
||||
wantSub: "access_tokens",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
tt.mutate(&env)
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected lint error, got none")
|
||||
}
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Error(), tt.wantSub) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasureCoverage_Counts(t *testing.T) {
|
||||
envs := []Envelope{
|
||||
{Description: "ok", Meta: &Meta{Scopes: []string{"s"}, Risk: "read", DocURL: "http://x"}},
|
||||
{Description: "", Meta: &Meta{Scopes: []string{}, Risk: "", DocURL: ""}},
|
||||
{Description: "ok2", Meta: &Meta{Scopes: []string{"s"}, Risk: "write", DocURL: "http://y"}},
|
||||
}
|
||||
c := measureCoverage(envs)
|
||||
// 2/3 have non-empty description = ~0.667
|
||||
if c["description"] < 0.66 || c["description"] > 0.67 {
|
||||
t.Errorf("description coverage = %v, want ~0.667", c["description"])
|
||||
}
|
||||
// 2/3 have non-empty scopes
|
||||
if c["scopes"] < 0.66 || c["scopes"] > 0.67 {
|
||||
t.Errorf("scopes coverage = %v, want ~0.667", c["scopes"])
|
||||
}
|
||||
// 2/3 have doc_url
|
||||
if c["doc_url"] < 0.66 || c["doc_url"] > 0.67 {
|
||||
t.Errorf("doc_url coverage = %v, want ~0.667", c["doc_url"])
|
||||
}
|
||||
// 2/3 have non-empty risk (but our builder always fills risk with "read" default — this test uses raw envs)
|
||||
if c["risk"] < 0.66 || c["risk"] > 0.67 {
|
||||
t.Errorf("risk coverage = %v, want ~0.667", c["risk"])
|
||||
}
|
||||
}
|
||||
|
||||
// isKnownDataInconsistency returns true for lint errors that originate from
|
||||
// real meta_data quality issues we still have to ship around in PR-1. With
|
||||
// Task 17b the assembler walks embedded data only, so overlay-induced
|
||||
// inconsistencies (risk-stripping) no longer appear; only the true embedded
|
||||
// meta_data data-quality patterns remain.
|
||||
//
|
||||
// As meta_data quality improves this filter should be tightened/removed so
|
||||
// TestAllEnvelopesPass becomes a hard gate again.
|
||||
func isKnownDataInconsistency(msg string) bool {
|
||||
switch {
|
||||
case strings.Contains(msg, `L3: _meta.danger=false inconsistent with risk="write"`):
|
||||
// Embedded meta_data has ~7 envelopes (e.g. attendance.user_tasks.query,
|
||||
// drive.user.subscription, mail.user_mailbox.event.subscribe) where
|
||||
// `risk="write"` but `danger` is missing (defaults to false). Needs a
|
||||
// meta_data fix to set danger=true on these write methods.
|
||||
return true
|
||||
case strings.Contains(msg, `L3: _meta.danger=true inconsistent with risk="read"`):
|
||||
// Embedded meta_data has ~9 envelopes (e.g. calendar.events.search_event,
|
||||
// drive.metas.batch_query, mail.user_mailbox.templates.create) where
|
||||
// `danger=true` but `risk` is missing (defaults to "read"). Needs a
|
||||
// meta_data fix to set the proper risk level on these methods.
|
||||
return true
|
||||
case strings.Contains(msg, "L2: field") && strings.Contains(msg, "minimum") && strings.Contains(msg, "maximum"):
|
||||
// meta_data sets min == max on some fields (e.g.
|
||||
// mail.user_mailbox.event.subscribe.event_type), which the lint reads
|
||||
// as min >= max. Real fix is in meta_data.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAllEnvelopesPass(t *testing.T) {
|
||||
failCount := 0
|
||||
knownWarnings := 0
|
||||
knownEnvelopes := map[string]bool{}
|
||||
// Use embedded data only so the gate is deterministic across machines
|
||||
// (matches Task 17b: envelope assembly is overlay-independent).
|
||||
for _, svc := range registry.EmbeddedServiceNames() {
|
||||
spec := registry.EmbeddedSpec(svc)
|
||||
envs := AssembleService(svc, spec, nil)
|
||||
for _, env := range envs {
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
continue
|
||||
}
|
||||
var realErrs []error
|
||||
for _, e := range errs {
|
||||
if isKnownDataInconsistency(e.Error()) {
|
||||
t.Logf("env %s skipped: known data-level inconsistency: %v", env.Name, e)
|
||||
knownWarnings++
|
||||
knownEnvelopes[env.Name] = true
|
||||
continue
|
||||
}
|
||||
realErrs = append(realErrs, e)
|
||||
}
|
||||
if len(realErrs) > 0 {
|
||||
for _, e := range realErrs {
|
||||
t.Errorf("%s: %v", env.Name, e)
|
||||
}
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("L1-L3 known data-level inconsistencies: %d warnings across %d envelopes (danger/risk mismatch + min==max)", knownWarnings, len(knownEnvelopes))
|
||||
if failCount > 0 {
|
||||
t.Fatalf("%d envelopes failed L1-L3 lint with non-data-level errors", failCount)
|
||||
}
|
||||
|
||||
// L4 coverage report (warn-only via t.Logf)
|
||||
all := AssembleAll(nil)
|
||||
c := measureCoverage(all)
|
||||
for metric, rate := range c {
|
||||
baseline := coverageBaseline[metric]
|
||||
if rate < baseline {
|
||||
t.Logf("L4 coverage warn: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
|
||||
} else {
|
||||
t.Logf("L4 coverage ok: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
internal/schema/path.go
Normal file
30
internal/schema/path.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import "strings"
|
||||
|
||||
// ParsePath normalizes the positional arguments of `lark-cli schema` into a
|
||||
// slice of path segments. It accepts two equivalent forms:
|
||||
//
|
||||
// lark-cli schema im.messages.reply -> single arg, split on "."
|
||||
// lark-cli schema im messages reply -> multiple args, used as-is
|
||||
// lark-cli schema "im chat.members bots" is NOT a supported form; quote
|
||||
// arguments individually if your shell needs it. Nested resources keep their
|
||||
// internal dots (e.g. "chat.members").
|
||||
//
|
||||
// Returns nil for zero args (bare invocation).
|
||||
func ParsePath(args []string) []string {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
if strings.Contains(args[0], ".") {
|
||||
return strings.Split(args[0], ".")
|
||||
}
|
||||
return []string{args[0]}
|
||||
default:
|
||||
return args
|
||||
}
|
||||
}
|
||||
34
internal/schema/path_test.go
Normal file
34
internal/schema/path_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"empty args -> nil", nil, nil},
|
||||
{"empty slice -> nil", []string{}, nil},
|
||||
{"single dotted", []string{"im.messages.reply"}, []string{"im", "messages", "reply"}},
|
||||
{"single no-dot", []string{"im"}, []string{"im"}},
|
||||
{"multi args", []string{"im", "messages", "reply"}, []string{"im", "messages", "reply"}},
|
||||
{"two args", []string{"im", "messages"}, []string{"im", "messages"}},
|
||||
{"nested resource dotted", []string{"im.chat.members.bots"}, []string{"im", "chat", "members", "bots"}},
|
||||
{"nested resource space form", []string{"im", "chat.members", "bots"}, []string{"im", "chat.members", "bots"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParsePath(tt.args)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
164
internal/schema/types.go
Normal file
164
internal/schema/types.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Envelope is the MCP Tool spec contract for a single API method command.
|
||||
type Envelope struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema *InputSchema `json:"inputSchema"`
|
||||
OutputSchema *OutputSchema `json:"outputSchema"`
|
||||
Meta *Meta `json:"_meta"`
|
||||
}
|
||||
|
||||
// InputSchema is JSON Schema Draft 2020-12 flattened.
|
||||
//
|
||||
// Required is intentionally rendered (no omitempty) so the envelope shape
|
||||
// stays stable for AI consumers — an empty []string means "no required
|
||||
// fields" rather than "schema is missing the field".
|
||||
type InputSchema struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
Properties *OrderedProps `json:"properties"`
|
||||
}
|
||||
|
||||
// OutputSchema wraps responseBody into a JSON Schema object.
|
||||
type OutputSchema struct {
|
||||
Type string `json:"type"`
|
||||
Properties *OrderedProps `json:"properties"`
|
||||
}
|
||||
|
||||
// Property is one field's JSON Schema shape, recursive.
|
||||
//
|
||||
// Required is used when Property describes a nested object (e.g. the
|
||||
// "params" / "data" sub-objects inside inputSchema): it lists which keys
|
||||
// inside that object's Properties are mandatory. Leaf fields ignore it.
|
||||
type Property struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []interface{} `json:"enum,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Example interface{} `json:"example,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
Properties *OrderedProps `json:"properties,omitempty"`
|
||||
Items *Property `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// Meta is the Lark-specific extension namespace.
|
||||
type Meta struct {
|
||||
EnvelopeVersion string `json:"envelope_version"`
|
||||
Scopes []string `json:"scopes"`
|
||||
RequiredScopes []string `json:"required_scopes"`
|
||||
AccessTokens []string `json:"access_tokens"`
|
||||
Danger bool `json:"danger"`
|
||||
Risk string `json:"risk"`
|
||||
DocURL string `json:"doc_url,omitempty"`
|
||||
Affordance *Affordance `json:"affordance,omitempty"`
|
||||
}
|
||||
|
||||
// Affordance is the hand-written overlay (PR-1 only defines the type, no YAML loaded).
|
||||
type Affordance struct {
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Examples []AffordanceCase `json:"examples,omitempty"`
|
||||
Related []string `json:"related,omitempty"`
|
||||
}
|
||||
|
||||
// AffordanceCase is one example entry: a one-line description plus a
|
||||
// ready-to-run lark-cli command string.
|
||||
type AffordanceCase struct {
|
||||
Description string `json:"description"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
|
||||
// It is used wherever JSON output must reflect meta_data.json's natural field
|
||||
// order rather than Go's default alphabetical map encoding.
|
||||
type OrderedProps struct {
|
||||
Order []string
|
||||
Map map[string]Property
|
||||
}
|
||||
|
||||
// MarshalJSON emits keys in Order, not alphabetical. If Order is empty but
|
||||
// Map has entries, fall back to alphabetical key order over Map so callers
|
||||
// that only populated Map (no explicit ordering) still see their fields.
|
||||
func (o *OrderedProps) MarshalJSON() ([]byte, error) {
|
||||
if o == nil || (len(o.Order) == 0 && len(o.Map) == 0) {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
keys := o.Order
|
||||
if len(keys) == 0 {
|
||||
keys = make([]string, 0, len(o.Map))
|
||||
for k := range o.Map {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
keyJSON, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal key %q: %w", k, err)
|
||||
}
|
||||
buf.Write(keyJSON)
|
||||
buf.WriteByte(':')
|
||||
valJSON, err := json.Marshal(o.Map[k])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal value for %q: %w", k, err)
|
||||
}
|
||||
buf.Write(valJSON)
|
||||
}
|
||||
buf.WriteByte('}')
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON parses an object preserving key order via json.Decoder.Token().
|
||||
// Used for round-tripping in tests (and future golden update flows).
|
||||
func (o *OrderedProps) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
|
||||
return fmt.Errorf("expected object, got %v", tok)
|
||||
}
|
||||
o.Order = nil
|
||||
o.Map = make(map[string]Property)
|
||||
for dec.More() {
|
||||
keyTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok := keyTok.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected string key, got %v", keyTok)
|
||||
}
|
||||
var prop Property
|
||||
if err := dec.Decode(&prop); err != nil {
|
||||
return err
|
||||
}
|
||||
o.Order = append(o.Order, key)
|
||||
o.Map[key] = prop
|
||||
}
|
||||
if _, err := dec.Token(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
58
internal/schema/types_test.go
Normal file
58
internal/schema/types_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// OrderedProps 在测试里验证:MarshalJSON 按 Order 切片顺序输出 key,跳过 Go map 默认字母序。
|
||||
func TestOrderedProps_MarshalJSON_PreservesOrder(t *testing.T) {
|
||||
op := &OrderedProps{
|
||||
Order: []string{"z_first", "a_second", "m_third"},
|
||||
Map: map[string]Property{
|
||||
"z_first": {Type: "string"},
|
||||
"a_second": {Type: "integer"},
|
||||
"m_third": {Type: "boolean"},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
want := `{"z_first":{"type":"string"},"a_second":{"type":"integer"},"m_third":{"type":"boolean"}}`
|
||||
if got != want {
|
||||
t.Errorf("OrderedProps key order not preserved:\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedProps_MarshalJSON_Empty(t *testing.T) {
|
||||
op := &OrderedProps{Order: nil, Map: nil}
|
||||
b, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(b) != "{}" {
|
||||
t.Errorf("empty OrderedProps should marshal to {}, got: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedProps_UnmarshalJSON_RoundTrip(t *testing.T) {
|
||||
in := []byte(`{"first":{"type":"string"},"second":{"type":"integer"}}`)
|
||||
var op OrderedProps
|
||||
if err := json.Unmarshal(in, &op); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if len(op.Order) != 2 {
|
||||
t.Fatalf("expected 2 keys, got %d", len(op.Order))
|
||||
}
|
||||
if op.Order[0] != "first" || op.Order[1] != "second" {
|
||||
t.Errorf("unmarshal lost order: got %v", op.Order)
|
||||
}
|
||||
if op.Map["first"].Type != "string" {
|
||||
t.Errorf("first.type mismatch")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.41",
|
||||
"version": "1.0.44",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
Command: "+access-scope-get",
|
||||
Description: "Get Miaoda app access scope configuration",
|
||||
Risk: "read",
|
||||
Scopes: []string{"spark:app.access_scope:read"},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
|
||||
@@ -27,7 +27,7 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
Command: "+access-scope-set",
|
||||
Description: "Set Miaoda app access scope (specific / public / tenant)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app.access_scope:write"},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
|
||||
@@ -21,7 +21,7 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
Command: "+html-publish",
|
||||
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:publish"},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
36
shortcuts/base/dashboard_block_get_data.go
Normal file
36
shortcuts/base/dashboard_block_get_data.go
Normal 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)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,6 +84,7 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseDashboardArrange,
|
||||
BaseDashboardBlockList,
|
||||
BaseDashboardBlockGet,
|
||||
BaseDashboardBlockGetData,
|
||||
BaseDashboardBlockCreate,
|
||||
BaseDashboardBlockUpdate,
|
||||
BaseDashboardBlockDelete,
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
33
shortcuts/common/runner_lang_test.go
Normal file
33
shortcuts/common/runner_lang_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}},
|
||||
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
||||
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
@@ -142,7 +142,7 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return ro
|
||||
}
|
||||
|
||||
// validateFetchDetail 非 xml 格式(markdown/text)不承载 block_id 与样式属性,拒绝 with-ids/full。
|
||||
// validateFetchDetail 非 xml 格式(markdown)不承载 block_id 与样式属性,拒绝 with-ids/full。
|
||||
func validateFetchDetail(runtime *common.RuntimeContext) error {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
detail := strings.TrimSpace(runtime.Str("detail"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
124
shortcuts/drive/drive_secure_label.go
Normal file
124
shortcuts/drive/drive_secure_label.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
secureLabelReadScope = "drive:file.meta.sec_label.read_only"
|
||||
secureLabelUpdateScope = "docs:secure_label:write_only"
|
||||
)
|
||||
|
||||
var secureLabelTypes = permApplyTypes
|
||||
|
||||
// DriveSecureLabelList lists secure labels available to the current user.
|
||||
var DriveSecureLabelList = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+secure-label-list",
|
||||
Description: "List secure labels available to the current user",
|
||||
Risk: "read",
|
||||
Scopes: []string{secureLabelReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
{Name: "lang", Desc: "label language", Enum: []string{"zh", "en", "ja"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
pageSize := runtime.Int("page-size")
|
||||
if pageSize < 1 || pageSize > 10 {
|
||||
return output.ErrValidation("--page-size must be between 1 and 10")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List secure labels available to the current user").
|
||||
GET("/open-apis/drive/v2/my_secure_labels").
|
||||
Params(buildSecureLabelListParams(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
data, err := runtime.CallAPI("GET",
|
||||
"/open-apis/drive/v2/my_secure_labels",
|
||||
buildSecureLabelListParams(runtime),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(data, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// DriveSecureLabelUpdate updates the secure label on a Drive file/document.
|
||||
var DriveSecureLabelUpdate = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+secure-label-update",
|
||||
Description: "Update the secure label on a Drive file or document",
|
||||
Risk: "write",
|
||||
Scopes: []string{secureLabelUpdateScope},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
|
||||
{Name: "label-id", Desc: "secure label ID to set", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Update Drive secure label").
|
||||
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
|
||||
Params(map[string]interface{}{"type": docType}).
|
||||
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
|
||||
Set("file_token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"id": runtime.Str("label-id")}
|
||||
data, err := runtime.CallAPI("PATCH",
|
||||
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
|
||||
map[string]interface{}{"type": docType},
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
if lang := runtime.Str("lang"); lang != "" {
|
||||
params["lang"] = lang
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
|
||||
return resolvePermApplyTarget(raw, explicitType)
|
||||
}
|
||||
164
shortcuts/drive/drive_secure_label_test.go
Normal file
164
shortcuts/drive/drive_secure_label_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveSecureLabelList_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_1",
|
||||
"--lang", "zh",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v2/my_secure_labels",
|
||||
`"GET"`,
|
||||
`"page_size": 5`,
|
||||
`"page_token": "page_1"`,
|
||||
`"lang": "zh"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelList_ValidatePageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--page-size", "11",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "page-size") {
|
||||
t.Fatalf("expected page-size validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "7217780879644737540", "name": "L1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"L1"`) {
|
||||
t.Fatalf("stdout missing label:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
`"PATCH"`,
|
||||
`"docx"`,
|
||||
`"id": "7217780879644737539"`,
|
||||
`"file_token": "doxTok123"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["id"] != "7217780879644737539" {
|
||||
t.Fatalf("id = %v, want label id", body["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
Status: 403,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1063013, "msg": "Security label downgrade requires approval",
|
||||
},
|
||||
})
|
||||
|
||||
targetURL := "https://example.feishu.cn/docx/doxTok123"
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", targetURL,
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected 1063013 error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
|
||||
t.Fatalf("expected raw API error message, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveSync,
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
DriveInspect,
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+sync",
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
"+inspect",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,6 +178,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
|
||||
}
|
||||
|
||||
// Preserve API-provided fields (even if this formatter doesn't otherwise use them).
|
||||
// update_time is only meaningful when the message was actually edited;
|
||||
// the server echoes update_time == create_time for unedited messages, which
|
||||
// would otherwise make every output look "updated" to downstream consumers.
|
||||
if updated {
|
||||
if v, ok := m["update_time"]; ok && v != nil {
|
||||
if s, isStr := v.(string); isStr {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
msg["update_time"] = common.FormatTime(s)
|
||||
}
|
||||
} else {
|
||||
msg["update_time"] = common.FormatTime(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := m["chat_id"]; ok {
|
||||
msg["chat_id"] = v
|
||||
}
|
||||
|
||||
@@ -95,6 +95,61 @@ func TestFormatMessageItem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMessageItem_UpdateTime_Present(t *testing.T) {
|
||||
raw := map[string]interface{}{
|
||||
"msg_type": "text",
|
||||
"message_id": "om_edit",
|
||||
"updated": true,
|
||||
"create_time": "1710500000",
|
||||
"update_time": "1710600000",
|
||||
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
|
||||
"body": map[string]interface{}{"content": `{"text":"edited"}`},
|
||||
}
|
||||
|
||||
got := FormatMessageItem(raw, nil)
|
||||
want := common.FormatTime("1710600000")
|
||||
if got["update_time"] != want {
|
||||
t.Fatalf("FormatMessageItem() update_time = %#v, want %#v", got["update_time"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMessageItem_UpdateTime_Absent(t *testing.T) {
|
||||
raw := map[string]interface{}{
|
||||
"msg_type": "text",
|
||||
"message_id": "om_no_edit",
|
||||
"updated": false,
|
||||
"create_time": "1710500000",
|
||||
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
|
||||
"body": map[string]interface{}{"content": `{"text":"hi"}`},
|
||||
}
|
||||
|
||||
got := FormatMessageItem(raw, nil)
|
||||
if _, ok := got["update_time"]; ok {
|
||||
t.Fatalf("FormatMessageItem() should not include update_time when absent, got = %#v", got["update_time"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMessageItem_UpdateTime_UnchangedMessage: real API behavior — even
|
||||
// for unedited messages, server returns update_time == create_time. We must
|
||||
// NOT echo it through, otherwise every message looks "edited" to consumers.
|
||||
// Gate the output on updated==true.
|
||||
func TestFormatMessageItem_UpdateTime_UnchangedMessage(t *testing.T) {
|
||||
raw := map[string]interface{}{
|
||||
"msg_type": "text",
|
||||
"message_id": "om_unchanged",
|
||||
"updated": false,
|
||||
"create_time": "1710500000",
|
||||
"update_time": "1710500000", // server echoes create_time
|
||||
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
|
||||
"body": map[string]interface{}{"content": `{"text":"hi"}`},
|
||||
}
|
||||
|
||||
got := FormatMessageItem(raw, nil)
|
||||
if v, ok := got["update_time"]; ok {
|
||||
t.Fatalf("FormatMessageItem() must skip update_time for unedited message, got = %#v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppLinkDomain(t *testing.T) {
|
||||
if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" {
|
||||
t.Fatalf("resolveAppLinkDomain(feishu) = %q", got)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
272
shortcuts/im/convert_lib/reactions.go
Normal file
272
shortcuts/im/convert_lib/reactions.go
Normal file
@@ -0,0 +1,272 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package convertlib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// reactionsBatchQueryMaxQueries is the server-side hard limit on queries[]
|
||||
// length for POST /im/v1/messages/reactions/batch_query (see
|
||||
// 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.
|
||||
//
|
||||
// Failure modes (warning to stderr + skip; never aborts main message output):
|
||||
// - batch_query call fails (network, 5xx, scope insufficient, rate limited):
|
||||
// each message in the failed batch is marked with "reactions_error": true
|
||||
// so callers can distinguish "fetch failed" from "no reactions exist".
|
||||
// - batch_query returns a partial result: only messages the server failed on
|
||||
// get "reactions_error": true; the successful ones get the reactions block.
|
||||
//
|
||||
// The "reactions_error" flag mirrors the "thread_replies_error" pattern in
|
||||
// thread.go so downstream consumers handle both enrichment failures uniformly.
|
||||
//
|
||||
// Output shape (only on messages that the server actually returned data for):
|
||||
//
|
||||
// "reactions": {
|
||||
// "counts": [{"reaction_type": "SMILE", "count": 3}],
|
||||
// "details": [{"reaction_id": "...", "emoji_type": "SMILE",
|
||||
// "operator": {...}, "action_time": "..."}]
|
||||
// }
|
||||
//
|
||||
// The server caps queries[] at 20 per call, so messages are split into
|
||||
// batches of size <= 20 before invoking the API.
|
||||
func EnrichReactions(runtime *common.RuntimeContext, messages []map[string]interface{}) {
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Index messages by ID so we can merge reactions back later.
|
||||
// A single message_id may appear more than once (e.g. mget --message-ids
|
||||
// om_a,om_a); every occurrence must receive the reactions block, but the
|
||||
// API should only be queried once per distinct id.
|
||||
// Walks into msg["thread_replies"] recursively so replies attached by
|
||||
// ExpandThreadReplies are enriched in the same batched call as their parent.
|
||||
idIndex := make(map[string][]map[string]interface{}, len(messages))
|
||||
var ids []string
|
||||
collectMessageNodes(messages, idIndex, &ids)
|
||||
if len(ids) == 0 {
|
||||
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)
|
||||
}
|
||||
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
|
||||
// records each map under its message_id. Distinct ids are appended to *ids in
|
||||
// first-seen order so the API is queried at most once per id.
|
||||
func collectMessageNodes(messages []map[string]interface{}, idIndex map[string][]map[string]interface{}, ids *[]string) {
|
||||
for _, msg := range messages {
|
||||
if id, _ := msg["message_id"].(string); id != "" {
|
||||
if _, seen := idIndex[id]; !seen {
|
||||
*ids = append(*ids, id)
|
||||
}
|
||||
idIndex[id] = append(idIndex[id], msg)
|
||||
}
|
||||
// thread_replies may arrive as a typed slice (set by ExpandThreadReplies)
|
||||
// or as []interface{} (e.g. when produced via JSON round-trip).
|
||||
switch nested := msg["thread_replies"].(type) {
|
||||
case []map[string]interface{}:
|
||||
collectMessageNodes(nested, idIndex, ids)
|
||||
case []interface{}:
|
||||
typed := make([]map[string]interface{}, 0, len(nested))
|
||||
for _, raw := range nested {
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
typed = append(typed, m)
|
||||
}
|
||||
}
|
||||
collectMessageNodes(typed, idIndex, ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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})
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSON(http.MethodPost,
|
||||
"/open-apis/im/v1/messages/reactions/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{"queries": queries},
|
||||
)
|
||||
if err != nil {
|
||||
warnReactionsf(stderrMu, runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
|
||||
markReactionsError(batchIDs, idIndex)
|
||||
return
|
||||
}
|
||||
|
||||
countsByMsg := groupReactionCounts(data["success_msg_reaction_counts"])
|
||||
detailsByMsg := groupReactionDetails(data["success_msg_reaction_details"])
|
||||
|
||||
// Attach the merged reactions block to every message that had any data.
|
||||
// Each id may map to >1 message map (duplicate input), so iterate the slice.
|
||||
for _, id := range batchIDs {
|
||||
msgs := idIndex[id]
|
||||
if len(msgs) == 0 {
|
||||
continue
|
||||
}
|
||||
counts := countsByMsg[id]
|
||||
details := detailsByMsg[id]
|
||||
if len(counts) == 0 && len(details) == 0 {
|
||||
continue
|
||||
}
|
||||
block := make(map[string]interface{}, 2)
|
||||
if len(counts) > 0 {
|
||||
block["counts"] = counts
|
||||
}
|
||||
if len(details) > 0 {
|
||||
block["details"] = details
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
msg["reactions"] = block
|
||||
}
|
||||
}
|
||||
|
||||
// Surface per-message failures from the API response.
|
||||
if fails, _ := data["fail_msg_reaction_details"].([]interface{}); len(fails) > 0 {
|
||||
var failedIDs []string
|
||||
for _, raw := range fails {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if id, _ := item["message_id"].(string); id != "" {
|
||||
failedIDs = append(failedIDs, id)
|
||||
}
|
||||
}
|
||||
if len(failedIDs) > 0 {
|
||||
warnReactionsf(stderrMu, runtime.IO().ErrOut,
|
||||
"warning: reactions_partial_failed: %d message(s) failed (%v)\n",
|
||||
len(failedIDs), failedIDs)
|
||||
markReactionsError(failedIDs, idIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func markReactionsError(ids []string, idIndex map[string][]map[string]interface{}) {
|
||||
for _, id := range ids {
|
||||
for _, msg := range idIndex[id] {
|
||||
msg["reactions_error"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func groupReactionCounts(raw interface{}) map[string][]interface{} {
|
||||
groups := map[string][]interface{}{}
|
||||
items, _ := raw.([]interface{})
|
||||
for _, item := range items {
|
||||
row, _ := item.(map[string]interface{})
|
||||
msgID, _ := row["message_id"].(string)
|
||||
if msgID == "" {
|
||||
continue
|
||||
}
|
||||
entries, _ := row["reaction_count"].([]interface{})
|
||||
if len(entries) == 0 {
|
||||
continue
|
||||
}
|
||||
groups[msgID] = append(groups[msgID], entries...)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func groupReactionDetails(raw interface{}) map[string][]interface{} {
|
||||
groups := map[string][]interface{}{}
|
||||
items, _ := raw.([]interface{})
|
||||
for _, item := range items {
|
||||
row, _ := item.(map[string]interface{})
|
||||
msgID, _ := row["message_id"].(string)
|
||||
if msgID == "" {
|
||||
continue
|
||||
}
|
||||
entries, _ := row["message_reaction_items"].([]interface{})
|
||||
if len(entries) == 0 {
|
||||
continue
|
||||
}
|
||||
groups[msgID] = append(groups[msgID], entries...)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
410
shortcuts/im/convert_lib/reactions_test.go
Normal file
410
shortcuts/im/convert_lib/reactions_test.go
Normal file
@@ -0,0 +1,410 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package convertlib
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEnrichReactions_Success exercises the basic happy path: messages that
|
||||
// carry reactions get a "reactions" field, messages without reactions stay
|
||||
// untouched.
|
||||
func TestEnrichReactions_Success(t *testing.T) {
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/reactions/batch_query") {
|
||||
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
queries, _ := payload["queries"].([]interface{})
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("queries size = %d, want 2", len(queries))
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"success_msg_reaction_counts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "om_a",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "SMILE", "count": 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
"success_msg_reaction_details": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "om_a",
|
||||
"message_reaction_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"reaction_id": "react_1",
|
||||
"emoji_type": "SMILE",
|
||||
"operator": map[string]interface{}{"operator_id": "ou_x", "operator_type": "user"},
|
||||
"action_time": "1710600000",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fail_msg_reaction_details": []interface{}{},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
messages := []map[string]interface{}{
|
||||
{"message_id": "om_a"},
|
||||
{"message_id": "om_b"},
|
||||
}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
reactionsA, ok := messages[0]["reactions"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("message om_a missing reactions field: %#v", messages[0])
|
||||
}
|
||||
counts, _ := reactionsA["counts"].([]interface{})
|
||||
if len(counts) != 1 {
|
||||
t.Fatalf("om_a counts = %d, want 1", len(counts))
|
||||
}
|
||||
details, _ := reactionsA["details"].([]interface{})
|
||||
if len(details) != 1 {
|
||||
t.Fatalf("om_a details = %d, want 1", len(details))
|
||||
}
|
||||
|
||||
if _, ok := messages[1]["reactions"]; ok {
|
||||
t.Fatalf("message om_b should not have reactions field (none in response): %#v", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_BatchSize splits queries into batches of 20 (server-side
|
||||
// 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{}{},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
messages := make([]map[string]interface{}, 25)
|
||||
for i := range messages {
|
||||
messages[i] = map[string]interface{}{"message_id": fmt.Sprintf("om_%02d", i)}
|
||||
}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_APIFailure: when the API call fails, messages stay
|
||||
// without a reactions field but get marked with reactions_error=true so
|
||||
// downstream consumers can distinguish "fetch failed" from "no reactions".
|
||||
// Mirrors the thread_replies_error pattern in thread.go.
|
||||
func TestEnrichReactions_APIFailure(t *testing.T) {
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("simulated network error")
|
||||
}))
|
||||
|
||||
messages := []map[string]interface{}{
|
||||
{"message_id": "om_a"},
|
||||
{"message_id": "om_b"},
|
||||
}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
for _, m := range messages {
|
||||
if _, ok := m["reactions"]; ok {
|
||||
t.Fatalf("message %v should have no reactions after API failure", m["message_id"])
|
||||
}
|
||||
if v, _ := m["reactions_error"].(bool); !v {
|
||||
t.Fatalf("message %v should have reactions_error=true after API failure, got = %#v",
|
||||
m["message_id"], m["reactions_error"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_PartialFailure: when batch_query returns a fail entry
|
||||
// for one ID, that message gets reactions_error=true while the rest stay
|
||||
// clean (no error flag) and keep their normal reactions block.
|
||||
func TestEnrichReactions_PartialFailure(t *testing.T) {
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"success_msg_reaction_counts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "om_ok",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fail_msg_reaction_details": []interface{}{
|
||||
map[string]interface{}{"message_id": "om_bad"},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
ok := map[string]interface{}{"message_id": "om_ok"}
|
||||
bad := map[string]interface{}{"message_id": "om_bad"}
|
||||
EnrichReactions(runtime, []map[string]interface{}{ok, bad})
|
||||
|
||||
if _, has := ok["reactions"]; !has {
|
||||
t.Fatalf("om_ok should have reactions: %#v", ok)
|
||||
}
|
||||
if v, _ := ok["reactions_error"].(bool); v {
|
||||
t.Fatalf("om_ok must not carry reactions_error: %#v", ok)
|
||||
}
|
||||
if _, has := bad["reactions"]; has {
|
||||
t.Fatalf("om_bad should have no reactions block: %#v", bad)
|
||||
}
|
||||
if v, _ := bad["reactions_error"].(bool); !v {
|
||||
t.Fatalf("om_bad should have reactions_error=true, got = %#v", bad["reactions_error"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_EmptyMessages: no messages -> no API call at all.
|
||||
func TestEnrichReactions_EmptyMessages(t *testing.T) {
|
||||
called := false
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
called = true
|
||||
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
|
||||
}))
|
||||
|
||||
EnrichReactions(runtime, nil)
|
||||
EnrichReactions(runtime, []map[string]interface{}{})
|
||||
|
||||
if called {
|
||||
t.Fatalf("API should not be called when messages list is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_SkipsMessagesWithoutID: messages missing message_id
|
||||
// (defensive) should not crash and not be sent in queries.
|
||||
func TestEnrichReactions_SkipsMessagesWithoutID(t *testing.T) {
|
||||
var sentIDs []string
|
||||
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{})
|
||||
for _, q := range queries {
|
||||
qm, _ := q.(map[string]interface{})
|
||||
id, _ := qm["message_id"].(string)
|
||||
sentIDs = append(sentIDs, id)
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
|
||||
}))
|
||||
|
||||
messages := []map[string]interface{}{
|
||||
{"message_id": "om_a"},
|
||||
{}, // no message_id
|
||||
{"message_id": ""},
|
||||
{"message_id": "om_b"},
|
||||
}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
if want := []string{"om_a", "om_b"}; !reflect.DeepEqual(sentIDs, want) {
|
||||
t.Fatalf("sent IDs = %v, want %v", sentIDs, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_WalksThreadReplies: thread_replies nested under a parent
|
||||
// message must also be enriched, in the same batch_query call as the parent —
|
||||
// otherwise the parent gets reactions but its replies don't, leaving the output
|
||||
// inconsistent.
|
||||
func TestEnrichReactions_WalksThreadReplies(t *testing.T) {
|
||||
var observedQueriedIDs []string
|
||||
var observedCallCount int
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
observedCallCount++
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
var payload map[string]interface{}
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
queries, _ := payload["queries"].([]interface{})
|
||||
for _, q := range queries {
|
||||
qm, _ := q.(map[string]interface{})
|
||||
id, _ := qm["message_id"].(string)
|
||||
observedQueriedIDs = append(observedQueriedIDs, id)
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"success_msg_reaction_counts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "om_top",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"message_id": "om_reply1",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "THUMBSUP", "count": 2},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"message_id": "om_reply2",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "HEART", "count": 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
reply1 := map[string]interface{}{"message_id": "om_reply1"}
|
||||
reply2 := map[string]interface{}{"message_id": "om_reply2"}
|
||||
top := map[string]interface{}{
|
||||
"message_id": "om_top",
|
||||
"thread_replies": []map[string]interface{}{reply1, reply2},
|
||||
}
|
||||
messages := []map[string]interface{}{top}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
if observedCallCount != 1 {
|
||||
t.Fatalf("expected 1 batched API call, got %d", observedCallCount)
|
||||
}
|
||||
sort.Strings(observedQueriedIDs)
|
||||
if want := []string{"om_reply1", "om_reply2", "om_top"}; !reflect.DeepEqual(observedQueriedIDs, want) {
|
||||
t.Fatalf("queried IDs = %v, want %v (top + thread_replies)", observedQueriedIDs, want)
|
||||
}
|
||||
|
||||
if _, ok := top["reactions"]; !ok {
|
||||
t.Fatalf("top message missing reactions")
|
||||
}
|
||||
if _, ok := reply1["reactions"]; !ok {
|
||||
t.Fatalf("reply1 missing reactions — thread_replies were not walked")
|
||||
}
|
||||
if _, ok := reply2["reactions"]; !ok {
|
||||
t.Fatalf("reply2 missing reactions — thread_replies were not walked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_DuplicateMessageID: when the caller passes two distinct
|
||||
// message maps that share the same message_id (e.g. mget --message-ids om_a,om_a),
|
||||
// both maps must receive the same reactions block, and the API must be queried
|
||||
// for the id only once.
|
||||
func TestEnrichReactions_DuplicateMessageID(t *testing.T) {
|
||||
var observedQueriesPerCall []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{})
|
||||
observedQueriesPerCall = append(observedQueriesPerCall, len(queries))
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"success_msg_reaction_counts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "om_a",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "SMILE", "count": 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
first := map[string]interface{}{"message_id": "om_a"}
|
||||
second := map[string]interface{}{"message_id": "om_a"}
|
||||
other := map[string]interface{}{"message_id": "om_b"}
|
||||
messages := []map[string]interface{}{first, other, second}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
if want := []int{2}; !reflect.DeepEqual(observedQueriesPerCall, want) {
|
||||
t.Fatalf("queries-per-call = %v, want %v (each id once, no dup fetch)", observedQueriesPerCall, want)
|
||||
}
|
||||
|
||||
firstReactions, firstOK := first["reactions"]
|
||||
secondReactions, secondOK := second["reactions"]
|
||||
if !firstOK {
|
||||
t.Fatalf("first om_a entry missing reactions")
|
||||
}
|
||||
if !secondOK {
|
||||
t.Fatalf("second om_a entry missing reactions — dup msg_id was dropped")
|
||||
}
|
||||
if !reflect.DeepEqual(firstReactions, secondReactions) {
|
||||
t.Fatalf("dup entries reactions differ: %#v vs %#v", firstReactions, secondReactions)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ var ImChatMessageList = common.Shortcut{
|
||||
Description: "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",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.base:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
@@ -34,6 +34,7 @@ var ImChatMessageList = common.Shortcut{
|
||||
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
@@ -54,7 +55,12 @@ var ImChatMessageList = common.Shortcut{
|
||||
dryParams[k] = vs[0]
|
||||
}
|
||||
}
|
||||
return d.GET("/open-apis/im/v1/messages").Params(dryParams)
|
||||
d = d.GET("/open-apis/im/v1/messages").Params(dryParams)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
|
||||
Desc("Reaction enrichment: queries returned messages (including thread_replies expanded inline) in batches of up to 20. Pass --no-reactions to skip.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Under bot identity, --user-id is not supported; require --chat-id only.
|
||||
@@ -111,16 +117,28 @@ 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)
|
||||
convertlib.ResolveSenderNames(runtime, messages, nameCache)
|
||||
convertlib.AttachSenderNames(messages, nameCache)
|
||||
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
convertlib.EnrichReactions(runtime, messages)
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"messages": messages,
|
||||
|
||||
@@ -22,16 +22,22 @@ var ImMessagesMGet = common.Shortcut{
|
||||
Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ids := common.SplitCSV(runtime.Str("message-ids"))
|
||||
return common.NewDryRunAPI().GET(buildMGetURL(ids))
|
||||
d := common.NewDryRunAPI().GET(buildMGetURL(ids))
|
||||
if !runtime.Bool("no-reactions") {
|
||||
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
|
||||
Desc("Reaction enrichment: queries returned messages in batches of up to 20 to attach the reactions block (operator, action_time, counts). Pass --no-reactions to skip.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ids := common.SplitCSV(runtime.Str("message-ids"))
|
||||
@@ -60,15 +66,25 @@ 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)
|
||||
convertlib.AttachSenderNames(messages, nameCache)
|
||||
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
convertlib.EnrichReactions(runtime, messages)
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"messages": messages,
|
||||
|
||||
@@ -30,7 +30,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
Command: "+messages-search",
|
||||
Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:message", "contact:user.basic_profile:readonly"},
|
||||
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
@@ -49,6 +49,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate search results"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
req, err := buildMessagesSearchRequest(runtime)
|
||||
@@ -68,12 +69,17 @@ var ImMessagesSearch = common.Shortcut{
|
||||
} else {
|
||||
d = d.Desc("Step 1: search messages")
|
||||
}
|
||||
return d.
|
||||
d = d.
|
||||
POST("/open-apis/im/v1/messages/search").
|
||||
Params(dryParams).
|
||||
Body(req.body).
|
||||
Desc("Step 2 (if results): GET /open-apis/im/v1/messages/mget?message_ids=... — batch fetch message details (max 50)").
|
||||
Desc("Step 3 (if results): POST /open-apis/im/v1/chats/batch_query — fetch chat names for context")
|
||||
if !runtime.Bool("no-reactions") {
|
||||
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
|
||||
Desc("Step 4 (if results): reaction enrichment in batches of up to 20 messages. Pass --no-reactions to skip.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildMessagesSearchRequest(runtime)
|
||||
@@ -153,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
|
||||
}
|
||||
@@ -184,6 +196,9 @@ var ImMessagesSearch = common.Shortcut{
|
||||
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
|
||||
convertlib.ResolveSenderNames(runtime, enriched, nameCache)
|
||||
convertlib.AttachSenderNames(enriched, nameCache)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
convertlib.EnrichReactions(runtime, enriched)
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"messages": enriched,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -24,8 +24,8 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
@@ -33,6 +33,7 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
{Name: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
threadFlag := runtime.Str("thread")
|
||||
@@ -65,10 +66,15 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
return d.
|
||||
d = d.
|
||||
GET("/open-apis/im/v1/messages").
|
||||
Params(params).
|
||||
Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
|
||||
Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
threadId := runtime.Str("thread")
|
||||
@@ -115,15 +121,25 @@ 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)
|
||||
convertlib.ResolveSenderNames(runtime, messages, nameCache)
|
||||
convertlib.AttachSenderNames(messages, nameCache)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
convertlib.EnrichReactions(runtime, messages)
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"thread_id": threadId,
|
||||
|
||||
85
shortcuts/im/mp4_box_test.go
Normal file
85
shortcuts/im/mp4_box_test.go
Normal 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
109
shortcuts/mail/body_file.go
Normal 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
|
||||
}
|
||||
1156
shortcuts/mail/lint/linter.go
Normal file
1156
shortcuts/mail/lint/linter.go
Normal file
File diff suppressed because it is too large
Load Diff
920
shortcuts/mail/lint/linter_test.go
Normal file
920
shortcuts/mail/lint/linter_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user