Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 151 additions & 3 deletions shortcuts/im/convert_lib/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -72,12 +73,14 @@
return time.Unix(n, 0).Local().Format("2006-01-02 15:04:05")
}

// ResolveSenderNames batch-resolves sender open_ids to display names.
// ResolveSenderNames batch-resolves sender 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).
// Step 2: batch-resolve bot open_ids via bot basic info API.
// Step 3: resolve the current bot/app sender from bot metadata as fallback.
// Step 4: for remaining unresolved user 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.
Expand Down Expand Up @@ -113,6 +116,10 @@
}
}

resolveAppSenderNamesFromContent(messages, nameMap)
resolveBotSenderNames(runtime, messages, nameMap)
resolveCurrentBotSenderName(runtime, messages, nameMap)

// Collect sender IDs still missing a name
seen := make(map[string]bool)
var missingIDs []string
Expand All @@ -136,7 +143,7 @@
return nameMap
}

// Step 2: batch resolve remaining via contact API.
// Step 4: batch resolve remaining users via contact API.
// Use basic_batch for user identity (lighter permission requirement),
// full batch for bot identity.
if runtime.As().IsBot() {
Expand All @@ -148,6 +155,147 @@
return nameMap
}

var appWelcomeNameRe = regexp.MustCompile(`我是\s+\*\*([^*\n]+)\*\*`)

func resolveAppSenderNamesFromContent(messages []map[string]interface{}, nameMap map[string]string) {
for _, msg := range messages {
sender, ok := msg["sender"].(map[string]interface{})
if !ok {
continue

Check warning on line 164 in shortcuts/im/convert_lib/helpers.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/helpers.go#L164

Added line #L164 was not covered by tests
}
senderType, _ := sender["sender_type"].(string)
idType, _ := sender["id_type"].(string)
id, _ := sender["id"].(string)
if !isBotSenderType(senderType) || idType != "app_id" || id == "" || nameMap[id] != "" || senderHasName(sender) {
continue
}
content, _ := msg["content"].(string)
name := extractAppWelcomeName(content)
if name != "" {
nameMap[id] = name
}
}
}

func extractAppWelcomeName(content string) string {
match := appWelcomeNameRe.FindStringSubmatch(content)
if len(match) < 2 {
return ""

Check warning on line 183 in shortcuts/im/convert_lib/helpers.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/helpers.go#L183

Added line #L183 was not covered by tests
}
return strings.TrimSpace(match[1])
}

func resolveBotSenderNames(runtime *common.RuntimeContext, messages []map[string]interface{}, nameMap map[string]string) {
if runtime == nil {
return

Check warning on line 190 in shortcuts/im/convert_lib/helpers.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/helpers.go#L190

Added line #L190 was not covered by tests
}

seen := make(map[string]bool)
var missingIDs []string
for _, msg := range messages {
sender, ok := msg["sender"].(map[string]interface{})
if !ok {
continue

Check warning on line 198 in shortcuts/im/convert_lib/helpers.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/helpers.go#L198

Added line #L198 was not covered by tests
}
senderType, _ := sender["sender_type"].(string)
if !isBotSenderType(senderType) {
continue
}
id, _ := sender["id"].(string)
if id == "" || !strings.HasPrefix(id, "ou_") || seen[id] || nameMap[id] != "" || senderHasName(sender) {
continue
}
seen[id] = true
missingIDs = append(missingIDs, id)
}
if len(missingIDs) == 0 {
return
}

batchResolveBots(runtime, missingIDs, nameMap)
}

func resolveCurrentBotSenderName(runtime *common.RuntimeContext, messages []map[string]interface{}, nameMap map[string]string) {
if runtime == nil {
return

Check warning on line 220 in shortcuts/im/convert_lib/helpers.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/helpers.go#L220

Added line #L220 was not covered by tests
}

seen := make(map[string]bool)
var missingIDs []string
for _, msg := range messages {
sender, ok := msg["sender"].(map[string]interface{})
if !ok {
continue

Check warning on line 228 in shortcuts/im/convert_lib/helpers.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/helpers.go#L228

Added line #L228 was not covered by tests
}
senderType, _ := sender["sender_type"].(string)
if !isBotSenderType(senderType) {
continue
}
id, _ := sender["id"].(string)
if id == "" || seen[id] || nameMap[id] != "" || senderHasName(sender) {
continue
}
seen[id] = true
missingIDs = append(missingIDs, id)
}
if len(missingIDs) == 0 {
return
}

info, err := runtime.BotInfo()
if err != nil || info == nil || strings.TrimSpace(info.AppName) == "" {
return
}

currentBotIDs := make(map[string]bool, 2)
if info.OpenID != "" {
currentBotIDs[info.OpenID] = true
}
if runtime.Config != nil && runtime.Config.AppID != "" {
currentBotIDs[runtime.Config.AppID] = true
}
for _, id := range missingIDs {
if currentBotIDs[id] {
nameMap[id] = info.AppName
}
}
}

func isBotSenderType(senderType string) bool {
return senderType == "bot" || senderType == "app"
}

func senderHasName(sender map[string]interface{}) bool {
name, _ := sender["name"].(string)
return strings.TrimSpace(name) != ""
}

func batchResolveBots(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]

query := larkcore.QueryParams{"bot_ids": batch}
data, err := runtime.DoAPIJSON(http.MethodGet, "/open-apis/bot/v3/bots/basic_batch", query, nil)
if err != nil {
break
}

bots, _ := data["bots"].(map[string]interface{})
for requestedID, raw := range bots {
bot, _ := raw.(map[string]interface{})
name, _ := bot["name"].(string)
if requestedID != "" && name != "" {
nameMap[requestedID] = name
}
}
}
}

// 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.
Expand Down
110 changes: 110 additions & 0 deletions shortcuts/im/convert_lib/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"strings"
"testing"
"time"

"github.com/larksuite/cli/shortcuts/common"
)

func TestParseJSONObject(t *testing.T) {
Expand Down Expand Up @@ -172,6 +174,114 @@ func TestResolveSenderNames(t *testing.T) {
}
}

func TestResolveSenderNamesAddsCurrentBotAppName(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}))
runtime.Config.AppID = "cli_current"
botInfoCalls := 0
setConvertlibRuntimeField(t, runtime, "botInfoFunc", func() (*common.BotInfo, error) {
botInfoCalls++
return &common.BotInfo{OpenID: "ou_bot_current", AppName: "Release Bot"}, nil
})

messages := []map[string]interface{}{
{"sender": map[string]interface{}{"sender_type": "app", "id": "cli_current"}},
{"sender": map[string]interface{}{"sender_type": "bot", "id": "ou_bot_current"}},
{"sender": map[string]interface{}{"sender_type": "app", "id": "cli_other"}},
}

nameMap := ResolveSenderNames(runtime, messages, nil)
AttachSenderNames(messages, nameMap)

if botInfoCalls != 1 {
t.Fatalf("BotInfo() calls = %d, want 1", botInfoCalls)
}
for i := 0; i < 2; i++ {
sender := messages[i]["sender"].(map[string]interface{})
if sender["name"] != "Release Bot" {
t.Fatalf("sender %d name = %#v, want %#v", i, sender["name"], "Release Bot")
}
}
otherSender := messages[2]["sender"].(map[string]interface{})
if _, hasName := otherSender["name"]; hasName {
t.Fatalf("other bot sender should remain unresolved, got %#v", otherSender["name"])
}
}

func TestResolveSenderNamesBatchResolvesBotOpenIDs(t *testing.T) {
var requestedBotIDs []string
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/bot/v3/bots/basic_batch"):
requestedBotIDs = append(requestedBotIDs, req.URL.Query()["bot_ids"]...)
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"bots": map[string]interface{}{
"ou_bot_alpha": map[string]interface{}{"bot_id": "ou_bot_alpha", "name": "Alpha Bot"},
"ou_bot_beta": map[string]interface{}{"bot_id": "ou_bot_beta", "name": "Beta Bot"},
},
"failed_bots": map[string]interface{}{
"ou_bot_missing": map[string]interface{}{"code": 20002, "reason": "bot not found"},
},
},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))

messages := []map[string]interface{}{
{"sender": map[string]interface{}{"sender_type": "bot", "id": "ou_bot_alpha"}},
{"sender": map[string]interface{}{"sender_type": "app", "id": "ou_bot_beta"}},
{"sender": map[string]interface{}{"sender_type": "bot", "id": "ou_bot_missing"}},
{"sender": map[string]interface{}{"sender_type": "bot", "id": "ou_bot_alpha"}},
}

nameMap := ResolveSenderNames(runtime, messages, nil)
AttachSenderNames(messages, nameMap)

if want := []string{"ou_bot_alpha", "ou_bot_beta", "ou_bot_missing"}; !reflect.DeepEqual(requestedBotIDs, want) {
t.Fatalf("bot_ids = %#v, want %#v", requestedBotIDs, want)
}
if got := messages[0]["sender"].(map[string]interface{})["name"]; got != "Alpha Bot" {
t.Fatalf("alpha sender name = %#v, want Alpha Bot", got)
}
if got := messages[1]["sender"].(map[string]interface{})["name"]; got != "Beta Bot" {
t.Fatalf("beta sender name = %#v, want Beta Bot", got)
}
missingSender := messages[2]["sender"].(map[string]interface{})
if _, hasName := missingSender["name"]; hasName {
t.Fatalf("failed bot sender should remain unresolved, got %#v", missingSender["name"])
}
}

func TestResolveSenderNamesExtractsAppSenderNameFromWelcomeCard(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}))

messages := []map[string]interface{}{
{
"content": "<card>\n同学们好!我是 **生活bot**,由 @ou_401dd1d6257568b71f210c84ec3d98d1 邀请入群,并由 AIME 提供能力支持。\n</card>",
"sender": map[string]interface{}{
"id": "cli_a97a8cbfdf38dcbc",
"id_type": "app_id",
"sender_type": "app",
},
},
}

nameMap := ResolveSenderNames(runtime, messages, nil)
AttachSenderNames(messages, nameMap)

sender := messages[0]["sender"].(map[string]interface{})
if sender["name"] != "生活bot" {
t.Fatalf("sender name = %#v, want 生活bot", sender["name"])
}
}

func TestBatchResolveByBasicContactRespectsAPILimit(t *testing.T) {
// basic_batch allows at most 10 user_ids per request. Given 25 missing IDs,
// expect three requests with sizes 10 / 10 / 5.
Expand Down
5 changes: 5 additions & 0 deletions shortcuts/im/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ var mentionFixRe = regexp.MustCompile(`<at\s+(id|open_id|user_id)=("?)([^"\s/>]+
var threadIDRe = regexp.MustCompile(`^omt_`)
var messageIDRe = regexp.MustCompile(`^om_`)

const botBasicInfoReadScope = "application:bot.basic_info:read"

func flagMessageID(rt *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(rt.Str("message-id"))
if id == "" {
Expand Down Expand Up @@ -1480,9 +1482,12 @@ const (

var (
flagWriteLookupScopes = append([]string{flagWriteScope}, flagLookupScopes...)
// Feed-thread flag enrichment can expose nested messages sent by bots, so keep
// the dynamic scope check aligned with sender-name resolution's bot lookup.
flagMessageReadScopes = []string{
"im:message.group_msg:get_as_user",
"im:message.p2p_msg:get_as_user",
botBasicInfoReadScope,
}
flagLookupScopes = []string{
"im:message.group_msg:get_as_user",
Expand Down
24 changes: 24 additions & 0 deletions shortcuts/im/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"fmt"
"net/http"
"reflect"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -876,3 +877,26 @@ func TestShortcuts(t *testing.T) {
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)
}
}

func TestMessageReadShortcutsDeclareBotBasicInfoScope(t *testing.T) {
for _, tt := range []struct {
name string
shortcut common.Shortcut
identity string
}{
{name: "chat messages user", shortcut: ImChatMessageList, identity: "user"},
{name: "chat messages bot", shortcut: ImChatMessageList, identity: "bot"},
{name: "messages mget user", shortcut: ImMessagesMGet, identity: "user"},
{name: "messages mget bot", shortcut: ImMessagesMGet, identity: "bot"},
{name: "thread messages user", shortcut: ImThreadsMessagesList, identity: "user"},
{name: "thread messages bot", shortcut: ImThreadsMessagesList, identity: "bot"},
{name: "messages search user", shortcut: ImMessagesSearch, identity: "user"},
} {
t.Run(tt.name, func(t *testing.T) {
const wantScope = "application:bot.basic_info:read"
if !slices.Contains(tt.shortcut.ScopesForIdentity(tt.identity), wantScope) {
t.Fatalf("%s scopes = %#v, want %s", tt.name, tt.shortcut.ScopesForIdentity(tt.identity), wantScope)
}
})
}
}
4 changes: 2 additions & 2 deletions shortcuts/im/im_chat_messages_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "contact:user.base:readonly", botBasicInfoReadScope},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", botBasicInfoReadScope},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
Expand Down
Loading
Loading