Files
multica/server/internal/integrations/lark/ws_frame_decoder_test.go
Bohan Jiangandmultica-agent 9b8da372cc MUL-7450 fix(lark): surface the event-delivery check a silent Bot needs (#8735)
* fix(lark): surface the event-delivery check a silent Bot needs

A Feishu app whose event subscription delivers to a request URL instead
of the long connection binds successfully, shows Connected everywhere,
and never receives a message. Multica has no webhook ingress, so those
events go nowhere near us. Nothing in the product said so: the drop path
in the connector writes no log line, an inbound audit row is only reached
once a message enters the Router, and the troubleshooting docs listed
only the causes this case is not.

Three pointers, no behaviour change:

- The connector logs the event type when the decoder declines a frame,
  so a socket that is up but only receiving events we do not handle is
  distinguishable from a healthy one. Heartbeats carry no event type and
  stay silent, so the log does not fill with noise.
- The connected badge on the agent's Integrations tab names the long
  connection and links to the guide. That is where someone looks when
  the Bot stays quiet; the install dialog closes itself on success.
- The integration guide's troubleshooting list gains the subscription
  mode and event subscription, in all four locales.

Refs #8496

Co-authored-by: multica-agent <[email protected]>

* fix(lark): report a dropped event type once per connection

Review on #8735: one Info line per declined frame is unbounded. An app
that subscribes to reactions or membership churn delivers those
continuously, and there are thousands of Feishu installations.

Report each event type once per connection instead. The map is
read-loop-local, so it needs no lock and a reconnect re-reports. What
the line is worth stays the same: it names what IS arriving on a socket
whose drops leave no other trace. It says nothing about what is missing
— an app delivering to a request URL sends no frames at all, so it
produces no line, which is why install-time verification is the actual
fix for #8496 rather than this.

Also drops apps/web/next-env.d.ts from the branch: Next regenerated it
during a typecheck run and it has nothing to do with this change.

Refs #8496

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-09-23 15:25:45 +08:00

639 lines
22 KiB
Go

package lark
import (
"encoding/json"
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
func TestLarkJSONFrameDecoderTextMessageInP2P(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{
"event_id":"evt-1",
"event_type":"im.message.receive_v1",
"app_id":"cli_app_x"
},
"event":{
"sender":{
"sender_id":{"open_id":"ou_user"},
"sender_type":"user"
},
"message":{
"message_id":"om_1",
"chat_id":"oc_1",
"chat_type":"p2p",
"message_type":"text",
"content":"{\"text\":\"hello\"}"
}
}
}`)
d := NewLarkJSONFrameDecoder()
msg, ok, err := d.Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
if msg.EventID != "evt-1" {
t.Errorf("EventID = %q", msg.EventID)
}
if msg.AppID != "cli_app_x" {
t.Errorf("AppID = %q", msg.AppID)
}
if msg.ChatType != ChatTypeP2P {
t.Errorf("ChatType = %q", msg.ChatType)
}
if msg.MessageID != "om_1" {
t.Errorf("MessageID = %q", msg.MessageID)
}
if msg.SenderOpenID != "ou_user" {
t.Errorf("SenderOpenID = %q", msg.SenderOpenID)
}
if msg.Body != "hello" {
t.Errorf("Body = %q", msg.Body)
}
if msg.AddressedToBot {
t.Errorf("P2P AddressedToBot should not be true")
}
}
func TestLarkJSONFrameDecoderGroupMentionDiscrimination(t *testing.T) {
t.Parallel()
mkRaw := func(mentionOpenID string) []byte {
return []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"m","chat_id":"c","chat_type":"group",
"message_type":"text","content":"{\"text\":\"hi\"}",
"mentions":[{"id":{"open_id":"` + mentionOpenID + `"}}]
}
}
}`)
}
d := NewLarkJSONFrameDecoder()
t.Run("mentions bot", func(t *testing.T) {
msg, ok, err := d.Decode(mkRaw("ou_bot"), Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.ChatType != ChatTypeGroup {
t.Errorf("ChatType = %q", msg.ChatType)
}
if !msg.AddressedToBot {
t.Error("AddressedToBot = false; expected true")
}
})
t.Run("mentions other user", func(t *testing.T) {
msg, ok, err := d.Decode(mkRaw("ou_other"), Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.AddressedToBot {
t.Error("AddressedToBot = true; expected false")
}
})
}
// TestLarkJSONFrameDecoderGroupMentionUnionID exercises the MUL-2671
// fix: in a multi-bot group chat the per-app `mentions[].id.open_id`
// is structurally inverted across WS perspectives, so we route on
// `union_id` (the stable, cross-app identifier captured at install
// time) when the installation row knows it. The open_id path remains
// as a transitional fallback for installations that haven't been
// backfilled yet.
func TestLarkJSONFrameDecoderGroupMentionUnionID(t *testing.T) {
t.Parallel()
mkRaw := func(mentionOpenID, mentionUnionID string) []byte {
return []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"m","chat_id":"c","chat_type":"group",
"message_type":"text","content":"{\"text\":\"hi\"}",
"mentions":[{"id":{"open_id":"` + mentionOpenID + `","union_id":"` + mentionUnionID + `"}}]
}
}
}`)
}
d := NewLarkJSONFrameDecoder()
pgText := func(s string) pgtype.Text { return pgtype.Text{String: s, Valid: true} }
t.Run("union_id match wins even when open_id mismatches", func(t *testing.T) {
// Two-bot group chat, this bot's WS perspective:
// payload.mentions[0].open_id is the WIRE-form open_id Lark
// hands us (not equal to our installation's bot_open_id,
// which is what /bot/v3/info returned), but the union_id is
// the stable identifier we captured at install. The match
// must succeed.
inst := Installation{
BotOpenID: "ou_bot_a_canonical",
BotUnionID: pgText("on_bot_a_union"),
}
msg, ok, err := d.Decode(mkRaw("ou_bot_a_wire", "on_bot_a_union"), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if !msg.AddressedToBot {
t.Error("AddressedToBot = false; expected true via union_id")
}
})
t.Run("union_id mismatch wins even when open_id matches", func(t *testing.T) {
// The other bot in the group was @-mentioned; Lark hands
// THIS bot's WS a payload whose mentions[].id.open_id
// happens to equal our bot_open_id (the inverse-mapping
// quirk Bohan's live triage surfaced). The match must NOT
// fire — union_id is the source of truth.
inst := Installation{
BotOpenID: "ou_bot_a_canonical",
BotUnionID: pgText("on_bot_a_union"),
}
msg, ok, err := d.Decode(mkRaw("ou_bot_a_canonical", "on_bot_b_union"), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.AddressedToBot {
t.Error("AddressedToBot = true; expected false because union_id points at the OTHER bot")
}
})
t.Run("falls back to open_id when union_id is unknown", func(t *testing.T) {
// Pre-backfill installation row: no union_id yet. Decoder
// must keep working in the single-bot case via the legacy
// open_id comparison.
inst := Installation{BotOpenID: "ou_bot_a_canonical"}
msg, ok, err := d.Decode(mkRaw("ou_bot_a_canonical", "on_anything"), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if !msg.AddressedToBot {
t.Error("AddressedToBot = false; expected true via legacy open_id fallback")
}
})
}
// TestLarkJSONFrameDecoderMentionPlaceholderRewrite covers the body
// cleanup: Lark inlines `@_user_N` placeholders inside the text and
// resolves them via the `mentions` array. We strip the bot's own
// mention (the dispatcher already routes the event), substitute
// other users with `@<displayName>`, and leave the agent with a
// natural-looking message body.
func TestLarkJSONFrameDecoderMentionPlaceholderRewrite(t *testing.T) {
t.Parallel()
pgText := func(s string) pgtype.Text { return pgtype.Text{String: s, Valid: true} }
mkRaw := func(text, mentionsJSON string) []byte {
// Lark wraps text in a `{"text": ...}` JSON envelope inside
// `message.content`; we double-encode below to match wire.
contentDoc := map[string]string{"text": text}
contentBytes, _ := json.Marshal(contentDoc)
contentEsc, _ := json.Marshal(string(contentBytes))
return []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"m","chat_id":"c","chat_type":"group",
"message_type":"text",
"content":` + string(contentEsc) + `,
"mentions":` + mentionsJSON + `
}
}
}`)
}
d := NewLarkJSONFrameDecoder()
t.Run("strips bot self-mention via union_id", func(t *testing.T) {
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
mentions := `[{"key":"@_user_1","name":"My Bot","id":{"open_id":"ou_bot_wire","union_id":"on_bot"}}]`
msg, ok, err := d.Decode(mkRaw("@_user_1 ping test", mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != "ping test" {
t.Errorf("Body = %q; want %q", msg.Body, "ping test")
}
})
t.Run("substitutes other-user mention with display name", func(t *testing.T) {
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
mentions := `[
{"key":"@_user_1","name":"My Bot","id":{"open_id":"ou_bot_wire","union_id":"on_bot"}},
{"key":"@_user_2","name":"Alice","id":{"open_id":"ou_alice","union_id":"on_alice"}}
]`
msg, ok, err := d.Decode(mkRaw("@_user_1 hey @_user_2 take a look", mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != "hey @Alice take a look" {
t.Errorf("Body = %q; want %q", msg.Body, "hey @Alice take a look")
}
})
t.Run("preserves newlines after stripped mention", func(t *testing.T) {
// Strip the bot mention + one adjacent space; the newline that
// follows stays put so the rest of the message keeps its
// shape. User-typed extra spaces (the double space here) are
// preserved verbatim — we do not globally collapse whitespace.
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
mentions := `[{"key":"@_user_1","name":"My Bot","id":{"open_id":"ou_bot_wire","union_id":"on_bot"}}]`
msg, ok, err := d.Decode(mkRaw("@_user_1 first line\nsecond line", mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != " first line\nsecond line" {
t.Errorf("Body = %q; want %q", msg.Body, " first line\nsecond line")
}
})
t.Run("no mentions leaves body unchanged", func(t *testing.T) {
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
msg, ok, err := d.Decode(mkRaw("just a normal message", `[]`), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != "just a normal message" {
t.Errorf("Body = %q; want %q", msg.Body, "just a normal message")
}
})
t.Run("preserves indentation and tabs around stripped mention", func(t *testing.T) {
// Code-block / indented messages: stripping the bot mention
// must not eat the surrounding indent, tabs, or any internal
// whitespace the user intentionally typed. We only consume a
// single space directly adjacent to the placeholder.
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
mentions := `[{"key":"@_user_1","name":"My Bot","id":{"open_id":"ou_bot_wire","union_id":"on_bot"}}]`
raw := " @_user_1 review this snippet:\n\tfunc add(a, b int) int {\n\t\treturn a + b\n\t}"
want := " review this snippet:\n\tfunc add(a, b int) int {\n\t\treturn a + b\n\t}"
msg, ok, err := d.Decode(mkRaw(raw, mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != want {
t.Errorf("Body = %q; want %q", msg.Body, want)
}
})
t.Run("avoids @_user_1 / @_user_10 prefix collision", func(t *testing.T) {
// Lark assigns mention keys positionally; a chat with eleven+
// participants exposes both `@_user_1` and `@_user_10`. Naive
// ReplaceAll for `@_user_1` would mangle `@_user_10`, so we
// match longest-first.
inst := Installation{
BotOpenID: "ou_bot",
BotUnionID: pgText("on_bot"),
}
mentions := `[
{"key":"@_user_1","name":"My Bot","id":{"open_id":"ou_bot_wire","union_id":"on_bot"}},
{"key":"@_user_10","name":"Alice","id":{"open_id":"ou_alice","union_id":"on_alice"}}
]`
raw := "@_user_1 forward this to @_user_10 please"
want := "forward this to @Alice please"
msg, ok, err := d.Decode(mkRaw(raw, mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != want {
t.Errorf("Body = %q; want %q", msg.Body, want)
}
})
t.Run("@-ing both bots in one message strips only self, renders other by name", func(t *testing.T) {
// Multi-bot group chat where the user @-mentions BOTH bots in
// the same message. From this WS's perspective only the self
// mention should be stripped; the sibling bot renders as
// @<displayName> so the agent receives a faithful transcript
// of the user intent.
inst := Installation{
BotOpenID: "ou_self_canonical",
BotUnionID: pgText("on_self_union"),
}
mentions := `[
{"key":"@_user_1","name":"Self Bot","id":{"open_id":"ou_self_wire","union_id":"on_self_union"}},
{"key":"@_user_2","name":"Sibling Bot","id":{"open_id":"ou_sibling_wire","union_id":"on_sibling_union"}}
]`
raw := "@_user_1 @_user_2 please coordinate"
want := "@Sibling Bot please coordinate"
msg, ok, err := d.Decode(mkRaw(raw, mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != want {
t.Errorf("Body = %q; want %q", msg.Body, want)
}
})
t.Run("open_id match does NOT strip when union_id known but differs", func(t *testing.T) {
// Mirror of containsMention's union_id-first rule: when we
// know our union_id, an open_id-only match means the mention
// is for the OTHER bot (the inverse-mapping quirk), so we
// must render it as @<name>, not strip it.
inst := Installation{
BotOpenID: "ou_self_canonical",
BotUnionID: pgText("on_self_union"),
}
mentions := `[{"key":"@_user_1","name":"Sibling Bot","id":{"open_id":"ou_self_canonical","union_id":"on_sibling_union"}}]`
raw := "@_user_1 hi"
want := "@Sibling Bot hi"
msg, ok, err := d.Decode(mkRaw(raw, mentions), inst)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != want {
t.Errorf("Body = %q; want %q", msg.Body, want)
}
})
}
func TestLarkJSONFrameDecoderDropsHeartbeat(t *testing.T) {
t.Parallel()
d := NewLarkJSONFrameDecoder()
cases := [][]byte{
[]byte(`{"type":"heartbeat"}`),
[]byte(`{"type":"frame_ack","data":{"id":"1"}}`),
[]byte(`{"type":"event_callback","header":{"event_type":"im.message.unknown_kind"}}`),
}
for _, raw := range cases {
msg, ok, err := d.Decode(raw, Installation{})
if err != nil || ok {
t.Errorf("Decode(%q) ok=%v err=%v; expected (false, nil)", raw, ok, err)
}
if msg.EventID != "" {
t.Errorf("expected zero-value InboundMessage on drop, got %+v", msg)
}
}
}
func TestLarkJSONFrameDecoderEmptyRaw(t *testing.T) {
t.Parallel()
msg, ok, err := NewLarkJSONFrameDecoder().Decode(nil, Installation{})
if ok || err != nil {
t.Fatalf("expected (zero, false, nil) for empty raw; got ok=%v err=%v msg=%+v", ok, err, msg)
}
}
func TestLarkJSONFrameDecoderMalformedReturnsError(t *testing.T) {
t.Parallel()
_, ok, err := NewLarkJSONFrameDecoder().Decode([]byte("not-json"), Installation{})
if err == nil {
t.Fatal("expected error on malformed envelope")
}
if ok {
t.Error("ok should be false on decode failure")
}
}
func TestLarkJSONFrameDecoderMessageContentEmptyOnInvalidContentJSON(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{"message_id":"m","chat_id":"c","chat_type":"p2p","message_type":"text","content":"not-json"}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != "" {
t.Errorf("Body = %q; expected empty on unparseable content", msg.Body)
}
}
func TestLarkJSONFrameDecoderMediaMessageKeepsPlaceholderAndContent(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{"message_id":"m","chat_id":"c","chat_type":"p2p","message_type":"image","content":"{\"image_key\":\"img1\"}"}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if msg.Body != "[Image]" {
t.Errorf("Body = %q; want media placeholder", msg.Body)
}
if msg.Content != `{"image_key":"img1"}` {
t.Errorf("Content = %q; want raw media content", msg.Content)
}
if msg.MessageID == "" {
t.Error("MessageID should still be populated for non-text events")
}
}
// TestLarkJSONFrameDecoderPostMessageFlattened verifies that a rich-text
// `post` message is flattened to plain text end-to-end through Decode —
// the MUL-2951 example. Body.content is the JSON-encoded post object; we
// marshal a Go string to get the correctly-escaped content field.
func TestLarkJSONFrameDecoderPostMessageFlattened(t *testing.T) {
t.Parallel()
postContent := `{"title":"周报","content":[[{"tag":"text","text":"本周完成:"}],[{"tag":"text","text":"Lark 集成"},{"tag":"a","href":"https://github.com/multica-ai/multica/pull/3277","text":"PR #3277"}]]}`
escaped, err := json.Marshal(postContent)
if err != nil {
t.Fatalf("marshal: %v", err)
}
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{"message_id":"m","chat_id":"c","chat_type":"p2p","message_type":"post","content":` + string(escaped) + `}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
want := "周报\n本周完成:\nLark 集成 PR #3277 (https://github.com/multica-ai/multica/pull/3277)"
if msg.Body != want {
t.Errorf("post Body\n got = %q\nwant = %q", msg.Body, want)
}
if msg.MessageType != "post" {
t.Errorf("MessageType = %q want post", msg.MessageType)
}
}
// TestLarkJSONFrameDecoderPostResolvesMentions checks that @-mentions in
// a post (carried as `at` spans with @_user_N placeholders) are resolved
// through the same mention pipeline as text, including stripping the
// bot's own mention.
func TestLarkJSONFrameDecoderPostResolvesMentions(t *testing.T) {
t.Parallel()
postContent := `{"content":[[{"tag":"at","user_id":"@_user_1","user_name":""},{"tag":"text","text":"please review"},{"tag":"at","user_id":"@_user_2","user_name":""}]]}`
escaped, err := json.Marshal(postContent)
if err != nil {
t.Fatalf("marshal: %v", err)
}
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"m","chat_id":"c","chat_type":"group","message_type":"post",
"content":` + string(escaped) + `,
"mentions":[
{"key":"@_user_1","id":{"open_id":"ou_bot"},"name":"Bot"},
{"key":"@_user_2","id":{"open_id":"ou_alice"},"name":"Alice"}
]
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
// @_user_1 is the bot → stripped; @_user_2 → @Alice.
want := "please review @Alice"
if msg.Body != want {
t.Errorf("post Body\n got = %q\nwant = %q", msg.Body, want)
}
if !msg.AddressedToBot {
t.Error("AddressedToBot should be true (bot was @-mentioned)")
}
}
// TestLarkJSONFrameDecoderCapturesReplyLinkage verifies parent_id /
// root_id from a quote-reply event land on the InboundMessage so the
// enricher can expand them.
func TestLarkJSONFrameDecoderCapturesReplyLinkage(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"om_child","chat_id":"c","chat_type":"group","message_type":"text",
"content":"{\"text\":\"去实现\"}",
"parent_id":"om_parent","root_id":"om_root"
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
if msg.ParentID != "om_parent" {
t.Errorf("ParentID = %q want om_parent", msg.ParentID)
}
if msg.RootID != "om_root" {
t.Errorf("RootID = %q want om_root", msg.RootID)
}
if msg.MessageType != "text" {
t.Errorf("MessageType = %q want text", msg.MessageType)
}
// CommandBody snapshots the user's own text (pre-enrichment) so
// /issue parsing survives the enricher's prepended context blocks.
if msg.CommandBody != "去实现" {
t.Errorf("CommandBody = %q want 去实现", msg.CommandBody)
}
}
// TestLarkJSONFrameDecoderCapturesThreadID verifies thread_id from a
// topic (话题) message lands on the InboundMessage so the outbound
// patcher can reply back into the thread.
func TestLarkJSONFrameDecoderCapturesThreadID(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"om_in_thread","chat_id":"c","chat_type":"group","message_type":"text",
"content":"{\"text\":\"@bot help\"}",
"thread_id":"omt_topic_123"
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
if msg.ThreadID != "omt_topic_123" {
t.Errorf("ThreadID = %q want omt_topic_123", msg.ThreadID)
}
}
// TestLarkJSONFrameDecoderNonThreadHasEmptyThreadID verifies a normal
// chat message (no thread_id in the event) leaves ThreadID empty, which
// keeps the outbound on the unchanged chat-level send path.
func TestLarkJSONFrameDecoderNonThreadHasEmptyThreadID(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"event_callback",
"header":{"event_id":"e","event_type":"im.message.receive_v1","app_id":"a"},
"event":{
"sender":{"sender_id":{"open_id":"ou_user"}},
"message":{
"message_id":"om_plain","chat_id":"c","chat_type":"group","message_type":"text",
"content":"{\"text\":\"hi\"}"
}
}
}`)
msg, ok, err := NewLarkJSONFrameDecoder().Decode(raw, Installation{BotOpenID: "ou_bot"})
if err != nil || !ok {
t.Fatalf("Decode ok=%v err=%v", ok, err)
}
if msg.ThreadID != "" {
t.Errorf("ThreadID = %q want empty for non-thread message", msg.ThreadID)
}
}
func TestPeekEventType(t *testing.T) {
t.Parallel()
cases := []struct {
name string
payload string
want string
}{
{"empty payload", "", ""},
{"malformed json", "not json", ""},
{"heartbeat", "{}", ""},
{"handled event", `{"schema":"2.0","header":{"event_type":"im.message.receive_v1"}}`, "im.message.receive_v1"},
{"unhandled event", `{"schema":"2.0","header":{"event_type":"im.chat.access_event_v1"}}`, "im.chat.access_event_v1"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := PeekEventType([]byte(tc.payload)); got != tc.want {
t.Errorf("PeekEventType(%q) = %q, want %q", tc.payload, got, tc.want)
}
})
}
}