Compare commits

...

5 Commits

Author SHA1 Message Date
J
0dcabd1bc8 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>
2026-06-05 16:54:38 +08:00
J
b7245ab67b fix(lark): region-aware doJSON for ListChatMessages after rebase
origin/main merged #3815 (Lark dual-region support), which changed
doJSON to take a per-call baseURL resolved via resolveBaseURL(creds).
Adapt the new ListChatMessages call to that signature so the backend
build passes against latest main, and refresh the now-stale
ListMessagesParams comment (EndTime is exposed).

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 16:22:51 +08:00
J
4e8ff9121f docs(lark): clarify recent-context persistence stance and fetch-window semantics
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 16:21:17 +08:00
J
c3061a24a7 feat(lark): anchor group context window to trigger time, default 10
Address review feedback on MUL-3084:
- Anchor the recent-context prefetch to the trigger message's time:
  thread the message create_time through InboundMessage and pass it as
  the list end_time (millis -> seconds), so the window is the
  conversation up to the @-mention rather than whatever is newest when
  the slightly-later prefetch HTTP call runs. end_time is omitted when
  the time is missing/unparseable (falls back to newest N).
- Lower DefaultRecentContextSize from 20 to 10.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 16:21:17 +08:00
J
13fb6e7a8e feat(lark): prefetch surrounding group context on @-mention (MUL-3084)
In Feishu group chats the Bot only saw the single message that @-mentioned
it — never the surrounding conversation — because the inbound enricher only
inlined context the user explicitly attached (a quoted reply or a
merge_forward), and the API client had no way to list a chat's history.

Add APIClient.ListChatMessages (GET /open-apis/im/v1/messages,
container_id_type=chat, ByCreateTimeDesc, page_size clamped to Lark's 50
cap) and, for a group message addressed to the Bot, prefetch a bounded
window of recent messages and inline them as a <recent_context> block
ahead of the user's own message. The trigger and any quoted parent are
excluded so nothing is duplicated; speakers are labeled positionally
(User 1/2 / Bot); failures degrade to a visible placeholder and never
block ingestion. Window size is configurable via
InboundEnricherConfig.RecentContextSize (<=0 disables); production wires
DefaultRecentContextSize (20). One list call per addressed turn keeps the
fetch within the inbound ACK / EnrichTimeout budget.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 16:21:17 +08:00
12 changed files with 961 additions and 31 deletions

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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 }

View File

@@ -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