mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 02:19:19 +02:00
Compare commits
5 Commits
agent/lamb
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dcabd1bc8 | ||
|
|
b7245ab67b | ||
|
|
4e8ff9121f | ||
|
|
c3061a24a7 | ||
|
|
13fb6e7a8e |
@@ -1003,11 +1003,15 @@ func buildLarkConnectorFactory(installSvc *lark.InstallationService, apiClient l
|
||||
}
|
||||
return creds, nil
|
||||
})
|
||||
// Inbound enricher: expands quoted replies / forwarded bundles into
|
||||
// the agent's body via the IM API before dispatch. It shares the
|
||||
// Inbound enricher: expands quoted replies / forwarded bundles AND
|
||||
// prefetches a window of surrounding group history (MUL-3084) into the
|
||||
// agent's body via the IM API before dispatch. It shares the
|
||||
// connector's resolved credentials and runs under the connector's
|
||||
// EnrichTimeout so it cannot overrun the Lark long-conn ACK budget.
|
||||
enricher := lark.NewInboundEnricher(apiClient, lark.InboundEnricherConfig{Logger: slog.Default()})
|
||||
enricher := lark.NewInboundEnricher(apiClient, lark.InboundEnricherConfig{
|
||||
RecentContextSize: lark.DefaultRecentContextSize,
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
conn, err := lark.NewWSLongConnConnector(lark.WSConnectorConfig{
|
||||
Dialer: dialer,
|
||||
EndpointFetcher: endpointFetcher,
|
||||
|
||||
@@ -81,6 +81,49 @@ type APIClient interface {
|
||||
// slice keeps this method a thin transport adapter — flattening and
|
||||
// block assembly are the enricher's job.
|
||||
GetMessage(ctx context.Context, creds InstallationCredentials, messageID string) ([]LarkMessage, error)
|
||||
|
||||
// ListChatMessages fetches the most recent messages in a single chat
|
||||
// via GET /open-apis/im/v1/messages?container_id_type=chat. It powers
|
||||
// the group-context prefetch: when a user @-mentions the Bot in a busy
|
||||
// group, the enricher pulls a bounded window of surrounding messages
|
||||
// so the agent sees the conversation, not just the one @-ed line.
|
||||
//
|
||||
// Results come back newest-first (sort_type=ByCreateTimeDesc), capped
|
||||
// at p.PageSize (Lark hard-caps a page at 50); the caller orders and
|
||||
// trims for rendering. Only a single page is fetched — pagination is
|
||||
// deliberately not exposed so the inbound ACK path's HTTP fan-out
|
||||
// stays a single round-trip. Like GetMessage, this is a thin transport
|
||||
// adapter: flattening and block assembly are the enricher's job.
|
||||
ListChatMessages(ctx context.Context, creds InstallationCredentials, p ListMessagesParams) ([]LarkMessage, error)
|
||||
|
||||
// BatchGetUsers resolves a set of user open_ids to their display names
|
||||
// via GET /open-apis/contact/v3/users/batch. The enricher uses it to
|
||||
// label recent-context / quoted / forwarded speakers (and the sender
|
||||
// who @-mentioned the Bot) with real names instead of positional
|
||||
// "User 1 / User 2". Returns an open_id -> name map; ids the API does
|
||||
// not return (restricted contact scope, deactivated user, …) are
|
||||
// simply absent from the map, and the caller falls back to a
|
||||
// positional label. openIDs beyond Lark's 50-per-call cap are dropped
|
||||
// by the client.
|
||||
BatchGetUsers(ctx context.Context, creds InstallationCredentials, openIDs []string) (map[string]string, error)
|
||||
}
|
||||
|
||||
// ListMessagesParams selects a bounded, recent window of messages in a
|
||||
// single Lark chat for the group-context prefetch. Only the fields the
|
||||
// enricher needs today are exposed (ChatID, PageSize, EndTime);
|
||||
// start_time and page_token are intentionally omitted until a caller
|
||||
// needs them.
|
||||
type ListMessagesParams struct {
|
||||
ChatID ChatID
|
||||
// PageSize is how many of the most-recent messages to fetch. The
|
||||
// client clamps it into Lark's valid 1..50 range.
|
||||
PageSize int
|
||||
// EndTime, when > 0, caps the window to messages created at or before
|
||||
// this Unix timestamp in SECONDS (Lark's end_time is second-, not
|
||||
// millisecond-, granularity). The enricher sets it to the trigger
|
||||
// message's time so the prefetch is anchored to the @-mention moment
|
||||
// rather than whatever is newest by the time the fetch runs.
|
||||
EndTime int64
|
||||
}
|
||||
|
||||
// LarkMessage is the normalized slice of an IM v1 message item the
|
||||
@@ -280,3 +323,13 @@ func (s *stubAPIClient) GetMessage(ctx context.Context, creds InstallationCreden
|
||||
s.log.Warn("lark stub client: GetMessage called", "message_id", messageID)
|
||||
return nil, ErrAPIClientNotConfigured
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) ListChatMessages(ctx context.Context, creds InstallationCredentials, p ListMessagesParams) ([]LarkMessage, error) {
|
||||
s.log.Warn("lark stub client: ListChatMessages called", "chat_id", string(p.ChatID))
|
||||
return nil, ErrAPIClientNotConfigured
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) BatchGetUsers(ctx context.Context, creds InstallationCredentials, openIDs []string) (map[string]string, error) {
|
||||
s.log.Warn("lark stub client: BatchGetUsers called", "count", len(openIDs))
|
||||
return nil, ErrAPIClientNotConfigured
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ type InboundMessage struct {
|
||||
// the dispatcher itself stays msg_type-agnostic and only reads Body.
|
||||
MessageType string
|
||||
|
||||
// CreateTime is the trigger message's creation time (epoch
|
||||
// milliseconds, as Lark sends it). The enricher uses it to anchor the
|
||||
// group recent-context window to the moment of the @-mention — it
|
||||
// fetches the conversation up to this time rather than whatever is
|
||||
// newest when the (slightly later) prefetch HTTP call runs.
|
||||
CreateTime string
|
||||
|
||||
// ParentID is the message_id of the message this one quote-replies
|
||||
// to, taken verbatim from the receive event's `parent_id`. Empty
|
||||
// when the message is not a reply. The enricher fetches it and
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -585,6 +586,130 @@ func (c *httpAPIClient) GetMessage(ctx context.Context, creds InstallationCreden
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// larkListMessagesMaxPageSize is Lark's hard cap on a single
|
||||
// im/v1/messages page. We clamp to it so a caller asking for more
|
||||
// silently gets the max rather than a 400 from Lark.
|
||||
const larkListMessagesMaxPageSize = 50
|
||||
|
||||
// ListChatMessages retrieves a bounded, recent window of messages in one
|
||||
// chat via GET /open-apis/im/v1/messages?container_id_type=chat. Where
|
||||
// GetMessage fetches a single message by id, this lists a conversation;
|
||||
// it backs the enricher's group-context prefetch. We pass
|
||||
// sort_type=ByCreateTimeDesc so the newest messages come first and a
|
||||
// small page_size captures "the last N" without paginating, keeping the
|
||||
// inbound ACK path's fan-out to a single round-trip. user_id_type=open_id
|
||||
// matches the identifiers the rest of the package keys on; body.content
|
||||
// is forwarded verbatim for the enricher's flattener to interpret.
|
||||
func (c *httpAPIClient) ListChatMessages(ctx context.Context, creds InstallationCredentials, p ListMessagesParams) ([]LarkMessage, error) {
|
||||
if p.ChatID == "" {
|
||||
return nil, errors.New("lark http client: missing chat_id")
|
||||
}
|
||||
size := p.PageSize
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
} else if size > larkListMessagesMaxPageSize {
|
||||
size = larkListMessagesMaxPageSize
|
||||
}
|
||||
token, err := c.tenantAccessToken(ctx, creds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("container_id_type", "chat")
|
||||
q.Set("container_id", string(p.ChatID))
|
||||
q.Set("sort_type", "ByCreateTimeDesc")
|
||||
q.Set("page_size", strconv.Itoa(size))
|
||||
q.Set("user_id_type", "open_id")
|
||||
if p.EndTime > 0 {
|
||||
q.Set("end_time", strconv.FormatInt(p.EndTime, 10))
|
||||
}
|
||||
path := "/open-apis/im/v1/messages?" + q.Encode()
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Items []larkRESTMessageItem `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.doJSON(ctx, c.resolveBaseURL(creds), http.MethodGet, path, token, nil, &resp); err != nil {
|
||||
return nil, fmt.Errorf("lark http client: list chat messages: %w", err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
if isTokenError(resp.Code) {
|
||||
c.invalidateToken(creds.AppID)
|
||||
}
|
||||
return nil, fmt.Errorf("lark http client: list chat messages: code=%d msg=%q", resp.Code, resp.Msg)
|
||||
}
|
||||
|
||||
out := make([]LarkMessage, 0, len(resp.Data.Items))
|
||||
for _, it := range resp.Data.Items {
|
||||
out = append(out, it.normalize())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// larkBatchGetUsersMaxIDs is Lark's hard cap on user_ids per
|
||||
// contact/v3/users/batch call. We drop the overflow rather than error so
|
||||
// a caller asking for more still gets the first 50 resolved.
|
||||
const larkBatchGetUsersMaxIDs = 50
|
||||
|
||||
// BatchGetUsers resolves user open_ids to display names via
|
||||
// GET /open-apis/contact/v3/users/batch?user_ids=…&user_id_type=open_id.
|
||||
// It mirrors fetchBotUnionID's single-user contact lookup, batched. Only
|
||||
// id->name pairs the API actually returns are included; a restricted
|
||||
// contact scope or an unknown id simply yields a smaller map (code==0
|
||||
// with fewer items), never an error, so the enricher degrades to
|
||||
// positional speaker labels. Ids past Lark's 50-per-call cap are dropped.
|
||||
func (c *httpAPIClient) BatchGetUsers(ctx context.Context, creds InstallationCredentials, openIDs []string) (map[string]string, error) {
|
||||
if len(openIDs) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
if len(openIDs) > larkBatchGetUsersMaxIDs {
|
||||
openIDs = openIDs[:larkBatchGetUsersMaxIDs]
|
||||
}
|
||||
token, err := c.tenantAccessToken(ctx, creds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("user_id_type", "open_id")
|
||||
for _, id := range openIDs {
|
||||
if id != "" {
|
||||
q.Add("user_ids", id)
|
||||
}
|
||||
}
|
||||
path := "/open-apis/contact/v3/users/batch?" + q.Encode()
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
OpenID string `json:"open_id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.doJSON(ctx, c.resolveBaseURL(creds), http.MethodGet, path, token, nil, &resp); err != nil {
|
||||
return nil, fmt.Errorf("lark http client: batch get users: %w", err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
if isTokenError(resp.Code) {
|
||||
c.invalidateToken(creds.AppID)
|
||||
}
|
||||
return nil, fmt.Errorf("lark http client: batch get users: code=%d msg=%q", resp.Code, resp.Msg)
|
||||
}
|
||||
|
||||
out := make(map[string]string, len(resp.Data.Items))
|
||||
for _, it := range resp.Data.Items {
|
||||
if it.OpenID != "" && it.Name != "" {
|
||||
out[it.OpenID] = it.Name
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// larkRESTMessageItem is the IM v1 message item shape returned by the
|
||||
// get / list endpoints. It differs from the WS receive event in two
|
||||
// ways the enricher cares about: msg_type (not message_type), and a
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package lark
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHTTPClient_ListChatMessages exercises the group-context list path:
|
||||
// the request carries container_id_type=chat, the chat id, a descending
|
||||
// sort, the requested page_size, and user_id_type=open_id; items[] come
|
||||
// back normalized verbatim (ordering is the enricher's job, not the
|
||||
// transport's).
|
||||
func TestHTTPClient_ListChatMessages(t *testing.T) {
|
||||
fake := newLarkFake(t)
|
||||
fake.stubToken("tok", 7200)
|
||||
fake.mux.HandleFunc("/open-apis/im/v1/messages", 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("container_id_type") != "chat" {
|
||||
t.Errorf("container_id_type = %q", q.Get("container_id_type"))
|
||||
}
|
||||
if q.Get("container_id") != "oc_chat" {
|
||||
t.Errorf("container_id = %q", q.Get("container_id"))
|
||||
}
|
||||
if q.Get("sort_type") != "ByCreateTimeDesc" {
|
||||
t.Errorf("sort_type = %q", q.Get("sort_type"))
|
||||
}
|
||||
if q.Get("page_size") != "20" {
|
||||
t.Errorf("page_size = %q", q.Get("page_size"))
|
||||
}
|
||||
if q.Get("user_id_type") != "open_id" {
|
||||
t.Errorf("user_id_type = %q", q.Get("user_id_type"))
|
||||
}
|
||||
if q.Get("end_time") != "1700000000" {
|
||||
t.Errorf("end_time = %q", q.Get("end_time"))
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{
|
||||
"message_id": "om_2",
|
||||
"msg_type": "text",
|
||||
"create_time": "2000",
|
||||
"sender": map[string]any{"id": "ou_b", "id_type": "open_id", "sender_type": "user"},
|
||||
"body": map[string]any{"content": `{"text":"second"}`},
|
||||
},
|
||||
map[string]any{
|
||||
"message_id": "om_1",
|
||||
"msg_type": "text",
|
||||
"create_time": "1000",
|
||||
"sender": map[string]any{"id": "ou_a", "id_type": "open_id", "sender_type": "user"},
|
||||
"body": map[string]any{"content": `{"text":"first"}`},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
c := newTestClient(fake, time.Now)
|
||||
items, err := c.ListChatMessages(context.Background(), testCreds(), ListMessagesParams{ChatID: "oc_chat", PageSize: 20, EndTime: 1700000000})
|
||||
if err != nil {
|
||||
t.Fatalf("ListChatMessages: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d, want 2", len(items))
|
||||
}
|
||||
if items[0].MessageID != "om_2" || items[0].Content != `{"text":"second"}` || items[0].SenderID != "ou_b" {
|
||||
t.Errorf("items[0] = %+v", items[0])
|
||||
}
|
||||
if a := fake.lastAuth(); a != "Bearer tok" {
|
||||
t.Errorf("auth header = %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPClient_ListChatMessagesClampsPageSize pins that an over-cap
|
||||
// page_size is clamped to Lark's 50 limit rather than passed through (a
|
||||
// raw >50 would earn a 400 from Lark).
|
||||
func TestHTTPClient_ListChatMessagesClampsPageSize(t *testing.T) {
|
||||
fake := newLarkFake(t)
|
||||
fake.stubToken("tok", 7200)
|
||||
var seenSize string
|
||||
var endPresent bool
|
||||
fake.mux.HandleFunc("/open-apis/im/v1/messages", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
seenSize = q.Get("page_size")
|
||||
endPresent = q.Has("end_time")
|
||||
writeJSON(w, map[string]any{"code": 0, "msg": "ok", "data": map[string]any{"items": []any{}}})
|
||||
})
|
||||
c := newTestClient(fake, time.Now)
|
||||
if _, err := c.ListChatMessages(context.Background(), testCreds(), ListMessagesParams{ChatID: "oc_chat", PageSize: 999}); err != nil {
|
||||
t.Fatalf("ListChatMessages: %v", err)
|
||||
}
|
||||
if seenSize != "50" {
|
||||
t.Errorf("page_size = %q, want clamped 50", seenSize)
|
||||
}
|
||||
// With no EndTime set, the end_time param must be omitted entirely.
|
||||
if endPresent {
|
||||
t.Errorf("end_time should be absent when EndTime=0")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPClient_ListChatMessagesMissingChatID fails fast (no token, no
|
||||
// network call) when the chat id is empty.
|
||||
func TestHTTPClient_ListChatMessagesMissingChatID(t *testing.T) {
|
||||
fake := newLarkFake(t)
|
||||
c := newTestClient(fake, time.Now)
|
||||
if _, err := c.ListChatMessages(context.Background(), testCreds(), ListMessagesParams{}); err == nil {
|
||||
t.Fatalf("want error for empty chat id")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,16 @@ const larkMsgTypeMergeForward = "merge_forward"
|
||||
// visible "... (N more truncated)" marker.
|
||||
const defaultMaxForwardChildren = 100
|
||||
|
||||
// DefaultRecentContextSize is the window the production wiring uses for
|
||||
// the group-context prefetch: the page_size of the single list call made
|
||||
// when a user @-mentions the Bot in a group. It is a FETCH budget, not a
|
||||
// guaranteed rendered count — the trigger message itself and any quoted
|
||||
// parent are filtered out of the result, so the <recent_context> block
|
||||
// usually renders one or two fewer lines. 10 keeps the agent's prompt
|
||||
// meaningfully contextual without bloating it or straining the inbound
|
||||
// ACK budget (one list call, page_size 10).
|
||||
const DefaultRecentContextSize = 10
|
||||
|
||||
// Enricher expands an inbound message's body with context the user
|
||||
// EXPLICITLY attached — a quoted reply or a merged-and-forwarded bundle
|
||||
// — by calling back into Lark's IM API. It runs after the (fast,
|
||||
@@ -37,11 +47,18 @@ type Enricher interface {
|
||||
Enrich(ctx context.Context, msg InboundMessage, creds InstallationCredentials) InboundMessage
|
||||
}
|
||||
|
||||
// InboundEnricherConfig tunes the enricher. Both fields default.
|
||||
// InboundEnricherConfig tunes the enricher. All fields default.
|
||||
type InboundEnricherConfig struct {
|
||||
// MaxForwardChildren caps inlined forward children. <=0 uses
|
||||
// defaultMaxForwardChildren.
|
||||
MaxForwardChildren int
|
||||
// RecentContextSize caps how many surrounding group messages the
|
||||
// enricher prefetches and inlines as a <recent_context> block when a
|
||||
// user @-mentions the Bot in a group. <=0 DISABLES the prefetch
|
||||
// entirely (only explicitly-attached quote/forward context is used);
|
||||
// the production wiring sets DefaultRecentContextSize. Values above
|
||||
// Lark's 50-per-page cap are clamped by the client.
|
||||
RecentContextSize int
|
||||
// Logger receives best-effort warnings about fetch failures. Nil
|
||||
// uses slog.Default().
|
||||
Logger *slog.Logger
|
||||
@@ -50,6 +67,7 @@ type InboundEnricherConfig struct {
|
||||
type inboundEnricher struct {
|
||||
client APIClient
|
||||
maxForwardChildren int
|
||||
recentContextSize int
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
@@ -66,22 +84,49 @@ func NewInboundEnricher(client APIClient, cfg InboundEnricherConfig) Enricher {
|
||||
return &inboundEnricher{
|
||||
client: client,
|
||||
maxForwardChildren: cfg.MaxForwardChildren,
|
||||
recentContextSize: cfg.RecentContextSize,
|
||||
logger: cfg.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich rewrites msg.Body to inline any quoted-reply parent and/or
|
||||
// forwarded bundle. Composition order matches the product spec: the
|
||||
// quoted block is prepended, then the message's own content (or, for a
|
||||
// forward, the rendered transcript) follows.
|
||||
// Enrich rewrites msg.Body to inline surrounding group context and/or
|
||||
// any quoted-reply parent and/or forwarded bundle. Composition order
|
||||
// goes broadest-to-narrowest: the surrounding group history first, then
|
||||
// the explicitly-quoted parent (a specific reference), then the message's
|
||||
// own content (or, for a forward, the rendered transcript).
|
||||
//
|
||||
// <recent_context …>…</recent_context>
|
||||
//
|
||||
// <quoted_message …>…</quoted_message>
|
||||
//
|
||||
// <the user's own message, or the forwarded transcript>
|
||||
// <[sender name]: the user's own message, or the forwarded transcript>
|
||||
//
|
||||
// The <recent_context> block is only produced for a group message
|
||||
// addressed to the Bot, and only when RecentContextSize > 0 — it answers
|
||||
// MUL-3084 (the Bot saw only the single @-ed line, never the surrounding
|
||||
// conversation). It is the one fetch here NOT triggered by something the
|
||||
// user explicitly attached.
|
||||
//
|
||||
// For the group prefetch path, speakers (in every block) and the sender
|
||||
// who @-mentioned the Bot are resolved to real display names via one
|
||||
// Contact batch call, so the agent reads "[Alice]: …" rather than
|
||||
// "[User 1]: …" and knows who addressed it. Unresolved senders fall back
|
||||
// to positional "User N"; the resolution is best-effort and never blocks.
|
||||
//
|
||||
// Persistence note: like the quoted/forwarded blocks, the rewritten Body
|
||||
// is persisted into the addressed turn's chat_message.content downstream
|
||||
// (AppendUserMessage). Inlining nearby group messages — including ones
|
||||
// from senders who did not address the Bot — into a member's addressed
|
||||
// turn is an accepted product decision for MUL-3084. It does NOT relax
|
||||
// the MUL-2671 drop-audit invariant: a non-addressed group message still
|
||||
// never creates its own session row, and is only ever surfaced as read-
|
||||
// context attached to a turn a workspace member explicitly directed at
|
||||
// the Bot.
|
||||
func (e *inboundEnricher) Enrich(ctx context.Context, msg InboundMessage, creds InstallationCredentials) InboundMessage {
|
||||
isForward := msg.MessageType == larkMsgTypeMergeForward
|
||||
if msg.ParentID == "" && !isForward {
|
||||
// Nothing the user explicitly attached — no network call.
|
||||
wantRecent := e.recentContextSize > 0 && msg.ChatType == ChatTypeGroup && msg.AddressedToBot
|
||||
if msg.ParentID == "" && !isForward && !wantRecent {
|
||||
// Nothing to expand and no group prefetch wanted — no network call.
|
||||
return msg
|
||||
}
|
||||
// If the transport isn't wired (stub client on a deployment without
|
||||
@@ -91,16 +136,52 @@ func (e *inboundEnricher) Enrich(ctx context.Context, msg InboundMessage, creds
|
||||
return msg
|
||||
}
|
||||
|
||||
// names maps sender open_id -> display name for this message's blocks.
|
||||
// It is resolved once, only for the group prefetch path (where we have
|
||||
// a set of senders worth a single Contact batch call), and reused by
|
||||
// the recent-context block, the quoted/forwarded labelers, and the
|
||||
// trigger-sender label below. Nil everywhere else, which makes every
|
||||
// labeler fall back to positional "User N" — i.e. unchanged behavior.
|
||||
var names map[string]string
|
||||
|
||||
var b strings.Builder
|
||||
if wantRecent {
|
||||
kept, ferr := e.fetchRecentItems(ctx, creds, msg)
|
||||
if ferr != nil {
|
||||
b.WriteString(recentContextErrorBlock())
|
||||
} else {
|
||||
// Resolve names for the surrounding speakers AND the sender who
|
||||
// @-mentioned the Bot, in one batch, so both the transcript and
|
||||
// the trigger label read with real names.
|
||||
ids := senderOpenIDs(kept)
|
||||
if msg.SenderOpenID != "" {
|
||||
ids = append(ids, string(msg.SenderOpenID))
|
||||
}
|
||||
names = e.resolveNames(ctx, creds, ids)
|
||||
if len(kept) > 0 {
|
||||
b.WriteString(e.renderRecentContextBlock(kept, names))
|
||||
}
|
||||
}
|
||||
}
|
||||
if msg.ParentID != "" {
|
||||
b.WriteString(e.renderQuoted(ctx, creds, msg.ParentID))
|
||||
if b.Len() > 0 {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(e.renderQuoted(ctx, creds, msg.ParentID, names))
|
||||
}
|
||||
|
||||
var core string
|
||||
if isForward {
|
||||
core = e.renderForwarded(ctx, creds, msg.MessageID)
|
||||
core = e.renderForwarded(ctx, creds, msg.MessageID, names)
|
||||
} else {
|
||||
core = msg.Body
|
||||
// Label the user's own message with their real name so the agent
|
||||
// knows WHO @-mentioned it — not just what they said. Only when the
|
||||
// name resolved (group prefetch path); otherwise the body is passed
|
||||
// through unchanged.
|
||||
if name := names[string(msg.SenderOpenID)]; name != "" {
|
||||
core = fmt.Sprintf("[%s]: %s", name, msg.Body)
|
||||
}
|
||||
}
|
||||
if b.Len() > 0 && core != "" {
|
||||
b.WriteString("\n\n")
|
||||
@@ -111,13 +192,126 @@ func (e *inboundEnricher) Enrich(ctx context.Context, msg InboundMessage, creds
|
||||
return msg
|
||||
}
|
||||
|
||||
// senderOpenIDs returns the distinct non-app sender open_ids across the
|
||||
// given messages, in first-appearance order — the input set for a
|
||||
// Contact name lookup.
|
||||
func senderOpenIDs(msgs []LarkMessage) []string {
|
||||
seen := make(map[string]bool, len(msgs))
|
||||
out := make([]string, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.SenderType == "app" || m.SenderID == "" || seen[m.SenderID] {
|
||||
continue
|
||||
}
|
||||
seen[m.SenderID] = true
|
||||
out = append(out, m.SenderID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveNames batch-resolves open_ids to display names, best-effort: a
|
||||
// failure (restricted contact scope, transport error) logs and returns
|
||||
// nil so every speaker labeler degrades to positional "User N" rather
|
||||
// than blocking ingestion. Duplicate / empty ids are dropped first.
|
||||
func (e *inboundEnricher) resolveNames(ctx context.Context, creds InstallationCredentials, ids []string) map[string]string {
|
||||
uniq := make([]string, 0, len(ids))
|
||||
seen := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
uniq = append(uniq, id)
|
||||
}
|
||||
if len(uniq) == 0 {
|
||||
return nil
|
||||
}
|
||||
names, err := e.client.BatchGetUsers(ctx, creds, uniq)
|
||||
if err != nil {
|
||||
e.logger.Warn("lark enricher: speaker name resolution failed", "ids", len(uniq), "err", err)
|
||||
return nil
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// fetchRecentItems pulls the recent group window and returns the
|
||||
// messages to render — the trigger message itself and the directly-quoted
|
||||
// parent (which gets its own <quoted_message> block) filtered out, sorted
|
||||
// oldest-first. The window is anchored to the trigger message's time so
|
||||
// it captures the conversation up to the @-mention rather than whatever
|
||||
// is newest by the time this fetch runs. A fetch failure is returned to
|
||||
// the caller (which renders the documented placeholder); it never blocks
|
||||
// ingestion.
|
||||
func (e *inboundEnricher) fetchRecentItems(ctx context.Context, creds InstallationCredentials, msg InboundMessage) ([]LarkMessage, error) {
|
||||
items, err := e.client.ListChatMessages(ctx, creds, ListMessagesParams{
|
||||
ChatID: msg.ChatID,
|
||||
PageSize: e.recentContextSize,
|
||||
// Lark sends create_time as epoch millis; end_time wants seconds. A
|
||||
// missing/unparseable time yields 0, which the client treats as
|
||||
// "no end_time" (newest N).
|
||||
EndTime: parseLarkMillis(msg.CreateTime) / 1000,
|
||||
})
|
||||
if err != nil {
|
||||
e.logger.Warn("lark enricher: recent context fetch failed",
|
||||
"chat_id", string(msg.ChatID), "err", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
exclude := map[string]bool{msg.MessageID: true}
|
||||
if msg.ParentID != "" {
|
||||
exclude[msg.ParentID] = true
|
||||
}
|
||||
kept := make([]LarkMessage, 0, len(items))
|
||||
for _, it := range items {
|
||||
if exclude[it.MessageID] {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, it)
|
||||
}
|
||||
|
||||
// The list endpoint returns newest-first; render oldest-first so the
|
||||
// transcript reads top-to-bottom like the chat does.
|
||||
sort.SliceStable(kept, func(i, j int) bool {
|
||||
return parseLarkMillis(kept[i].CreateTime) < parseLarkMillis(kept[j].CreateTime)
|
||||
})
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// renderRecentContextBlock renders the surrounding conversation as a
|
||||
// <recent_context> block: one "[<speaker>]: <text>" line per message,
|
||||
// oldest-first, speakers labeled with real names from `names` (falling
|
||||
// back to positional "User N"). Callers pass a non-empty `kept`.
|
||||
func (e *inboundEnricher) renderRecentContextBlock(kept []LarkMessage, names map[string]string) string {
|
||||
labeler := newSpeakerLabeler(names)
|
||||
lines := make([]string, 0, len(kept))
|
||||
for _, m := range kept {
|
||||
label := labeler.label(m)
|
||||
var text string
|
||||
switch {
|
||||
case m.MessageType == larkMsgTypeMergeForward:
|
||||
text = "[merge_forward, expand manually]"
|
||||
default:
|
||||
text = e.flattenMessage(m)
|
||||
if text == "" {
|
||||
text = "[empty message]"
|
||||
}
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("[%s]: %s", label, text))
|
||||
}
|
||||
return fmt.Sprintf("<recent_context count=\"%d\">\n%s\n</recent_context>",
|
||||
len(kept), strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func recentContextErrorBlock() string {
|
||||
return "<recent_context type=\"error\">[unable to fetch recent context]</recent_context>"
|
||||
}
|
||||
|
||||
// renderQuoted fetches the directly-quoted parent and renders a
|
||||
// <quoted_message> block. A parent that is itself a merge_forward nests
|
||||
// a <forwarded_messages> transcript inside the quoted block (the
|
||||
// GetMessage response already carries both the forward sentinel and its
|
||||
// children, so no extra round-trip is needed). Any failure degrades to
|
||||
// the documented error block.
|
||||
func (e *inboundEnricher) renderQuoted(ctx context.Context, creds InstallationCredentials, parentID string) string {
|
||||
func (e *inboundEnricher) renderQuoted(ctx context.Context, creds InstallationCredentials, parentID string, names map[string]string) string {
|
||||
items, err := e.client.GetMessage(ctx, creds, parentID)
|
||||
if err != nil || len(items) == 0 {
|
||||
e.logger.Warn("lark enricher: quoted parent fetch failed",
|
||||
@@ -129,11 +323,11 @@ func (e *inboundEnricher) renderQuoted(ctx context.Context, creds InstallationCr
|
||||
return quotedErrorBlock(parentID)
|
||||
}
|
||||
|
||||
labeler := newSpeakerLabeler()
|
||||
labeler := newSpeakerLabeler(names)
|
||||
sender := labeler.label(parent)
|
||||
|
||||
if parent.MessageType == larkMsgTypeMergeForward {
|
||||
inner := e.renderForwardedItems(items, parentID)
|
||||
inner := e.renderForwardedItems(items, parentID, names)
|
||||
return wrapQuoted(parentID, sender, larkMsgTypeMergeForward, inner)
|
||||
}
|
||||
text := e.flattenMessage(parent)
|
||||
@@ -147,13 +341,13 @@ func (e *inboundEnricher) renderQuoted(ctx context.Context, creds InstallationCr
|
||||
// children. The GetMessage response is [sentinel, child…]; we filter the
|
||||
// forward's own record out by id (robust to whether Lark returns the
|
||||
// sentinel first or not) and render the rest.
|
||||
func (e *inboundEnricher) renderForwarded(ctx context.Context, creds InstallationCredentials, forwardID string) string {
|
||||
func (e *inboundEnricher) renderForwarded(ctx context.Context, creds InstallationCredentials, forwardID string, names map[string]string) string {
|
||||
items, err := e.client.GetMessage(ctx, creds, forwardID)
|
||||
if err != nil {
|
||||
e.logger.Warn("lark enricher: forward fetch failed", "message_id", forwardID, "err", err)
|
||||
return forwardedErrorBlock()
|
||||
}
|
||||
return e.renderForwardedItems(items, forwardID)
|
||||
return e.renderForwardedItems(items, forwardID, names)
|
||||
}
|
||||
|
||||
// renderForwardedItems renders the children of a forward whose own
|
||||
@@ -161,7 +355,7 @@ func (e *inboundEnricher) renderForwarded(ctx context.Context, creds Installatio
|
||||
// rendered as "[<speaker>]: <text>"; a child that is itself a forward is
|
||||
// not recursed into (it gets a manual-expand placeholder) so the HTTP
|
||||
// fan-out on the ACK-latency-sensitive inbound path stays bounded.
|
||||
func (e *inboundEnricher) renderForwardedItems(items []LarkMessage, forwardID string) string {
|
||||
func (e *inboundEnricher) renderForwardedItems(items []LarkMessage, forwardID string, names map[string]string) string {
|
||||
// The verified contract is that GetMessage(forward_id) returns one
|
||||
// level of bundling: [sentinel, direct-children…]. We therefore
|
||||
// treat every non-sentinel item as a direct child. We filter by id
|
||||
@@ -192,7 +386,7 @@ func (e *inboundEnricher) renderForwardedItems(items []LarkMessage, forwardID st
|
||||
children = children[:e.maxForwardChildren]
|
||||
}
|
||||
|
||||
labeler := newSpeakerLabeler()
|
||||
labeler := newSpeakerLabeler(names)
|
||||
lines := make([]string, 0, len(children))
|
||||
for _, c := range children {
|
||||
label := labeler.label(c)
|
||||
@@ -268,18 +462,20 @@ func parseLarkMillis(s string) int64 {
|
||||
|
||||
// speakerLabeler assigns stable, human-readable labels to the senders
|
||||
// within one rendered block. Lark message items carry only a sender id
|
||||
// (no display name — resolving real names needs a separate Contact API
|
||||
// lookup, tracked as a follow-up), so we map each distinct user id to
|
||||
// "User 1", "User 2", … in first-appearance order, and app senders to
|
||||
// "Bot". This keeps the conversation's turn-taking structure legible
|
||||
// without a per-sender network round-trip.
|
||||
// (no display name in the payload), so the enricher resolves real names
|
||||
// out of band via the Contact API and passes them in as a sender-id ->
|
||||
// name map. A sender present in that map is labeled with their real
|
||||
// name; one that is not (restricted contact scope, deactivated user,
|
||||
// name lookup failed) falls back to "User 1", "User 2", … in
|
||||
// first-appearance order. App senders are always "Bot".
|
||||
type speakerLabeler struct {
|
||||
seen map[string]string
|
||||
n int
|
||||
names map[string]string // resolved open_id -> display name (may be nil)
|
||||
seen map[string]string
|
||||
n int
|
||||
}
|
||||
|
||||
func newSpeakerLabeler() *speakerLabeler {
|
||||
return &speakerLabeler{seen: make(map[string]string)}
|
||||
func newSpeakerLabeler(names map[string]string) *speakerLabeler {
|
||||
return &speakerLabeler{names: names, seen: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (l *speakerLabeler) label(m LarkMessage) string {
|
||||
@@ -293,8 +489,13 @@ func (l *speakerLabeler) label(m LarkMessage) string {
|
||||
if lbl, ok := l.seen[key]; ok {
|
||||
return lbl
|
||||
}
|
||||
l.n++
|
||||
lbl := fmt.Sprintf("User %d", l.n)
|
||||
var lbl string
|
||||
if name := l.names[key]; name != "" {
|
||||
lbl = name
|
||||
} else {
|
||||
l.n++
|
||||
lbl = fmt.Sprintf("User %d", l.n)
|
||||
}
|
||||
l.seen[key] = lbl
|
||||
return lbl
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package lark
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// appMsg builds a Bot-sent message (sender_type "app"), so the speaker
|
||||
// labeler renders it as "Bot" inside a recent_context transcript.
|
||||
func appMsg(id, text, createTime string) LarkMessage {
|
||||
return LarkMessage{
|
||||
MessageID: id,
|
||||
MessageType: "text",
|
||||
Content: `{"text":"` + text + `"}`,
|
||||
SenderID: "cli_bot",
|
||||
SenderType: "app",
|
||||
CreateTime: createTime,
|
||||
}
|
||||
}
|
||||
|
||||
// groupCfg enables the recent-context prefetch with the production window.
|
||||
func groupCfg() InboundEnricherConfig {
|
||||
return InboundEnricherConfig{RecentContextSize: DefaultRecentContextSize}
|
||||
}
|
||||
|
||||
// TestEnrichRecentContextGroupMention is the MUL-3084 core: a bare @-bot
|
||||
// mention in a group (no quote, no forward) gets the surrounding
|
||||
// conversation inlined as a <recent_context> block ahead of the user's
|
||||
// own message. The trigger message is excluded; speakers are labeled
|
||||
// positionally with Bot replies labeled "Bot"; oldest-first ordering.
|
||||
func TestEnrichRecentContextGroupMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newEnricherFake()
|
||||
// Lark returns newest-first; include the trigger itself to prove it
|
||||
// is filtered back out.
|
||||
fake.byChat["oc_g"] = []LarkMessage{
|
||||
textMsg("om_trigger", "ou_user", "总结一下", "3000"),
|
||||
appMsg("om_bot", "你好", "2500"),
|
||||
textMsg("om_b", "ou_bob", "明天发布", "2000"),
|
||||
textMsg("om_a", "ou_alice", "我改完了登录页", "1000"),
|
||||
}
|
||||
in := InboundMessage{
|
||||
MessageType: "text",
|
||||
MessageID: "om_trigger",
|
||||
ChatID: "oc_g",
|
||||
ChatType: ChatTypeGroup,
|
||||
AddressedToBot: true,
|
||||
Body: "总结一下",
|
||||
CreateTime: "3000", // 3000ms -> end_time 3s
|
||||
}
|
||||
|
||||
out := enrich(t, fake, in, groupCfg())
|
||||
|
||||
want := `<recent_context count="3">
|
||||
[User 1]: 我改完了登录页
|
||||
[User 2]: 明天发布
|
||||
[Bot]: 你好
|
||||
</recent_context>
|
||||
|
||||
总结一下`
|
||||
if out.Body != want {
|
||||
t.Errorf("body\n got = %q\nwant = %q", out.Body, want)
|
||||
}
|
||||
if len(fake.listCalls) != 1 || fake.listCalls[0] != "oc_g" {
|
||||
t.Errorf("expected one ListChatMessages(oc_g), got %v", fake.listCalls)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Errorf("no GetMessage expected, got %v", fake.calls)
|
||||
}
|
||||
// The window uses the production default size and is anchored to the
|
||||
// trigger's time (millis -> seconds).
|
||||
if got := fake.listParams[0].PageSize; got != DefaultRecentContextSize {
|
||||
t.Errorf("page size = %d, want %d", got, DefaultRecentContextSize)
|
||||
}
|
||||
if got := fake.listParams[0].EndTime; got != 3 {
|
||||
t.Errorf("end_time = %d, want 3 (3000ms -> 3s)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichRecentContextResolvesNames covers the MUL-3084 follow-up:
|
||||
// speakers in <recent_context> show real display names (not User 1/2),
|
||||
// and the user's own @-message is labeled with the sender's name so the
|
||||
// agent knows WHO @-mentioned it.
|
||||
func TestEnrichRecentContextResolvesNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newEnricherFake()
|
||||
fake.userNames = map[string]string{
|
||||
"ou_alice": "Alice",
|
||||
"ou_bob": "Bob",
|
||||
"ou_charlie": "Charlie",
|
||||
}
|
||||
fake.byChat["oc_g"] = []LarkMessage{
|
||||
textMsg("om_trigger", "ou_charlie", "总结一下", "3000"),
|
||||
textMsg("om_b", "ou_bob", "明天发布", "2000"),
|
||||
textMsg("om_a", "ou_alice", "我改完了登录页", "1000"),
|
||||
}
|
||||
in := InboundMessage{
|
||||
MessageType: "text",
|
||||
MessageID: "om_trigger",
|
||||
ChatID: "oc_g",
|
||||
ChatType: ChatTypeGroup,
|
||||
AddressedToBot: true,
|
||||
SenderOpenID: "ou_charlie",
|
||||
Body: "总结一下",
|
||||
CreateTime: "3000",
|
||||
}
|
||||
|
||||
out := enrich(t, fake, in, groupCfg())
|
||||
|
||||
want := `<recent_context count="2">
|
||||
[Alice]: 我改完了登录页
|
||||
[Bob]: 明天发布
|
||||
</recent_context>
|
||||
|
||||
[Charlie]: 总结一下`
|
||||
if out.Body != want {
|
||||
t.Errorf("body\n got = %q\nwant = %q", out.Body, want)
|
||||
}
|
||||
if len(fake.userCalls) != 1 {
|
||||
t.Fatalf("expected one BatchGetUsers call, got %d", len(fake.userCalls))
|
||||
}
|
||||
// The batch must include the surrounding speakers AND the trigger sender.
|
||||
got := map[string]bool{}
|
||||
for _, id := range fake.userCalls[0] {
|
||||
got[id] = true
|
||||
}
|
||||
for _, want := range []string{"ou_alice", "ou_bob", "ou_charlie"} {
|
||||
if !got[want] {
|
||||
t.Errorf("BatchGetUsers missing id %q (got %v)", want, fake.userCalls[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichRecentContextNameFallback pins the mixed case: a sender whose
|
||||
// name resolved shows the name; one that did not falls back to positional
|
||||
// "User N"; and an unresolved trigger sender leaves the core unlabeled.
|
||||
func TestEnrichRecentContextNameFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newEnricherFake()
|
||||
fake.userNames = map[string]string{"ou_alice": "Alice"} // bob + charlie unresolved
|
||||
fake.byChat["oc_g"] = []LarkMessage{
|
||||
textMsg("om_trigger", "ou_charlie", "总结一下", "3000"),
|
||||
textMsg("om_b", "ou_bob", "明天发布", "2000"),
|
||||
textMsg("om_a", "ou_alice", "我改完了登录页", "1000"),
|
||||
}
|
||||
in := InboundMessage{
|
||||
MessageType: "text",
|
||||
MessageID: "om_trigger",
|
||||
ChatID: "oc_g",
|
||||
ChatType: ChatTypeGroup,
|
||||
AddressedToBot: true,
|
||||
SenderOpenID: "ou_charlie",
|
||||
Body: "总结一下",
|
||||
CreateTime: "3000",
|
||||
}
|
||||
|
||||
out := enrich(t, fake, in, groupCfg())
|
||||
|
||||
want := `<recent_context count="2">
|
||||
[Alice]: 我改完了登录页
|
||||
[User 1]: 明天发布
|
||||
</recent_context>
|
||||
|
||||
总结一下`
|
||||
if out.Body != want {
|
||||
t.Errorf("body\n got = %q\nwant = %q", out.Body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichRecentContextWithQuotedReply composes both expansions: the
|
||||
// recent_context block comes first (broadest), then the quoted parent,
|
||||
// then the user's prose. The quoted parent is excluded from the
|
||||
// recent_context window so it isn't duplicated.
|
||||
func TestEnrichRecentContextWithQuotedReply(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newEnricherFake()
|
||||
fake.byID["om_parent"] = []LarkMessage{
|
||||
textMsg("om_parent", "ou_alice", "删除按钮加一下", "1000"),
|
||||
}
|
||||
fake.byChat["oc_g"] = []LarkMessage{
|
||||
textMsg("om_trigger", "ou_user", "去做", "3000"),
|
||||
textMsg("om_x", "ou_bob", "顺便看下样式", "2000"),
|
||||
textMsg("om_parent", "ou_alice", "删除按钮加一下", "1000"),
|
||||
}
|
||||
in := InboundMessage{
|
||||
MessageType: "text",
|
||||
MessageID: "om_trigger",
|
||||
ChatID: "oc_g",
|
||||
ChatType: ChatTypeGroup,
|
||||
AddressedToBot: true,
|
||||
Body: "去做",
|
||||
ParentID: "om_parent",
|
||||
}
|
||||
|
||||
out := enrich(t, fake, in, groupCfg())
|
||||
|
||||
want := `<recent_context count="1">
|
||||
[User 1]: 顺便看下样式
|
||||
</recent_context>
|
||||
|
||||
<quoted_message message_id="om_parent" sender="User 1" type="text">
|
||||
删除按钮加一下
|
||||
</quoted_message>
|
||||
|
||||
去做`
|
||||
if out.Body != want {
|
||||
t.Errorf("body\n got = %q\nwant = %q", out.Body, want)
|
||||
}
|
||||
if len(fake.listCalls) != 1 || fake.listCalls[0] != "oc_g" {
|
||||
t.Errorf("expected one ListChatMessages(oc_g), got %v", fake.listCalls)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0] != "om_parent" {
|
||||
t.Errorf("expected one GetMessage(om_parent), got %v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichRecentContextFetchError degrades to a visible placeholder on
|
||||
// a list failure, without blocking ingestion or dropping the user's body.
|
||||
func TestEnrichRecentContextFetchError(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newEnricherFake()
|
||||
fake.errByChat["oc_g"] = errors.New("boom")
|
||||
in := InboundMessage{
|
||||
MessageType: "text",
|
||||
MessageID: "om_trigger",
|
||||
ChatID: "oc_g",
|
||||
ChatType: ChatTypeGroup,
|
||||
AddressedToBot: true,
|
||||
Body: "在干嘛",
|
||||
}
|
||||
|
||||
out := enrich(t, fake, in, groupCfg())
|
||||
|
||||
want := `<recent_context type="error">[unable to fetch recent context]</recent_context>
|
||||
|
||||
在干嘛`
|
||||
if out.Body != want {
|
||||
t.Errorf("body\n got = %q\nwant = %q", out.Body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichRecentContextEmptyWindow emits NO block (not an empty one)
|
||||
// when the only message in the window is the trigger itself.
|
||||
func TestEnrichRecentContextEmptyWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newEnricherFake()
|
||||
fake.byChat["oc_g"] = []LarkMessage{
|
||||
textMsg("om_trigger", "ou_user", "在吗", "1000"),
|
||||
}
|
||||
in := InboundMessage{
|
||||
MessageType: "text",
|
||||
MessageID: "om_trigger",
|
||||
ChatID: "oc_g",
|
||||
ChatType: ChatTypeGroup,
|
||||
AddressedToBot: true,
|
||||
Body: "在吗",
|
||||
}
|
||||
|
||||
out := enrich(t, fake, in, groupCfg())
|
||||
|
||||
if out.Body != "在吗" {
|
||||
t.Errorf("body = %q, want unchanged %q", out.Body, "在吗")
|
||||
}
|
||||
if len(fake.listCalls) != 1 {
|
||||
t.Errorf("expected one ListChatMessages, got %v", fake.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichRecentContextSkippedCases pins the three conditions under
|
||||
// which the prefetch must NOT fire: p2p chats, group messages not
|
||||
// addressed to the Bot, and a disabled window (size 0). In all three the
|
||||
// body is untouched and no list call is made.
|
||||
func TestEnrichRecentContextSkippedCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
msg InboundMessage
|
||||
cfg InboundEnricherConfig
|
||||
}{
|
||||
{
|
||||
name: "p2p chat",
|
||||
msg: InboundMessage{MessageType: "text", MessageID: "om1", ChatID: "oc_p", ChatType: ChatTypeP2P, AddressedToBot: true, Body: "hi"},
|
||||
cfg: groupCfg(),
|
||||
},
|
||||
{
|
||||
name: "group but not addressed",
|
||||
msg: InboundMessage{MessageType: "text", MessageID: "om1", ChatID: "oc_g", ChatType: ChatTypeGroup, AddressedToBot: false, Body: "闲聊"},
|
||||
cfg: groupCfg(),
|
||||
},
|
||||
{
|
||||
name: "prefetch disabled (size 0)",
|
||||
msg: InboundMessage{MessageType: "text", MessageID: "om1", ChatID: "oc_g", ChatType: ChatTypeGroup, AddressedToBot: true, Body: "在吗"},
|
||||
cfg: InboundEnricherConfig{},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fake := newEnricherFake()
|
||||
out := enrich(t, fake, tc.msg, tc.cfg)
|
||||
if out.Body != tc.msg.Body {
|
||||
t.Errorf("body = %q, want unchanged %q", out.Body, tc.msg.Body)
|
||||
}
|
||||
if len(fake.listCalls) != 0 {
|
||||
t.Errorf("expected no ListChatMessages, got %v", fake.listCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,18 @@ type enricherFakeClient struct {
|
||||
byID map[string][]LarkMessage
|
||||
errByID map[string]error
|
||||
calls []string
|
||||
|
||||
// ListChatMessages canned results + recorder, keyed by chat id.
|
||||
byChat map[ChatID][]LarkMessage
|
||||
errByChat map[ChatID]error
|
||||
listCalls []ChatID
|
||||
listParams []ListMessagesParams
|
||||
|
||||
// BatchGetUsers canned open_id -> name map + recorder. Empty by
|
||||
// default, so speakers fall back to positional "User N".
|
||||
userNames map[string]string
|
||||
usersErr error
|
||||
userCalls [][]string
|
||||
}
|
||||
|
||||
func newEnricherFake() *enricherFakeClient {
|
||||
@@ -23,6 +35,8 @@ func newEnricherFake() *enricherFakeClient {
|
||||
configured: true,
|
||||
byID: map[string][]LarkMessage{},
|
||||
errByID: map[string]error{},
|
||||
byChat: map[ChatID][]LarkMessage{},
|
||||
errByChat: map[ChatID]error{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +48,27 @@ func (f *enricherFakeClient) GetMessage(ctx context.Context, creds InstallationC
|
||||
}
|
||||
return f.byID[id], nil
|
||||
}
|
||||
func (f *enricherFakeClient) ListChatMessages(ctx context.Context, creds InstallationCredentials, p ListMessagesParams) ([]LarkMessage, error) {
|
||||
f.listCalls = append(f.listCalls, p.ChatID)
|
||||
f.listParams = append(f.listParams, p)
|
||||
if e, ok := f.errByChat[p.ChatID]; ok {
|
||||
return nil, e
|
||||
}
|
||||
return f.byChat[p.ChatID], nil
|
||||
}
|
||||
func (f *enricherFakeClient) BatchGetUsers(ctx context.Context, creds InstallationCredentials, openIDs []string) (map[string]string, error) {
|
||||
f.userCalls = append(f.userCalls, openIDs)
|
||||
if f.usersErr != nil {
|
||||
return nil, f.usersErr
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, id := range openIDs {
|
||||
if name := f.userNames[id]; name != "" {
|
||||
out[id] = name
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Unused-by-enricher methods — present only to satisfy APIClient.
|
||||
func (f *enricherFakeClient) SendInteractiveCard(context.Context, SendCardParams) (string, error) {
|
||||
|
||||
@@ -122,6 +122,12 @@ func (f *fakeAPIClient) GetBotInfo(ctx context.Context, creds InstallationCreden
|
||||
func (f *fakeAPIClient) GetMessage(ctx context.Context, creds InstallationCredentials, messageID string) ([]LarkMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAPIClient) ListChatMessages(ctx context.Context, creds InstallationCredentials, p ListMessagesParams) ([]LarkMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAPIClient) BatchGetUsers(ctx context.Context, creds InstallationCredentials, openIDs []string) (map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newTestPatcher(t *testing.T) (*Patcher, *fakePatcherQueries, *fakeAPIClient) {
|
||||
t.Helper()
|
||||
|
||||
@@ -75,6 +75,12 @@ func (s *stubAPIClientWithRecorder) GetBotInfo(ctx context.Context, creds Instal
|
||||
func (s *stubAPIClientWithRecorder) GetMessage(ctx context.Context, creds InstallationCredentials, messageID string) ([]LarkMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAPIClientWithRecorder) ListChatMessages(ctx context.Context, creds InstallationCredentials, p ListMessagesParams) ([]LarkMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAPIClientWithRecorder) BatchGetUsers(ctx context.Context, creds InstallationCredentials, openIDs []string) (map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// stubCredentialsResolver returns a fixed plaintext secret.
|
||||
type stubCredentialsResolver struct{ secret string }
|
||||
|
||||
@@ -75,6 +75,7 @@ func (d *LarkJSONFrameDecoder) Decode(payload []byte, inst db.LarkInstallation)
|
||||
MessageID: evt.Message.MessageID,
|
||||
SenderOpenID: OpenID(evt.Sender.SenderID.OpenID),
|
||||
MessageType: evt.Message.MessageType,
|
||||
CreateTime: evt.Message.CreateTime,
|
||||
// parent_id / root_id are populated by Lark only in reply
|
||||
// scenarios. The enricher keys quoted-reply expansion off
|
||||
// ParentID (the directly quoted message); RootID is carried for
|
||||
|
||||
Reference in New Issue
Block a user