* feat(shortcuts): import typed shortcut framework from c07b64621
* feat(extension): add public command contract
* feat(extension): compile and register business commands
* feat(cmd): assemble business command sets
* fix(auth): derive login domains from shortcuts
* test(extension): add business command test runtime
* ci: verify generated Go sources
* test(auth): match help and interactive domains
* fix(extension): validate command path segments
* fix(extension): satisfy generator and error guards
* fix(extension): follow generated domain naming contract
* fix(command): complete extension runtime contracts
* test(command): cover public extension surface
* fix(command): remove unused typed runtime APIs
* fix(command): address follow-up review findings
* fix(command): enforce extension v1 contracts
* fix(command): remove unreachable compatibility wrappers
* fix(auth): keep scope-less domains addressable via --domain, matching main
Move the declared-scope filter from allKnownDomains to the interactive
selector only. On main, a scope-less shortcut domain (event) passes
--domain validation and fails later with "no matching scopes found";
the previous unified filter changed that to "unknown domain" and
dropped it from the --help list. The interactive picker still hides
scope-less domains — selecting one can only fail.
* feat(command): add PathSegment for user-provided path values
Business code concatenates IDs into request paths but had no public
escape helper (internal/validate.EncodePathSegment is unreachable from
extension/command). Mirror its url.PathEscape semantics, use it in the
business command examples, and pin the traversal defense: the validator
decodes percent-encoding before the canonical check, so both raw and
escaped dot sequences fail same-origin validation.
* docs(command): add runnable chat-brief distribution example
Mirror the audit-observer precedent: a buildable wrapper main under
examples/ showing WithCommandSets against the real distribution shape
(plugins, strict mode, and service commands stay enabled). Covers the
single-read command (Validate, shared DryRun request, CallJSON,
PathSegment, Tips) and a Page[T] list command whose pagination flags
come from the compiler. The testdata/wrapper fixture stays test-only.
Verified offline: --help renders tag-driven parameters, +chat-brief-list
exposes --page-all/--page-limit/--page-delay, and --dry-run previews the
request with a fake env token and no network access.
* fix(command): normalize page envelopes and bound CollectAllPages
Two pagination contract fixes from the extension design (owner plan §8.3):
Page decoding accepted only a literal "items" array, so endpoints that
spell their list field differently (drive uses files, some responses use
records) walked every page while decoding nothing — CollectAllPages then
returned an empty set marked complete, and downstream writes ran against
it. Each page now normalizes its single top-level array field into
Page.Items; zero or multiple array fields fail closed with a typed
invalid-response error.
CollectAllPages previously reused the user-facing --page-limit maximum
(1000) as its walk bound. A complete-set collection holds every page in
memory before the workflow's writes run, so it now uses the design's
dedicated workflow bound of 100 pages.
* feat(commandhost): note bounded repetition in Page dry-run previews
A Page[T] command's dry-run can only show the first request; fabricating
response-dependent page tokens is forbidden. Append the bounded-repeat
explanation to the previewed request (preserving any business
description), matching the design's dry-run contract.
Also give the example's list command a --page-token resume flag seeded
into the request, documenting the resume convention: the framework owns
--page-all/--page-limit/--page-delay while the starting cursor is a
business-declared input, independent of --page-all.
* docs(command): generate domain constants with English comments
The generator emitted Chinese titles while the rest of the public
extension packages document in English. Switch the generator to the
"en" service title and regenerate. The generator already rejects a
domain missing either locale, so the switch keeps its own guard.
* docs(command): mark the host adapter read surface
The Host* types, InspectCommand, InspectDomain and CloneSets exist for
lark-cli's host adapter, not for business commands, but nothing said so
at the symbols themselves. They cannot move to a subpackage: a Command
holds its declaration unexported, so a sibling package has no way to
reach it, and moving the wire types to internal/ would cycle back
through CommandMetadata and CommandContext.
Also correct HostPagination, which is not adapter-only -- ContextOptions
and commandtest both carry it.
* refactor(command): type CommandMetadata.Service as DomainName
A set already declares its domain through ExtendDomain(DomainIm), yet
every command repeated the same domain as a bare string that only the
host compiler checked. Typing the field points authors at the generated
enum and forces an explicit conversion when the value comes from a
string variable.
This does not make a mistyped literal a compile error -- an untyped
constant still converts to DomainName -- so the mismatch check in
CompileSets stays the actual net. The example and the wrapper fixture
now declare command.DomainIm.
The chat-brief example was also not gofmt-clean, which the CI format
gate would have caught.
* refactor(command): extract the command-set assembly steps
buildInternalWithConfig is an orchestrator, and the business command
sets were compiled inline inside it. Move that step to
resolveShortcutSnapshot so the entry point reads as one call and the
built-in/external merge has a name.
newCommand carried six near-identical blocks that each nil-checked a
hook and wrapped it in the same type assertion. Split them into one
binder per hook shape; Normalize and Validate now share bindArgsHook
since their signatures match. The behaviour is unchanged: an undeclared
hook still erases to nil, and an empty renderer map still yields nil.
* perf(shortcuts): stop re-cloning an already-isolated snapshot
AllShortcuts deep-copies because a Shortcut carries slice fields whose
backing arrays a shallow copy would share: an external distribution
mutating registered[0].Flags[0] would corrupt the process-global list.
That copy is worth its ~165us over 500+ shortcuts.
Paying it four times per startup is not. auth, schema and the mount path
each cloned the snapshot again, but they receive it from
AllShortcutsWithExternal with no third-party code in between, and nothing
in this repository mutates a shortcut element -- mountDeclarative takes a
value receiver and only replaces slice headers. Drop those three copies
and document the boundary on AllShortcuts so the next reader does not
reintroduce them.
Startup drops from four full clones to one. Benchmarks pin the remaining
cost so a regression points at a new clone rather than at growth in the
shortcut set.
* fix(command): align the commandtest page bound and escape wrapper paths
Two review findings, both of which let a business command pass its tests
and then misbehave in production.
The commandtest recorder walked 1000 pages for a complete-set collection
while the host adapter stops at 100, so a command tested against 300
pages of fixtures would fail its first real --page-all run with
PaginationLimitError. The bound now lives in internal/pagination, which
both sides already import, and the hard-limit test scripts itself from
that constant instead of restating 1000 -- the literal was what let the
two drift apart.
The testdata wrapper concatenated args.ID straight into the request path,
contradicting PathSegment's own documented rule and the chat-brief
example. ValidateRequestView does not cover this: "abc/other-users-file"
cleans to itself, so an unescaped separator silently retargets the
request. Since testdata is what an integrator copies first, route both
call sites through one readRequest helper, mirroring chat-brief.
The e2e assertion could not have caught it either -- PathSegment("chat_1")
is "chat_1", so the check passed with or without the call. It now sends
"chat/1" and asserts %2F reaches the wire; removing PathSegment fails it.
* fix(command): deny network to the pre-confirmation hooks and four review findings
Normalize and Validate run before the high-risk confirmation gate, and
both received the full CommandContext, so a high-risk business command
could POST or DELETE from Validate and leave remote side effects behind
before the user was ever asked to confirm. Moving the gate earlier would
contradict the documented hook order and would also make --dry-run
require --yes. The design already forbids this from the other side --
Validate is specified as parameter checking that issues no request -- so
enforce that instead: Normalize and Validate get a context whose CallJSON
and CollectPages refuse, while PreflightScopes stays available. The guard
sits in CommandContext rather than in the wiring, so a future adapter
that wires the callbacks anyway still cannot reach the API. commandtest
mirrors it, otherwise a command would pass its tests and fail only in
production.
Page.Items now starts non-nil. It is declared required;nonnullable, but a
zero-item collection encoded as {"items":null}, which a caller generating
types from the published schema would reject.
NewCmdAuthWithRecovery and NewCmdSchemaWithVisibility are restored as
wrappers. Both were dropped for shortcut-aware variants, and both are
reachable from outside this module: CommandVisibility is an ordinary
exported func type, and *recovery.Projector cannot be named by an outside
caller but can be passed as nil. A signature test now pins them.
The path-traversal fixture said "../../secret", which the deterministic
gate rejects as a generic credential assignment -- the reason CI is
currently red. The filename carries no meaning; it is now "../../outside".
* test(cmd): exercise the retained constructors instead of naming them
The compatibility wrappers restored for outside callers are unreachable
from inside this repository by construction, so the incremental dead-code
gate rejected them. A signature-only assertion did not help: taking a
function value and discarding it leaves the body unreachable, and it
proved nothing about whether the wrapper still builds a working command.
Call each one and assert the command it returns. NewCmdAuthWithRecovery
is called with a nil projector, which is the exact call an outside module
can make and the reason the wrapper has to keep compiling.
Verified with the same deadcode version CI runs: neither function is
reported, and no other function in this branch's files is either.
* docs(command): document the hook contract and pin dry-run note idempotency
Hooks is the first type a business author reads and carried no field
documentation, so the rules lived only in the design doc: which of DryRun
and DryRunE to set, that setting both fails to compile, that Execute owns
the API call and must not write stdout, and that Normalize and Validate
run before the confirmation gate and therefore get no network.
The choice between DryRun and DryRunE is not old-versus-new -- neither is
legacy. It follows from whether building the preview can fail, which is
now what the field docs say.
Also pin the dry-run note as idempotent. convertDryRun writes the
bounded-repeat note into the projection it builds, never back into the
hook's *DryRun, and DryRunAPI.Desc assigns rather than appends, so a hook
that caches and returns the same preview cannot accumulate the note. Both
properties were true and neither was tested.
* refactor(command): settle the dry-run constructor, tips, and domain enum
Three narrowings of the V1 business-command contract, none of which has a
published compatibility surface: extension/command does not exist on main.
Preview and NewDryRun were the same constructor twice -- one empty, one
seeded with requests. Fold them into a variadic NewDryRun. Every existing
NewDryRun() call keeps compiling, and the domain word in the contract is
now spelled one way. The type DryRun already owns that identifier in this
package, so naming the constructor DryRun outright cannot compile.
Drop Metadata.Tips. It was pure passthrough into common.Shortcut.Tips and
nothing in the execution path read it, so business commands lose only the
ability to declare help tips; the repository's own typed shortcuts keep
theirs. The mount test asserted a tip reached the rendered help as proof
that metadata survives the extension -> commandhost -> common.Shortcut ->
help conversion; it now asserts the risk line, which travels the same path.
Hand-write the domain enumeration and delete the generator. Generating
from shortcuts.AllShortcuts silently omitted approval, attendance and
mindnotes: all three are published under `lark-cli --help` and served by
typed and raw API commands, they just own no shortcut. The enum is now
the 23 domains the CLI actually exposes.
Those three would otherwise have been constants that compile and always
fail, because CompileSets derived its mountable domains from the same
shortcut list. It now reads the service registry, and shortcuts/register.go
already creates a domain command group on demand when no built-in occupies
it, so a business command can mount under a shortcut-less domain.
* ci: stop generating extension/command
The domain enumeration is hand-written now and extension/command holds no
go:generate directive, so the path was a no-op that still read as if the
package carried generated files.
* refactor(command): drop DryRunE and let the preview render like a built-in
DryRunE has no counterpart in the shipped CLI: `git show
main:shortcuts/common/types.go` has no such field, it arrived with the
typed-shortcut framework this branch imported, and no shortcut in the
repository sets one. Business commands get the single DryRun hook that
built-in shortcuts have. Validate already runs before it and owns the
error channel, so a preview that cannot be built still fails there with a
typed error -- which is what the repointed tests now assert, end to end
through --dry-run and through commandtest.Preview.
Also stop appending the bounded-repeat note. convertDryRun added "with
--page-all, repeats with the returned page_token until exhaustion or
--page-limit" to every Page[T] preview, so an external command's dry-run
carried a sentence its author never wrote. Built-in paginated shortcuts
say this themselves when they want it (im_chat_members_list.go calls
dry.Desc), and external commands now do the same: the framework renders
the description it was given and nothing else.
The dry-run context keeps refusing requests -- runner.go does the same for
built-ins, so removing that would be the divergence, not the alignment.
Only the word changes: "offline" was our own vocabulary for what the rest
of the CLI calls dry-run.
* refactor(command): drop the partial-failure outcome from the contract
Business commands now return Success only. Partial, OutcomeDefinition,
PartialFailureDefinition and FailedItemDefinition leave the public surface
along with Execution.Partial and the host adapter's receipt conversion.
Result keeps its outcome field. It is no longer a choice -- Success is the
only value -- but it is also how the host tells a returned Result apart
from the zero value that accompanies an error, which is the check
commandtest.Execute makes before reporting "returned both Result and
error". Collapsing it to nothing would delete that signal.
The exemplar commands that returned Partial keep their scenarios: a
best-effort scope failure still marks every item failed and appends the
snapshot, and the multi-call audit still records the owner it could not
resolve. That information lives in the command's own Data (Items[].State
plus Failures), not in the outcome, so the tests assert the same facts and
only the outcome assertion is gone. The deep-copy test moved its nested
JSON exemplar from FailedValues to InputDefault.Value, keeping
cloneJSONValue covered.
shortcuts/common still defines PartialFailure for built-in typed
shortcuts. That is the imported framework, untouched here.
* refactor(shortcuts): walk pages once for built-in and external commands
Commit 4d0c6ea61 added internal/pagination, moved PaginateInto onto it,
and then wrote a second caller for externally declared commands. Both
assembled the same Walk options, cloned the same params, read the same
cursor and mapped the same walk error; only the policy source, the call
path and the accumulator ever differed.
Those three now parameterize one pageWalk. PaginateInto keeps calling
through the RuntimeContext and keeps its per-page progress line; external
commands keep CallTypedAPI, the walker's context and their undecoded
pages, which the public contract needs because it decodes them into its
own Page[T]. Behavior is unchanged on both sides -- the external walk
still leaves Wait nil, which internal/pagination fills with WaitContext,
so --page-delay works exactly as before.
pageWalk is deliberately generic-free so one struct serves both callers;
the typed half of the built-in path moved to addDecodedPage.
* refactor(shortcuts): make the external page walk PaginateInto's twin
CollectCommandPages now differs from PaginateInto only where the context
type forces it. It takes the same PageAccumulator, decodes each page into
T through the same addDecodedPage, returns the same *output.PaginationMeta
and reads the same state out of the same walk, in the same order.
What is left is what the interface cannot supply. An externally declared
command compiles in the business module, so it holds a CommandContext
rather than a *RuntimeContext: the context arrives as a parameter because
the interface carries none, the call goes through CallTypedAPI, and there
is no progress line because deciding to print one needs StderrIsTerminal,
JqExpr and Format, none of which the interface exposes.
The all parameter stays. It is the complete-set policy CollectAllPages
depends on -- collect to exhaustion under the hard page bound instead of
obeying --page-all and --page-limit -- and PaginateInto has no way to
express it, since resolvePaginationPolicy only ever reads flags. Dropping
it would quietly turn a command that must see the whole set into one a
user can truncate with --page-limit 1.
CommandPageCollection is gone with it: pages accumulate in commandhost's
own accumulator, the way every built-in shortcut already accumulates its
own. One consequence of sharing the decode: a page whose response carries
no data object is now an error on this path too, as it always was for
built-ins.
* fix(shortcuts): reject recursive Data and Args types during compilation
shapeForType and compileStructShape called each other without recording the
Go types already being walked, so a self-referential type recursed forever.
The walk ran during command registration and ended in a stack overflow --
a fatal runtime error rather than a panic, so no recover boundary could
contain it and one extension command took the whole CLI down before --help,
schema, or any unrelated command could run. That also broke
CompileErasedDefinition's documented promise to compile without panic.
Thread the struct types open on the current recursion path through both
functions and return a compile error on a repeat visit, pointing at the
explicit Shape escape hatch. Membership is scoped to the path, not the whole
walk, so a type reused as a sibling or at another depth stays legal.
Covers self-reference through a slice and through a pointer, mutual
recursion through two types, the JSON-encoded Args path, and the public
CompileErasedDefinition contract.
* fix(command): share one result protocol between the host and commandtest
commandtest.Execute only checked that Data carried the expected type. Generic
erasure leaves a correctly typed zero Data behind, so a business command that
returned Result{} instead of Success(data) passed the type assertion and the
test reported success. Production rejects the same result, which left
extension authors with green tests and a command that failed on every real
invocation -- exactly the guarantee commandtest exists to provide.
Add ValidateHostResult in extension/command and call it from both
commandtest.Execute and the host adapter's execute hook, so the two surfaces
cannot drift. It rejects an empty or unsupported outcome and mirrors the
pagination receipt checks the host already applies: declared Page output,
pages of at least one, non-negative items, and next-token state consistent
with completeness.
RunWithFlags is covered because it delegates to Execute.
* feat(command): expose reusable download capabilities
* test(command): make the chat-brief example testable and cover its hooks
The example declared both commands as inline Definition literals handed
straight to Define. Define erases the type parameters and returns an opaque
Command that cannot hand its Definition back, while commandtest.Execute takes
the Definition -- so the shape the example demonstrated could not be unit
tested at all. Neither shipped example had a test file, so nobody had walked
the copy-the-example-then-add-a-test path.
Lift both declarations into Definition-returning functions, the shape the
repository's own commandtest suites already use, and keep the compiled
Commands as package vars so main is unchanged. The configuration bodies are
untouched.
Add the tests that shape exists for: the single-read projection and its
Validate rejection, plus the Page[T] contract for default single-page reads
and a --page-all walk through RunWithFlags. All four run offline through the
commandtest recorder.
* test(commandtest): cover the ordered URL download script
Recorder.ReplyURL shipped without a caller, so the incremental dead code
gate flagged it as new unreachable code and blocked the branch. The existing
URL download test uses the unordered RespondFile constructor, which never
exercises the URL assertion ReplyURL exists for.
Mirror the ReplyJSON pair: one test walks two scripted URLs in order and
checks the recorded source URLs, content types, and artifacts; the other
points DownloadURL at an unscripted URL and expects the mismatch error.
* feat(command): accept @file input for external commands
V1 rejected the file value source, leaving external commands with inline
flags and stdin only. A process has one stdin, so a command whose body is
too large or too quoted for the shell -- an XML document update is the case
that surfaced this -- had no second way to receive it, and the caller had to
fall back to shell escaping.
Nothing downstream was missing: resolveInputFlags already resolves @path and
the @@ escape through the invocation's FileIO, help renders the "@file"
affordance, and legacyInputSources maps the source onto the compiled flag.
The gap was the public constant and the host allow-list.
Export SourceFile and let it through compilation. Unknown sources still fail
the same way, which the rewritten host test now pins alongside the compiled
flag actually carrying both extra sources -- silently dropping a declared
source is the regression worth catching.
Give the wrapper fixture a command whose content flag declares all three
sources, and drive it end to end: @file and stdin both reach the request
body, and the help text advertises them.
* revert(content): drop the default content exports from the extension surface
Exporting the repository's embedded skills and affordance trees turned two
content directories into Go packages, because go:embed cannot reach up out of
a package directory and the repository root is package main. That cost two
things: a .go file living inside an authored-content tree, where a future
content type is silently omitted until someone edits the glob, and an embed
directive per tree where the root previously covered both in one.
The need it served does not exist. The distribution driving this work ships
its own skills and does not consume the official set, and a wrapper that does
want them can supply a tree through cmd.SetEmbeddedSkillContent, which is what
extension/platform already documents.
Restore content_embed.go, the SkillsOverlay comments, and the platform README
to their main state, and delete the two exporting packages. The example and
fixture wrappers now ship no embedded content, which is what a wrapper that
does not compile the repository root actually gets; the e2e assertion that
depended on inheriting lark-doc goes with it.
* fix(command): close the review findings on API surface and storage commit
Five findings from the extension-v1 review, each verified by a test that fails
against the previous implementation.
Source compatibility of auth.LoginOptions. The shortcut snapshot was an
unexported field on a struct that appears in the exported runF signature, which
ends positional literals for every caller outside this module. It becomes a
closure capture plus an explicit authLoginRun parameter, and the six domain
helpers collapse into domainResolver methods, removing five xxxWithShortcuts
twins that only tests reached. common.Shortcut.DryRunE goes too: it was a new
exported field with no production caller. The unexported typed field stays, so
Shortcut itself is still not positional-literal compatible -- that is a
deliberate remaining gap, since relocating it would need a global mutable map or
a wider signature change.
One canonical wire projection. queryValues stringified and dropped nils for the
live call while the dry-run preview and the pagination walk forwarded raw values,
so a preview could describe a request the runtime would never send. canonicalQuery
is now the only projection and all three consumers derive from it. Dry-run output
for numeric parameters therefore reads "20" instead of 20, matching what the
query string actually carries.
No-clobber as a storage guarantee. IfExistsFail checked existence, downloaded,
then committed with a rename that replaces unconditionally, so a target created
during the transfer was silently overwritten. The commit step is now an optional
ExclusiveFileIO capability: content lands in a temp file and is published with
Link, which refuses an existing target and never exposes a partial file. A
provider without the capability is refused rather than served a guarantee it
cannot keep.
V1 public surface. Removes NewDomain and its options (host compilation rejected
them), HostDomain.IsNew, reservedRootNames, and the unproducible
ResultMetaDefinition.Count; narrows Hooks.Renderers to a single PrettyRenderer,
since pretty was the only key the compiler accepted; and demotes the generic
authoring layer in shortcuts/common to unexported, as no production code outside
that package used it.
commandtest runs the production compiler. Execute, RunWithFlags and Preview now
share compileForTest, so a wrong tag, Shape or relation fails in the unit test
instead of at CLI startup. Applying this surfaced two long-standing contract
violations in the package's own fixture.
* refactor(command): keep a single authoring contract
* revert(schema): keep the schema command blind to shortcuts
The schema command serves the generated API catalog only, matching main.
Drop the shortcut contract lookup and completion from cmd/schema and
restore the constructor surface. ShortcutSchema stays on the sealed
commandbridge surface, where the host compiler tests assert the contract;
the CLI itself no longer consumes it. The surface scenario and wrapper
e2e pin the boundary from the other side: schema resolution and
completion must not see mounted shortcuts.
---------
Co-authored-by: sang-neo03 <266690410+sang-neo03@users.noreply.github.com>
Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
Previously, keys longer than the OpenAPI uuid limit were sent to the server and returned a generic field validation failed error. This change rejects overlong keys locally with a typed validation error that identifies
--idempotency-key and the 50-character limit.
Read the server-provided sender_name for both user and bot senders (previously
only users resolved) so message-read commands display bot names instead of raw
ids. The CLI opts into server-side name filling by sending with_sender_name=true
on chat-messages-list, threads-messages-list, messages-mget and messages-search,
as well as on the inline fetches that render nested senders: merge_forward
sub-messages and auto-expanded thread replies. Without it those nested-only
senders carry no sender_name and, with no fallback, render as raw ids.
Names come solely from the server (single source of truth): there is no contact
or mention fallback, so the contact scope is dropped from these four commands and
the contact/mention resolution code is removed. A sender the server does not name
falls back to its id; system messages show no name. The resolved name is exposed
in the existing `name` field (backward compatible); the duplicate raw
`sender_name` is stripped while the full `sender_i18n_names` map and `open_bot_id`
are preserved for consumers. No new permission scope is required. Updates the
lark-im skill docs.
Add a dedicated +chat-members-list shortcut that lists chat members,
returning users and bots in separate users[] / bots[] buckets. It owns its
pagination loop (mirroring the paginateLoop conventions: per-page log line,
--page-limit cap, non-advancing-token guard) because the list_members
response is multi-bucket: the generic --page-all merger is built for
single-array responses and would silently drop the bots[] bucket and the
final-page truncations[] signal.
Highlights:
- merges users[] and bots[] across pages; takes truncations[] / has_more /
page_token from the last page so a server-side cap is never hidden
- surfaces truncations[] with a loud stderr warning when the server caps a
bucket due to security config (the list is incomplete)
- --member-types filter (user/bot), --member-id-type, and the standard
--page-all / --page-limit / --page-token flags
- with --page-all and no explicit --page-size, uses the max page size to
minimize round-trips
- docs: SKILL.md Shortcuts table + references/lark-im-chat-members-list.md
Fix IM shortcut behavior for audio messages to match the Feishu/Lark file upload API: --audio is for voice messages and supports only Opus audio. Non-Opus local/URL inputs such as mp3 and wav are now rejected before upload with an actionable typed validation error. Users can still send those files as attachments with --file
Support content_v2 post message conversion in IM shortcuts so newer post payloads render with the expected markdown, mention, and image formats while preserving fallback compatibility with legacy content.
Previously, im.message.receive events with message_type: interactive surfaced the raw JSON
payload as content, requiring callers to manually parse the card schema. This PR introduces a
user_dsl renderer (ConvertInteractiveEventContent) that converts interactive card content into
structured human-readable text — consistent with how text, post, image, and other message
types are already handled.
The output format is <card title="..." subtitle="...">...</card>, with each card element type
serialised to a readable representation (markdown body, button links, table rows, chart summaries,
etc.).
The 4 im query commands had three inconsistent sort conventions and leaked upstream API jargon (ByCreateTimeAsc, member_count_desc) directly to users. This PR unifies them on a single rule — --sort selects a field, --order selects a direction, both from fixed enums — so an agent only ever picks from an enum, never constructs a string. Old flags (--sort-type, --sort-by, and --sort on messages/threads) are kept as hidden silent aliases (no deprecation warning), so existing scripts keep working byte-for-byte.
Add a server-side --chat-modes filter to the im +chat-search shortcut so
users can restrict results to regular groups and/or topic groups.
Change-Id: Ia59c2c05fb2e8e45bd741c8531ca0e3ca69de2f3
Block 1 — field completion: audio renders <audio key="..." duration="Xs"/>
(falls back to [Voice: Xs]/[Voice]); post renders emotion -> :emoji_type:,
applies text.style (bold/italic/underline/lineThrough), passes through md;
sticker unchanged.
Block 2 — opt-in --download-resources (default off) on +chat-messages-list,
+messages-mget, +threads-messages-list: extract downloadable resource refs
during formatting (image/file/audio/video/media + post-embedded; sticker
excluded; merge_forward sub-items carry the top-level container message_id,
since the resources endpoint rejects sub-item ids with "234003 File not in
msg" and can only fetch a forwarded resource through the container; thread
replies get their own block), then download each distinct (message_id,
file_key) once into ./lark-im-resources/ with bounded concurrency (3), filling
back local_path/size_bytes; single-resource failures are isolated (error:true +
stderr warning). Path safety reuses normalizeDownloadOutputPath +
ResolveSavePath.
Batch download keys each file on disk by its unique file_key basename and only
appends an extension (from the Content-Disposition filename or MIME type) —
it does NOT substitute the server's Content-Disposition filename. Otherwise two
resources whose servers return the same filename (e.g. download.bin) would
resolve to the same ./lark-im-resources/ path and clobber each other
concurrently. The friendly "adopt the server filename" behavior is kept only
for an explicit +messages-resources-download with no --output.
Resource ref extraction guards against self-referential / cyclic merge_forward
prefetch maps (a real API sub-item list can include the container's own id or a
back-pointing merge_forward) via a visited set, so extraction terminates instead
of overflowing the stack. The container message_id is threaded through nested
merge_forwards as the download owner.
Also: document the feature (including the im:message:readonly scope requirement)
in skills/lark-im — SKILL.md is generated from skill-template/domains/im.md
(edit the source), plus the hand-written message-enrichment + 3 command
references.
Change-Id: I3a71d7d1b193130f551aaa2ec180ac1500d59ac4
Meego: https://meego.larkoffice.com/5e96d7bff4e7c525510f9156/story/detail/7331555925
lark-cli update currently discovers official skills by parsing unstable human-oriented `skills add --list` output. This prefers the stable official JSON index for skills discovery, while preserving the existing CLI-list fallback and full-install fallback for resilience.
Changes:
- Add official skills index JSON parsing in `internal/skillscheck/sync.go`
- Prefer JSON index discovery before existing CLI list parsing in `internal/skillscheck/sync.go`
- Add reason-chain details when both discovery layers fall back to `fallbackFullInstall`
- Add bounded HTTPS fetch for `https://open.feishu.cn/.well-known/skills/index.json` in `internal/selfupdate/updater.go`
- Add unit tests for parser behavior, discovery fallback order, and fallback detail reasons in `internal/skillscheck/sync_test.go`
Co-authored-by: zhaoyukun.yk <zhaoyukun.yk@bytedance.com>
Adds feed shortcut management to the im domain: pin chats to the user's feed sidebar, list pinned entries, and unpin them. Three new shortcuts wrap the im/v2/feed_shortcuts OpenAPI routes, which currently expose CHAT-type entries only and accept user identity only.
The card message converter (shortcuts/im/convert_lib/card.go) previously
rendered a subset of card fields and had several mode-gated behaviors that
caused information to be silently dropped in concise mode. This PR audits
every element handler and brings the output up to full fidelity:
missing header fields are rendered, collapsible panels always expand, rich
element metadata (images, audio, video, overflow URLs, person names) is no
longer hidden behind cardModeDetailed, and several format bugs are fixed.
Change-Id: I422474ab6b7505e48ab5697793900df035be6e29
Interactive card messages (msg_type: interactive) can contain @user elements in their card
body. The json_attachment.at_users field stores resolved user info, but the user_id there is
the sender-side platform user_id — not the reading app's canonical open_id. When the backend
populates a mention_key on each at_users entry, it signals that the API-level mentions[]
array carries a more authoritative open_id and display name for the reading context. This PR adds
support for this two-level lookup: it threads the raw mentions[] array into the card converter,
indexes it by mention_key for O(1) access, and renders the canonical open_id + display name
whenever the link is resolvable. All existing fallback paths (no mention_key, nil mentions) are
preserved without behavioral change.
Change-Id: I00f846d76482adba315d07361c35909b71ca74c7
Simplifies the markdown-to-post rendering pipeline in the IM shortcut. The previous
implementation split markdown at blank-line boundaries into multiple post paragraphs,
using zero-width space (\u200B) sentinel characters to preserve visual spacing.
While well-intentioned, this approach introduced fragility around edge cases such as
blank lines inside fenced code blocks, messages with only blank lines, and interactions
with the heading-normalization pass. This change consolidates rendering back into a
single {"tag":"md"} segment, making the output more predictable, the code significantly
easier to follow, and the test surface easier to maintain.
Change-Id: Ic2870ecbcb31ae7d36121f120102f2ff964f5169
The size==1 (64-bit "largesize") branch of all three MP4 box walkers
(findMP4Box, readMp4DurationBytes, readMp4Duration) set boxEnd to the raw
largesize instead of offset+largesize — even though the 32-bit branch right
below correctly uses offset+size. Two consequences:
- Correctness: for any MP4 that carries a 64-bit box size at a non-zero
offset, the box walk is computed from the wrong end, so the moov/mvhd
lookup is truncated and the media duration is silently lost.
- Robustness/security (CWE-190): the unguarded uint64->int(64) conversion of
a largesize with the high bit set yields a negative boxEnd. The in-memory
walkers then assign it to offset and feed it back as a slice index
(data[offset:]), panicking with "slice bounds out of range" and crashing
the CLI on a crafted or corrupt MP4. This is reachable via URL-sourced IM
media, whose bytes the caller does not control.
Fix: compute boxEnd as offset+largesize (matching the 32-bit branch) and
reject largesize values smaller than the 16-byte header or larger than the
remaining input. Malformed media now honours the parsers' best-effort
contract by returning 0/-1 instead of panicking, and the bounds guarantee
the conversion can no longer overflow.
Add regression tests covering both the overflow (must not panic) and a
64-bit box at a non-zero offset (must walk correctly).
Add a new --types flag (string_slice; values from {group, p2p}) to
+chat-list, backed by the new GET /open-apis/im/v1/chats `types` query
parameter. Accepts CSV (--types group,p2p) and repeated-flag forms
(--types group --types p2p).
Defaults to groups-only (backward compatible). Under user identity,
p2p single chats appear with chat_mode="p2p" plus p2p_target_type /
p2p_target_id fields. Under bot identity:
- --types=p2p alone is rejected at validation
- --types=p2p,group is silently downgraded to types=group (no runtime
notice; skill docs document this contract)
Updates Shortcut.Description, lark-im SKILL.md (frontmatter trigger
+ shortcut table row), and the chat-list reference doc with command
examples, the new parameter, output field documentation, and a
dedicated "Bot identity and p2p" section.
Change-Id: I637ce23b3c6ce4ec350f0ac26dbac8120761bb71
Follow-up to #1095. The reactions auto-enrichment shipped, but on busy chats the strictly-serial per-resource fetches in EnrichReactions, ExpandThreadReplies, and merge_forward expansion stretched the command's wall time above 14s — enough that wrapper agents (30–60s wall-clock budgets) saw timeouts even though the CLI itself never errored. This PR parallelizes all three with the same bounded-concurrency pattern, batches the follow-up contact-API sender resolution so it doesn't fan back out into a serial stall, and fixes two correctness bugs that surfaced during review. Scoped to convert_lib/{reactions,thread,merge,content_convert}.go + tests + the 4 shortcut Execute hooks + the reference doc.
Change-Id: I0206d10ad204382170bd42aec67f82578923736e
- Pull messages now auto-call im.reactions.batch_query and attach a
reactions block (counts + details) to each message. Stops AI from
misjudging "user already reacted" as "no response yet" and
re-sending duplicate reactions. Server caps queries[] at 20 per
call, so messages are split into batches of size <= 20.
- Edited messages additionally surface update_time. The server echoes
update_time == create_time for unedited messages too, so the field
is only emitted when updated == true; otherwise every message
output would look "edited". The value is read via an explicit
string assertion + TrimSpace so empty strings are filtered properly
(the previous `v != ""` was a no-op for non-string types).
- All four message-pulling shortcuts (+messages-mget,
+chat-messages-list, +messages-search, +threads-messages-list) get
a --no-reactions opt-out flag for callers that want to skip the
extra round-trip.
- Each shortcut declares im:message.reactions:read on its
UserScopes/BotScopes (or Scopes for the user-only search command) so
the auth flow covers the new dependency.
- Each shortcut's --dry-run output now lists the
reactions/batch_query call (or omits it when --no-reactions is set),
so callers can audit the full set of API calls before execution.
- Warnings go through runtime.IO().ErrOut (forbidigo lint requires
IOStreams over os.Stderr in shortcut code).
- Duplicate message_id inputs (e.g. mget --message-ids om_a,om_a)
attach the reactions block to every entry while still querying the
API only once per distinct id.
- EnrichReactions walks msg["thread_replies"] recursively, and mget/
chat-messages-list call it after ExpandThreadReplies, so replies
receive reactions in the same batched call as their parent message.
- When the batch_query call fails or returns per-message failures,
the affected messages get reactions_error=true (mirroring the
thread_replies_error flag from thread.go) so consumers can
distinguish "fetch failed" from "no reactions exist" by reading
stdout alone, without depending on the stderr warning channel.
- lark-im skill docs: the default-enrichment contract lives in a
standalone references/lark-im-message-enrichment.md so the generated
SKILL.md can't strand it on regeneration. The four read references
and the raw reactions API reference link to it, and the template
source skill-template/domains/im.md carries a durable pointer.
Change-Id: Ia9ea74b11945644262bb25c6503fb9b2003c6c98
Add im +chat-list shortcut wrapping GET /open-apis/im/v1/chats (previously not exposed via lark-cli).
Add --exclude-muted to both +chat-search and +chat-list: client-side filter that calls POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status after each page and drops is_muted=true chats.
Introduce shortcuts/im/mute_filter.go with pure helpers and an orchestrator (MaybeApplyMuteFilter) shared by both shortcuts.
Change-Id: I22221ac5835667f58cbd40b34de75825d2445d1c
Adds --chat-mode group|topic to lark-cli im +chat-create so users and AI agents can create 话题群 (topic chats) directly via the CLI. Without this, requests to create a topic chat silently fall back to a normal conversation group. Default remains group; chat_mode is now always emitted in the POST /open-apis/im/v1/chats request body.
Change-Id: I79385e2e8606f84e3f27de240d1b41037bf51261
Add IM flag shortcut commands to lark-cli, enabling users to create, list, and cancel bookmarks on messages and threads via +flag-create, +flag-list, and +flag-cancel.
Change-Id: I8f87f0eadf83fb59b024a3b9fe67b23d363abe0a
- Assemble applinks via net/url to ensure proper encoding
- Normalize message position values across more numeric types
- Avoid leaking null message_app_link; assemble when missing
- Update unit tests to assert URL semantics and cover edge cases
Change-Id: Ic473cb563c8a648c4f6677c32b25b9f371a0f84e
Add --at-chatter-ids flag to shortcuts/im/im_messages_search.go that
passes filter.at_chatter_ids to the search API, restricting results to
messages that @mention any of the given user open_ids. Messages that
Update im +chat-messages-list to request only thread root messages from /open-apis/im/v1/messages by default. This aligns the shortcut request shape with topic-group usage and makes the intended API behavior explicit in both runtime params and dry-run output.
Change-Id: I3901b27e70b0e4db506ff199eb03c96fcf98671d
Unify lark-cli im +messages-search pagination flags to use int semantics consistently.
Previously, page-limit was registered as an int flag while page-size was still handled as a string flag and parsed manually. This led to inconsistent runtime behavior inside the same shortcut and allowed test helpers to drift from the real CLI flag registration.
Change-Id: Ic4876f4ca7f410a8fe3234e08e41b54ce26990d9
Preserve fenced code blocks and balanced-parenthesis URLs when converting markdown to post elements. Add regression tests covering code-block URLs and wiki-style links.
Change-Id: I709a3daf3635402848c96b5122edfc67979ed1a4
When downloading message resources, the saved filename was always derived from
file_key (e.g. file_v2_abc123.xlsx), ignoring the original filename the
sender uploaded. This PR resolves filenames from the Content-Disposition
response header first, falling back to Content-Type-based extension inference
only when the header is absent.
Change-Id: I68b48cf428aa8aded4ad9d55fa042f9d68263c3a
The POST /contact/v3/users/basic_batch endpoint caps user_ids at 1~10
per request, but batchResolveByBasicContact was chunking by 50. When
user identity needed to resolve >10 unresolved sender names, the
single oversized request was rejected, causing the batch resolver to
bail out and leave sender names empty for the rest.
Lower batchSize to 10 and add a unit test that exercises 25 missing
IDs and asserts they are sent as 10 / 10 / 5.
mediaBuffer.FileName() returned a hardcoded "media"+ext, so IM file
messages sent via URL displayed generic names like "media.pdf" instead
of the filename parsed from the URL. This regressed the pre-refactor
tempfile path which at least carried a unique basename.
Store fileNameFromURL(rawURL) on the buffer and return it from
FileName(). Split newMediaBuffer so the URL-to-filename wiring is
reachable from tests without going through the hardened download
transport.
Also lock in that the local upload branch keeps filepath.Base(filePath)
as file_name, so the URL fix cannot silently regress the local branch
later.
Change-Id: I729b217e9dc9237aeb89c2b89df86a37ad64a840
The /open-apis/im/v1/images and /open-apis/im/v1/files APIs now support User Access Token (UAT) in addition to Tenant Access Token (TAT). Previously the upload helpers forced bot identity unconditionally; this PR aligns them with the surrounding shortcut's --as flag so uploads and sends share the same identity.
Change-Id: I3d7fd528dd30fef9aea2d88100ceb03db4c7c3ac
Add range download support for IM OAPI resources so lark-cli can reliably download large files. This improves stability for large payloads and network interruptions.
Change-Id: I38e6f6f9cf8b8711dc40650d19c77503f4e44989
The chat_p2p/batch_query endpoint that resolves a user's p2p chat_id
requires user identity. Calling +chat-messages-list with --user-id
under bot identity previously failed silently or returned wrong
results.
- Validate: reject --user-id when runtime.IsBot(), with a hint to
pass --as user or use --chat-id instead
- resolveP2PChatID: add defensive guard for the same condition in
case the helper is reached via another path
- Update --user-id flag description and the lark-im skill reference
to note the user-identity requirement
- Tests: add bot-rejection cases for Validate and resolveP2PChatID,
switch p2p happy-path tests to a user-identity runtime helper