mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
501bf539af
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
142 lines
4.4 KiB
Go
142 lines
4.4 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package convertlib
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// resourceDownloadConcurrency caps in-flight resource downloads. Each download
|
|
// is a GET plus a local disk write; capping at 3 keeps the
|
|
// messages/{id}/resources/{key} endpoint well under any gateway-layer rate
|
|
// ceiling while still cutting wall-clock versus a serial loop.
|
|
const resourceDownloadConcurrency = 3
|
|
|
|
// ResourceDownloader downloads one resource and returns its local path and
|
|
// size in bytes. messageID is the resource's owning message id (the download
|
|
// API path parameter), key is the file_key/image_key, and fileType is the
|
|
// download API resource type ("image" or "file"). A non-nil error means the
|
|
// single resource failed; the engine isolates that failure (fail-silent).
|
|
type ResourceDownloader func(ctx context.Context, messageID, key, fileType string) (string, int64, error)
|
|
|
|
// EnrichResourceDownloads walks every message node (including nested
|
|
// thread_replies) for "resources" blocks attached during formatting, downloads
|
|
// each distinct (message_id, key) once with bounded concurrency, and fills
|
|
// local_path/size_bytes back into every ref sharing that key. A single
|
|
// resource failing is isolated: its ref is flagged "error": true and a warning
|
|
// is written to stderr, while the main message and the other resources are
|
|
// unaffected (S2.STA-DES-P0-002 weak-dependency isolation).
|
|
func EnrichResourceDownloads(runtime *common.RuntimeContext, messages []map[string]interface{}, dl ResourceDownloader) {
|
|
if len(messages) == 0 || dl == nil {
|
|
return
|
|
}
|
|
|
|
type refKey struct {
|
|
messageID string
|
|
key string
|
|
}
|
|
groups := make(map[refKey][]map[string]interface{})
|
|
types := make(map[refKey]string)
|
|
var order []refKey
|
|
|
|
collectResourceRefs(messages, func(ref map[string]interface{}) {
|
|
messageID, _ := ref["message_id"].(string)
|
|
key, _ := ref["key"].(string)
|
|
if messageID == "" || key == "" {
|
|
return
|
|
}
|
|
rk := refKey{messageID: messageID, key: key}
|
|
if _, seen := groups[rk]; !seen {
|
|
order = append(order, rk)
|
|
if t, _ := ref["type"].(string); t != "" {
|
|
types[rk] = t
|
|
}
|
|
}
|
|
groups[rk] = append(groups[rk], ref)
|
|
})
|
|
if len(order) == 0 {
|
|
return
|
|
}
|
|
|
|
ctx := runtime.Ctx()
|
|
var stderrMu sync.Mutex
|
|
|
|
download := func(rk refKey) {
|
|
if err := ctx.Err(); err != nil {
|
|
return
|
|
}
|
|
localPath, size, err := dl(ctx, rk.messageID, rk.key, types[rk])
|
|
if err != nil {
|
|
warnSyncf(&stderrMu, runtime.IO().ErrOut,
|
|
"warning: resource_download_failed: %s/%s: %v\n", rk.messageID, rk.key, err)
|
|
for _, ref := range groups[rk] {
|
|
ref["error"] = true
|
|
}
|
|
return
|
|
}
|
|
for _, ref := range groups[rk] {
|
|
ref["local_path"] = localPath
|
|
ref["size_bytes"] = size
|
|
}
|
|
}
|
|
|
|
// Single-resource fast path: no goroutine overhead, deterministic stderr.
|
|
if len(order) == 1 {
|
|
download(order[0])
|
|
return
|
|
}
|
|
|
|
// Bounded-concurrency fan-out. Each goroutine writes only to its own
|
|
// (message_id, key) group's ref maps — distinct keys map to distinct ref
|
|
// maps, so there is no shared mutable state besides the stderr mutex.
|
|
sem := make(chan struct{}, resourceDownloadConcurrency)
|
|
var wg sync.WaitGroup
|
|
for _, rk := range order {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func() {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
download(rk)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// collectResourceRefs walks messages (and nested thread_replies) and invokes fn
|
|
// for every resource ref map found in each node's "resources" block. Handles
|
|
// both the typed []map[string]interface{} (in-memory, set by formatMessageItem)
|
|
// and []interface{} (post JSON round-trip) shapes, mirroring collectMessageNodes.
|
|
func collectResourceRefs(messages []map[string]interface{}, fn func(ref map[string]interface{})) {
|
|
for _, msg := range messages {
|
|
switch res := msg["resources"].(type) {
|
|
case []map[string]interface{}:
|
|
for _, ref := range res {
|
|
fn(ref)
|
|
}
|
|
case []interface{}:
|
|
for _, raw := range res {
|
|
if ref, ok := raw.(map[string]interface{}); ok {
|
|
fn(ref)
|
|
}
|
|
}
|
|
}
|
|
switch nested := msg["thread_replies"].(type) {
|
|
case []map[string]interface{}:
|
|
collectResourceRefs(nested, fn)
|
|
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)
|
|
}
|
|
}
|
|
collectResourceRefs(typed, fn)
|
|
}
|
|
}
|
|
}
|