Files
larksuite-cli/shortcuts/im/convert_lib/helpers.go
haozhenghua-code db7d3cb64d fix(im): cap basic_batch user_ids at 10 per API limit (#551)
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.
2026-04-18 18:41:30 +08:00

267 lines
7.7 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ParseJSONObject parses a raw JSON string into a map.
func ParseJSONObject(raw string) (map[string]interface{}, error) {
var v map[string]interface{}
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return nil, err
}
return v, nil
}
func invalidJSONPlaceholder(kind string) string {
if kind == "" {
return "[Invalid JSON content]"
}
return fmt.Sprintf("[Invalid %s JSON]", kind)
}
// BuildMentionKeyMap builds a key→name lookup from the message "mentions" array.
func BuildMentionKeyMap(mentions []interface{}) map[string]string {
m := map[string]string{}
for _, raw := range mentions {
item, _ := raw.(map[string]interface{})
key, _ := item["key"].(string)
name, _ := item["name"].(string)
if key != "" && name != "" {
m[key] = name
}
}
return m
}
// ResolveMentionKeys replaces mention keys in text with @name format.
func ResolveMentionKeys(text string, mentionMap map[string]string) string {
for key, name := range mentionMap {
text = strings.ReplaceAll(text, key, "@"+name)
}
return text
}
// formatTimestamp converts a Unix timestamp string (seconds or milliseconds) to
// "YYYY-MM-DD HH:mm" local time. Values with fewer than 10 digits are treated as
// seconds; larger values are treated as milliseconds.
// Returns empty string if the input is empty or unparseable.
func formatTimestamp(ts string) string {
if ts == "" {
return ""
}
n, err := strconv.ParseInt(ts, 10, 64)
if err != nil || n == 0 {
return ""
}
if len(strings.TrimLeft(ts, "+-")) >= 13 { // milliseconds timestamps are typically 13+ digits
n /= 1000
}
return time.Unix(n, 0).Local().Format("2006-01-02 15:04:05")
}
// ResolveSenderNames batch-resolves sender open_ids to display names.
// The cache map is used to share already-resolved IDs across calls; newly resolved
// names are written back into it. Pass an empty map if no prior cache exists.
//
// Step 1: extract names from message mentions (free, no API call).
// Step 2: for remaining unresolved IDs, call contact batch API (requires contact:user.base:readonly).
// Silently returns partial results on API error.
//
// [#22] Changed from variadic `cache ...map[string]string` to a required parameter.
// The variadic form was misleading: every caller passed exactly one map, and the function
// body both modified it and returned it, making the dual semantics confusing.
func ResolveSenderNames(runtime *common.RuntimeContext, messages []map[string]interface{}, cache map[string]string) map[string]string {
nameMap := cache
if nameMap == nil {
nameMap = make(map[string]string)
}
// Step 1: extract names from mentions (free)
for _, msg := range messages {
switch mentions := msg["mentions"].(type) {
case []interface{}:
for _, raw := range mentions {
m, _ := raw.(map[string]interface{})
id, _ := m["id"].(string)
name, _ := m["name"].(string)
if id != "" && name != "" && strings.HasPrefix(id, "ou_") {
nameMap[id] = name
}
}
case []map[string]interface{}:
// Backward-compatible path for tests/callers that construct typed slices.
for _, m := range mentions {
id, _ := m["id"].(string)
name, _ := m["name"].(string)
if id != "" && name != "" && strings.HasPrefix(id, "ou_") {
nameMap[id] = name
}
}
}
}
// Collect sender IDs still missing a name
seen := make(map[string]bool)
var missingIDs []string
for _, msg := range messages {
sender, ok := msg["sender"].(map[string]interface{})
if !ok {
continue
}
senderType, _ := sender["sender_type"].(string)
if senderType != "user" {
continue
}
id, _ := sender["id"].(string)
if id == "" || !strings.HasPrefix(id, "ou_") || seen[id] || nameMap[id] != "" {
continue
}
seen[id] = true
missingIDs = append(missingIDs, id)
}
if len(missingIDs) == 0 {
return nameMap
}
// Step 2: batch resolve remaining via contact API.
// Use basic_batch for user identity (lighter permission requirement),
// full batch for bot identity.
if runtime.As().IsBot() {
batchResolveUsers(runtime, missingIDs, nameMap)
} else {
batchResolveByBasicContact(runtime, missingIDs, nameMap)
}
return nameMap
}
// batchResolveByBasicContact resolves user names via POST /contact/v3/users/basic_batch.
// This API has lighter permission requirements and works with user identity
// even when the target user is not in the app's visible range.
// Response uses "users" (not "items") and "user_id" (not "open_id").
// The basic_batch endpoint caps user_ids at 10 per request.
func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []string, nameMap map[string]string) {
const batchSize = 10
for i := 0; i < len(missingIDs); i += batchSize {
end := i + batchSize
if end > len(missingIDs) {
end = len(missingIDs)
}
batch := missingIDs[i:end]
data, err := runtime.DoAPIJSON(http.MethodPost,
"/open-apis/contact/v3/users/basic_batch",
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
map[string]interface{}{"user_ids": batch},
)
if err != nil {
break
}
users, _ := data["users"].([]interface{})
for _, item := range users {
user, _ := item.(map[string]interface{})
userID, _ := user["user_id"].(string)
name, _ := user["name"].(string)
if userID != "" && name != "" {
nameMap[userID] = name
}
}
}
}
func batchResolveUsers(runtime *common.RuntimeContext, missingIDs []string, nameMap map[string]string) {
const batchSize = 50
for i := 0; i < len(missingIDs); i += batchSize {
end := i + batchSize
if end > len(missingIDs) {
end = len(missingIDs)
}
batch := missingIDs[i:end]
parts := []string{"user_id_type=open_id"}
for _, uid := range batch {
parts = append(parts, "user_ids="+url.QueryEscape(uid))
}
apiURL := "/open-apis/contact/v3/users/batch?" + strings.Join(parts, "&")
data, err := runtime.DoAPIJSON(http.MethodGet, apiURL, nil, nil)
if err != nil {
break
}
items, _ := data["items"].([]interface{})
for _, item := range items {
user, _ := item.(map[string]interface{})
openID, _ := user["open_id"].(string)
name, _ := user["name"].(string)
if openID != "" && name != "" {
nameMap[openID] = name
}
}
}
}
// AttachSenderNames enriches message sender objects with resolved display names.
// Senders whose name could not be resolved are left unchanged (id is preserved).
func AttachSenderNames(messages []map[string]interface{}, nameMap map[string]string) {
for _, msg := range messages {
sender, ok := msg["sender"].(map[string]interface{})
if !ok {
continue
}
id, _ := sender["id"].(string)
if name, ok := nameMap[id]; ok {
sender["name"] = name
}
}
}
// xmlEscapeBody escapes XML special characters for use in element body content.
var xmlBodyEscaper = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
)
func xmlEscapeBody(s string) string {
return xmlBodyEscaper.Replace(s)
}
// escapeMDLinkText escapes square brackets in Markdown link text to prevent link injection.
func escapeMDLinkText(s string) string {
s = strings.ReplaceAll(s, `[`, `\[`)
s = strings.ReplaceAll(s, `]`, `\]`)
return s
}
// extractPostBlocksText extracts plain text from post-style content blocks ([][]element).
func extractPostBlocksText(blocks []interface{}) string {
var lines []string
for _, para := range blocks {
elems, _ := para.([]interface{})
var sb strings.Builder
for _, el := range elems {
elem, _ := el.(map[string]interface{})
sb.WriteString(renderPostElem(elem))
}
if s := sb.String(); s != "" {
lines = append(lines, s)
}
}
return strings.Join(lines, "\n")
}