Files
multica/server/internal/integrations/lark/http_client_getmessage_test.go
Bohan Jiang 5eba94ee25 feat(lark): inbound context enrichment — post / merge_forward / quoted-reply (MUL-2951) (#3724)
Expand an inbound Lark bot message's body before dispatch with the context
a user explicitly attached, so the agent sees a semantically complete
conversation instead of a bare "@bot 总结一下".

- post: flatten rich-text (title + paragraphs, links, @-mentions) to plain
  text synchronously in the decoder.
- merge_forward: inline the forwarded transcript via a single GetMessage —
  GET /open-apis/im/v1/messages/{id} returns the forward sentinel plus the
  bundled children. (The issue's container_id_type=merge_forward query is
  undocumented; this avoids it and also handles a forwarded quoted parent.)
- quoted reply: prepend the parent_id message as a <quoted_message> block;
  a parent that is itself a forward nests a <forwarded_messages> block.
- new InboundEnricher runs in the WS connector between decode and emit,
  bounded by EnrichTimeout and degrading to "[unable to fetch]" placeholders
  so it never blocks the ~3s long-conn ACK budget.

/issue stays parseable on a quote-reply by parsing the command from the
user's own text (CommandBody) rather than the enriched body.

Short-window debounce batching (issue item #4) is tracked as a follow-up.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 11:58:16 +08:00

125 lines
4.5 KiB
Go

package lark
import (
"context"
"net/http"
"strings"
"testing"
"time"
)
const messageGetPrefix = "/open-apis/im/v1/messages/"
// TestHTTPClient_GetMessageSingle exercises the happy path: a normal
// message comes back as a one-element items[] and is normalized with
// raw body.content, sender, and REST-shaped mentions intact.
func TestHTTPClient_GetMessageSingle(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok", 7200)
fake.mux.HandleFunc(messageGetPrefix, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("want GET, got %s", r.Method)
}
if id := strings.TrimPrefix(r.URL.Path, messageGetPrefix); id != "om_parent" {
t.Errorf("path id = %q", id)
}
if r.URL.Query().Get("user_id_type") != "open_id" {
t.Errorf("missing user_id_type=open_id: %q", r.URL.RawQuery)
}
writeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"items": []any{
map[string]any{
"message_id": "om_parent",
"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":"hi"}`},
"mentions": []any{map[string]any{"key": "@_user_1", "id": "ou_b", "name": "Bob"}},
},
},
},
})
})
c := newTestClient(fake, time.Now)
items, err := c.GetMessage(context.Background(), testCreds(), "om_parent")
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if len(items) != 1 {
t.Fatalf("items = %d, want 1", len(items))
}
m := items[0]
if m.MessageID != "om_parent" || m.MessageType != "text" || m.Content != `{"text":"hi"}` {
t.Errorf("normalized = %+v", m)
}
if m.SenderID != "ou_a" || m.SenderType != "user" {
t.Errorf("sender = id:%q type:%q", m.SenderID, m.SenderType)
}
if len(m.Mentions) != 1 || m.Mentions[0].Key != "@_user_1" || m.Mentions[0].ID != "ou_b" || m.Mentions[0].Name != "Bob" {
t.Errorf("mentions = %+v", m.Mentions)
}
if a := fake.lastAuth(); a != "Bearer tok" {
t.Errorf("auth header = %q", a)
}
}
// TestHTTPClient_GetMessageMergeForward pins the merge_forward contract:
// GetMessage(forward_id) returns the sentinel parent followed by the
// bundled child messages, all in one items[] array.
func TestHTTPClient_GetMessageMergeForward(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok", 7200)
fake.mux.HandleFunc(messageGetPrefix, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"items": []any{
map[string]any{"message_id": "om_fwd", "msg_type": "merge_forward", "body": map[string]any{"content": `{"content":"Merged and Forwarded Message"}`}},
map[string]any{"message_id": "c1", "msg_type": "text", "upper_message_id": "om_fwd", "create_time": "1000", "sender": map[string]any{"id": "ou_a", "sender_type": "user"}, "body": map[string]any{"content": `{"text":"one"}`}},
map[string]any{"message_id": "c2", "msg_type": "text", "upper_message_id": "om_fwd", "create_time": "2000", "sender": map[string]any{"id": "ou_b", "sender_type": "user"}, "body": map[string]any{"content": `{"text":"two"}`}},
},
},
})
})
c := newTestClient(fake, time.Now)
items, err := c.GetMessage(context.Background(), testCreds(), "om_fwd")
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if len(items) != 3 {
t.Fatalf("items = %d, want 3 (sentinel + 2 children)", len(items))
}
if items[0].MessageType != "merge_forward" {
t.Errorf("items[0] should be the forward sentinel, got %q", items[0].MessageType)
}
if items[1].UpperMessageID != "om_fwd" || items[2].UpperMessageID != "om_fwd" {
t.Errorf("children should link to the forward via upper_message_id")
}
}
// TestHTTPClient_GetMessageErrorCode maps a Lark business error (e.g.
// deleted / not visible) to a Go error so the enricher can degrade.
func TestHTTPClient_GetMessageErrorCode(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok", 7200)
fake.mux.HandleFunc(messageGetPrefix, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"code": 230110, "msg": "message has been deleted"})
})
c := newTestClient(fake, time.Now)
if _, err := c.GetMessage(context.Background(), testCreds(), "om_gone"); err == nil {
t.Fatal("expected error for non-zero Lark code")
}
}
func TestHTTPClient_GetMessageEmptyID(t *testing.T) {
fake := newLarkFake(t)
c := newTestClient(fake, time.Now)
if _, err := c.GetMessage(context.Background(), testCreds(), ""); err == nil {
t.Fatal("expected error for empty message id")
}
}