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
130 lines
4.9 KiB
Go
130 lines
4.9 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package im
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/internal/output"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||
)
|
||
|
||
const maxMGetMessageIDs = 50
|
||
|
||
var ImMessagesMGet = common.Shortcut{
|
||
Service: "im",
|
||
Command: "+messages-mget",
|
||
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", "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)"},
|
||
downloadResourcesFlag,
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
ids := common.SplitCSV(runtime.Str("message-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.")
|
||
}
|
||
if runtime.Bool("download-resources") {
|
||
d = d.Desc(downloadResourcesDryRunDesc)
|
||
}
|
||
return d
|
||
},
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
ids := common.SplitCSV(runtime.Str("message-ids"))
|
||
if len(ids) == 0 {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids is required (comma-separated om_xxx)").WithParam("--message-ids")
|
||
}
|
||
if len(ids) > maxMGetMessageIDs {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(ids)).WithParam("--message-ids")
|
||
}
|
||
for _, id := range ids {
|
||
if _, err := validateMessageID(id); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
ids := common.SplitCSV(runtime.Str("message-ids"))
|
||
mgetURL := buildMGetURL(ids)
|
||
|
||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, mgetURL, nil, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
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)
|
||
|
||
downloadResources := runtime.Bool("download-resources")
|
||
messages := make([]map[string]interface{}, 0, len(rawItems))
|
||
for _, item := range rawItems {
|
||
m, _ := item.(map[string]interface{})
|
||
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources))
|
||
}
|
||
|
||
convertlib.ResolveSenderNames(runtime, messages, nameCache)
|
||
convertlib.AttachSenderNames(messages, nameCache)
|
||
convertlib.ExpandThreadRepliesWithResources(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit, downloadResources)
|
||
if !runtime.Bool("no-reactions") {
|
||
convertlib.EnrichReactions(runtime, messages)
|
||
}
|
||
if downloadResources {
|
||
enrichMessageResourceDownloads(runtime, messages)
|
||
}
|
||
|
||
outData := map[string]interface{}{
|
||
"messages": messages,
|
||
"total": len(messages),
|
||
}
|
||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||
if len(messages) == 0 {
|
||
fmt.Fprintln(w, "No messages found.")
|
||
return
|
||
}
|
||
var rows []map[string]interface{}
|
||
for _, msg := range messages {
|
||
row := map[string]interface{}{
|
||
"message_id": msg["message_id"],
|
||
"time": msg["create_time"],
|
||
"type": msg["msg_type"],
|
||
}
|
||
if sender, ok := msg["sender"].(map[string]interface{}); ok {
|
||
if name, _ := sender["name"].(string); name != "" {
|
||
row["sender"] = name
|
||
}
|
||
}
|
||
if content, _ := msg["content"].(string); content != "" {
|
||
row["content"] = convertlib.TruncateContent(content, 40)
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
output.PrintTable(w, rows)
|
||
fmt.Fprintf(w, "\n%d message(s)\ntip: use --format json to view full message content\n", len(messages))
|
||
})
|
||
return nil
|
||
},
|
||
}
|