Files
multica/server/internal/integrations/lark/http_client_batchusers_test.go
Bohan Jiang f8bd1d8fc2 feat(lark): show real speaker names in Feishu group context (MUL-3084) (#3828)
* feat(lark): resolve real speaker names in group context (MUL-3084)

The recent-context block (and quoted/forwarded blocks) labeled senders
positionally as "User 1 / User 2", and the agent had no idea who had
@-mentioned it. Add APIClient.BatchGetUsers (contact/v3/users/batch) and,
on the group prefetch path, resolve the surrounding speakers AND the
trigger sender to display names in one batch call. Speakers now render as
"[Alice]: ..." and the user's own message as "[Charlie]: ..." so the
agent knows who addressed it. Unresolved senders (restricted contact
scope, deactivated user) fall back to positional "User N"; resolution is
best-effort and never blocks ingestion. Closes the standing speaker-name
TODO in the enricher.

Co-authored-by: multica-agent <github@multica.ai>

* fix(lark): resolve names for quoted/forwarded senders too (review)

Address the #3828 review: BatchGetUsers only included the recent-window
and trigger senders, so a quoted parent / merge_forward child whose
sender was NOT in the recent window still rendered as "User N".

Restructure Enrich into fetch (Phase 1) -> resolve names (Phase 2) ->
render (Phase 3): quote/forward items are now fetched up front and their
senders folded into the single Contact batch, so every block (recent +
quoted + forwarded) shows real names in group chats. p2p keeps positional
labels. Replaces the fetch+render renderQuoted/renderForwarded with a
render-only renderQuotedBlock plus an inline forward fetch.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 18:32:31 +08:00

70 lines
2.1 KiB
Go

package lark
import (
"context"
"net/http"
"sort"
"testing"
"time"
)
// TestHTTPClient_BatchGetUsers exercises the contact name-resolution
// path: the request carries user_id_type=open_id and a user_ids list, and
// the response items[] are folded into an open_id -> name map (entries
// without a name are dropped).
func TestHTTPClient_BatchGetUsers(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok", 7200)
fake.mux.HandleFunc("/open-apis/contact/v3/users/batch", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("want GET, got %s", r.Method)
}
q := r.URL.Query()
if q.Get("user_id_type") != "open_id" {
t.Errorf("user_id_type = %q", q.Get("user_id_type"))
}
ids := q["user_ids"]
sort.Strings(ids)
if len(ids) != 3 || ids[0] != "ou_a" || ids[1] != "ou_b" || ids[2] != "ou_c" {
t.Errorf("user_ids = %v", q["user_ids"])
}
writeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"items": []any{
map[string]any{"open_id": "ou_a", "name": "Alice"},
map[string]any{"open_id": "ou_b", "name": "Bob"},
map[string]any{"open_id": "ou_c"}, // no name -> dropped
},
},
})
})
c := newTestClient(fake, time.Now)
names, err := c.BatchGetUsers(context.Background(), testCreds(), []string{"ou_a", "ou_b", "ou_c"})
if err != nil {
t.Fatalf("BatchGetUsers: %v", err)
}
if len(names) != 2 || names["ou_a"] != "Alice" || names["ou_b"] != "Bob" {
t.Errorf("names = %v", names)
}
if _, ok := names["ou_c"]; ok {
t.Errorf("ou_c should be dropped (no name): %v", names)
}
}
// TestHTTPClient_BatchGetUsersEmpty returns an empty map and makes no HTTP
// call when given no ids.
func TestHTTPClient_BatchGetUsersEmpty(t *testing.T) {
fake := newLarkFake(t)
// No token stub and no handler: any HTTP call would panic the fake.
c := newTestClient(fake, time.Now)
names, err := c.BatchGetUsers(context.Background(), testCreds(), nil)
if err != nil {
t.Fatalf("BatchGetUsers(empty): %v", err)
}
if len(names) != 0 {
t.Errorf("names = %v, want empty", names)
}
}