fix(feishu): route thread replies via Lark reply endpoint

Bot responses to messages inside Lark topic threads were dropped
outside the thread because outbound Send always used the new-message
endpoint. This change:

- Adds LarkClient.ReplyMessage() that POSTs to
  /open-apis/im/v1/messages/{id}/reply with reply_in_thread=true
- Parses thread_id from im.message.receive_v1 events (distinct from
  root_id which fires on any quote reply) and stamps
  feishu_reply_target_id into the message metadata
- Propagates the key through cmd/gateway_consumer_normal.go and the
  new package-level routingMetaKeys var in internal/channels/events.go
  so block replies and retry notifications also land in thread
- Routes sendText, sendMarkdownCard, sendImage, sendFile, and
  sendMediaAttachment via a new deliverMessage helper that falls back
  to SendMessage with a warning log on reply endpoint errors (e.g.
  thread root deleted)
- Adds 10 unit tests covering routing, fallback, content
  double-encoding, and the thread_id gate that prevents plain quote
  replies from being silently promoted to threads

Closes #818
This commit is contained in:
viettranx
2026-04-11 21:22:23 +07:00
parent e8e00d1884
commit bf272d8dbf
13 changed files with 504 additions and 35 deletions
+1 -1
View File
@@ -220,7 +220,7 @@ func processNormalMessage(
outMeta["reply_to_message_id"] = mid
}
}
for _, k := range []string{tools.MetaMessageThreadID, "local_key", "placeholder_key", "group_id"} {
for _, k := range []string{tools.MetaMessageThreadID, "local_key", "placeholder_key", "group_id", "feishu_reply_target_id"} {
if v := msg.Metadata[k]; v != "" {
outMeta[k] = v
}
+9
View File
@@ -367,6 +367,15 @@ When enabled, each thread gets an isolated session:
- Different threads within the same group maintain separate conversation histories
- Disabled by default
### Thread Reply Routing
When a message is sent inside a Lark thread (detected via the `thread_id` field in the inbound event), the inbound handler stamps `metadata["feishu_reply_target_id"]` with the triggering message ID. During outbound delivery, the channel routes responses back to the same thread using `LarkClient.ReplyMessage()` which POSTs to `/open-apis/im/v1/messages/{message_id}/reply` with `reply_in_thread: true`.
- **Automatic thread detection**: No configuration needed; replies are routed based on inbound `thread_id`
- **Metadata propagation**: The `feishu_reply_target_id` key is included in the `routingMetaKeys` allowlist so replies, block replies, and placeholder updates all land in the correct thread
- **Graceful fallback**: If the reply endpoint fails (e.g., thread root deleted), the channel falls back to `SendMessage()` for the regular chat
- **Applies to**: Text, card, image, and file messages
---
## 7. Discord
+7
View File
@@ -32,6 +32,13 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep
## [Unreleased]
### Fixed
#### Feishu/Lark Thread Reply Routing — Issue #818 (2026-04-11)
- **Thread detection**: Inbound messages with `thread_id` now properly route responses back to the same Feishu thread via `/open-apis/im/v1/messages/{id}/reply` endpoint
- **Metadata propagation**: New `feishu_reply_target_id` metadata key added to `routingMetaKeys` allowlist so all outbound messages (text, cards, files, reactions) land in the correct thread
- **Graceful fallback**: If thread root is deleted, channel falls back to regular `SendMessage()` for robustness
### Testing
#### Test Coverage Improvement — Wave 1-3 (2026-04-11)
+17 -4
View File
@@ -263,10 +263,12 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
// Build outbound metadata: copy routing fields but strip reply_to_message_id
// (block replies are standalone) and placeholder_key (reserve for final message).
// feishu_reply_target_id MUST be preserved so intermediate block replies for
// threaded Lark messages also land inside the same thread.
var outMeta map[string]string
if rc.Metadata != nil {
outMeta = make(map[string]string)
for _, k := range []string{"message_thread_id", "local_key", "group_id"} {
for _, k := range routingMetaKeys {
if v := rc.Metadata[k]; v != "" {
outMeta[k] = v
}
@@ -344,11 +346,22 @@ func extractPayloadString(payload any, key string) string {
return ""
}
// copyRoutingMeta copies channel routing metadata (thread_id, local_key, group_id)
// from RunContext.Metadata into a new map suitable for outbound messages.
// routingMetaKeys enumerates the metadata keys that must survive the hop from
// inbound RunContext.Metadata into outbound OutboundMessage.Metadata so that
// replies, block replies, retries, and placeholder updates all land in the
// correct thread / topic / subgroup routing bucket on each channel.
var routingMetaKeys = []string{
"message_thread_id", // telegram forum topics
"local_key", // composite chat-id suffix
"group_id", // legacy group identifier
"feishu_reply_target_id", // feishu/lark thread reply routing (issue #818)
}
// copyRoutingMeta copies channel routing metadata from RunContext.Metadata
// into a new map suitable for outbound messages.
func copyRoutingMeta(src map[string]string) map[string]string {
out := make(map[string]string)
for _, k := range []string{"message_thread_id", "local_key", "group_id"} {
for _, k := range routingMetaKeys {
if v := src[k]; v != "" {
out[k] = v
}
+19 -2
View File
@@ -20,8 +20,9 @@ type messageContext struct {
Content string
ContentType string // "text", "post", "image", etc.
MentionedBot bool
RootID string // thread root message ID
ParentID string // parent message ID
RootID string // reply-chain root (populated on ANY reply, incl. plain quote reply)
ParentID string // direct parent in reply chain
ThreadID string // set ONLY when message is inside an actual topic thread
Mentions []mentionInfo
}
@@ -174,6 +175,22 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) {
"platform": channels.TypeFeishu,
}
// Thread routing: stamp the triggering message ID ONLY when the inbound
// message is inside an actual topic thread (thread_id present per Lark
// docs). We deliberately do NOT fire on mc.RootID — Lark populates root_id
// on every reply including plain quote replies outside any thread, and
// routing those through the reply endpoint would silently promote them to
// new threads. thread_id is the definitive signal.
//
// Outbound Send() reads this key and, when non-empty, routes to the Lark
// reply endpoint with reply_in_thread=true so the bot response lands
// inside the same thread. Absent on non-thread messages — preserves
// existing new-message endpoint behavior for DMs, plain groups, and quote
// replies.
if mc.ThreadID != "" {
metadata["feishu_reply_target_id"] = messageID
}
if sender != nil {
metadata["sender_open_id"] = sender.SenderID.OpenID
}
+2
View File
@@ -16,6 +16,7 @@ func (c *Channel) parseMessageEvent(event *MessageEvent) *messageContext {
contentType := msg.MessageType
rootID := msg.RootID
parentID := msg.ParentID
threadID := msg.ThreadID
senderID := ""
if sender != nil {
@@ -58,6 +59,7 @@ func (c *Channel) parseMessageEvent(event *MessageEvent) *messageContext {
MentionedBot: mentionedBot,
RootID: rootID,
ParentID: parentID,
ThreadID: threadID,
Mentions: mentions,
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string)
)
receiveIDType := resolveReceiveIDType(chatID)
if err := c.sendText(context.Background(), chatID, receiveIDType, replyText); err != nil {
if err := c.sendText(context.Background(), chatID, receiveIDType, replyText, ""); err != nil {
slog.Warn("failed to send feishu pairing reply", "error", err)
} else {
c.MarkPairingNotifSent(senderID)
+45 -14
View File
@@ -168,6 +168,13 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error {
// Determine receive_id_type
receiveIDType := resolveReceiveIDType(chatID)
// Thread reply: when the inbound message was inside a Lark thread, the
// Feishu inbound handler stamps metadata["feishu_reply_target_id"] with
// the triggering message ID so responses land back inside the same thread
// via POST /open-apis/im/v1/messages/{id}/reply with reply_in_thread=true.
// Absent on non-thread messages — Send falls back to the new-message path.
replyTargetID := msg.Metadata["feishu_reply_target_id"]
// Send text content
text := msg.Content
if text != "" {
@@ -191,19 +198,19 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error {
}
if useCard {
if err := c.sendMarkdownCard(ctx, chatID, receiveIDType, text, nil); err != nil {
if err := c.sendMarkdownCard(ctx, chatID, receiveIDType, text, replyTargetID, nil); err != nil {
return err
}
} else {
if err := c.sendChunkedText(ctx, chatID, receiveIDType, text, chunkLimit); err != nil {
if err := c.sendChunkedText(ctx, chatID, receiveIDType, text, chunkLimit, replyTargetID); err != nil {
return err
}
}
}
// Send media attachments
// Send media attachments — same thread routing applies as text.
for _, media := range msg.Media {
if err := c.sendMediaAttachment(ctx, chatID, receiveIDType, media); err != nil {
if err := c.sendMediaAttachment(ctx, chatID, receiveIDType, media, replyTargetID); err != nil {
slog.Warn("feishu send media failed", "url", media.URL, "error", err)
}
}
@@ -323,34 +330,58 @@ func (c *Channel) probeBotInfo(ctx context.Context) error {
// --- Send helpers ---
func (c *Channel) sendChunkedText(ctx context.Context, chatID, receiveIDType, text string, chunkLimit int) error {
func (c *Channel) sendChunkedText(ctx context.Context, chatID, receiveIDType, text string, chunkLimit int, replyTargetID string) error {
for _, chunk := range channels.ChunkMarkdown(text, chunkLimit) {
if err := c.sendText(ctx, chatID, receiveIDType, chunk); err != nil {
if err := c.sendText(ctx, chatID, receiveIDType, chunk, replyTargetID); err != nil {
return err
}
}
return nil
}
func (c *Channel) sendText(ctx context.Context, chatID, receiveIDType, text string) error {
content := buildPostContent(text)
// deliverMessage routes a message either through the Lark reply endpoint
// (when replyTargetID is non-empty) or the new-message endpoint. On reply
// endpoint failure — typically because the original thread-root message was
// deleted — it falls back to the new-message endpoint so the user still
// receives the response even if thread placement is lost. The fallback path
// logs a warning so operators can diagnose stale thread references.
func (c *Channel) deliverMessage(ctx context.Context, chatID, receiveIDType, replyTargetID, msgType, content string) error {
if replyTargetID != "" {
if _, err := c.client.ReplyMessage(ctx, replyTargetID, msgType, content, true); err == nil {
return nil
} else {
slog.Warn("feishu.reply_failed_fallback_send",
"reply_target_id", replyTargetID,
"msg_type", msgType,
"error", err,
)
// Fall through to new-message endpoint.
}
}
if _, err := c.client.SendMessage(ctx, receiveIDType, chatID, msgType, content); err != nil {
return err
}
return nil
}
_, err := c.client.SendMessage(ctx, receiveIDType, chatID, "post", content)
if err != nil {
// sendText sends a Lark "post" message. When replyTargetID is non-empty, the
// message is routed through the reply endpoint with reply_in_thread=true so it
// stays nested inside the original thread.
func (c *Channel) sendText(ctx context.Context, chatID, receiveIDType, text, replyTargetID string) error {
content := buildPostContent(text)
if err := c.deliverMessage(ctx, chatID, receiveIDType, replyTargetID, "post", content); err != nil {
return fmt.Errorf("feishu send text: %w", err)
}
return nil
}
func (c *Channel) sendMarkdownCard(ctx context.Context, chatID, receiveIDType, text string, metadata map[string]string) error {
func (c *Channel) sendMarkdownCard(ctx context.Context, chatID, receiveIDType, text, replyTargetID string, metadata map[string]string) error {
card := buildMarkdownCard(text)
cardJSON, err := json.Marshal(card)
if err != nil {
return fmt.Errorf("marshal card: %w", err)
}
_, err = c.client.SendMessage(ctx, receiveIDType, chatID, "interactive", string(cardJSON))
if err != nil {
if err := c.deliverMessage(ctx, chatID, receiveIDType, replyTargetID, "interactive", string(cardJSON)); err != nil {
return fmt.Errorf("feishu send card: %w", err)
}
return nil
@@ -37,6 +37,42 @@ func (c *LarkClient) SendMessage(ctx context.Context, receiveIDType, receiveID,
return &data, nil
}
// ReplyMessage posts a reply to an existing Lark message via
// POST /open-apis/im/v1/messages/{message_id}/reply.
//
// When replyInThread is true and the target message is inside a thread,
// the reply stays nested in that thread. When the target is not threaded,
// the reply renders as an inline quote at chat level.
//
// NOTE: `content` must be a double-encoded JSON string (e.g. `{"text":"hi"}`),
// not a Go struct. The Lark API rejects object-typed `content`.
func (c *LarkClient) ReplyMessage(ctx context.Context, rootMessageID, msgType, content string, replyInThread bool) (*SendMessageResp, error) {
if rootMessageID == "" {
return nil, fmt.Errorf("reply message: empty root message id")
}
// Defensive escaping: Lark message IDs are currently `om_` + alphanumeric,
// but any future schema change that permits `/` or reserved chars would
// otherwise silently break URL routing.
path := fmt.Sprintf("/open-apis/im/v1/messages/%s/reply", url.PathEscape(rootMessageID))
body := map[string]any{
"msg_type": msgType,
"content": content,
"reply_in_thread": replyInThread,
}
resp, err := c.doJSON(ctx, "POST", path, body)
if err != nil {
return nil, err
}
if resp.Code != 0 {
return nil, fmt.Errorf("reply message: code=%d msg=%s", resp.Code, resp.Msg)
}
var data SendMessageResp
if err := json.Unmarshal(resp.Data, &data); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return &data, nil
}
// --- IM API: Images ---
func (c *LarkClient) DownloadImage(ctx context.Context, imageKey string) ([]byte, error) {
@@ -0,0 +1,90 @@
package feishu
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestReplyMessage_InThread verifies the client hits the correct reply endpoint
// with reply_in_thread=true and a double-encoded JSON content string.
func TestReplyMessage_InThread(t *testing.T) {
const rootMsgID = "om_root_1234567890"
const wantContent = `{"text":"hello from thread"}`
var gotMethod, gotPath, gotAuth, gotContentType string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"fake-token","expire":7200}`))
return
}
gotMethod = r.Method
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotContentType = r.Header.Get("Content-Type")
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"msg":"","data":{"message_id":"om_reply_abc"}}`))
}))
defer srv.Close()
client := NewLarkClient("fake-app", "fake-secret", srv.URL)
resp, err := client.ReplyMessage(context.Background(), rootMsgID, "text", wantContent, true)
if err != nil {
t.Fatalf("ReplyMessage returned error: %v", err)
}
if resp == nil || resp.MessageID != "om_reply_abc" {
t.Fatalf("unexpected response: %+v", resp)
}
// Path must be the reply endpoint, not the new-message endpoint.
wantPath := "/open-apis/im/v1/messages/" + rootMsgID + "/reply"
if gotPath != wantPath {
t.Errorf("path: got %q, want %q", gotPath, wantPath)
}
if gotMethod != http.MethodPost {
t.Errorf("method: got %q, want POST", gotMethod)
}
if !strings.HasPrefix(gotAuth, "Bearer fake-token") {
t.Errorf("authorization header missing or wrong: %q", gotAuth)
}
if !strings.HasPrefix(gotContentType, "application/json") {
t.Errorf("content-type: got %q", gotContentType)
}
// Body assertions: content must be a JSON STRING (double-encoded), not an object.
if gotContent, _ := gotBody["content"].(string); gotContent != wantContent {
t.Errorf("content field: got %v (type %T), want string %q", gotBody["content"], gotBody["content"], wantContent)
}
if msgType, _ := gotBody["msg_type"].(string); msgType != "text" {
t.Errorf("msg_type: got %v, want %q", gotBody["msg_type"], "text")
}
if rit, ok := gotBody["reply_in_thread"].(bool); !ok || !rit {
t.Errorf("reply_in_thread: got %v, want true", gotBody["reply_in_thread"])
}
}
// TestReplyMessage_APIError verifies that a non-zero Lark code surfaces as an error.
func TestReplyMessage_APIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"t","expire":7200}`))
return
}
_, _ = w.Write([]byte(`{"code":230002,"msg":"message not found","data":{}}`))
}))
defer srv.Close()
client := NewLarkClient("a", "s", srv.URL)
if _, err := client.ReplyMessage(context.Background(), "om_missing", "text", `{"text":"x"}`, true); err == nil {
t.Fatal("expected error on non-zero code, got nil")
}
}
+6
View File
@@ -45,6 +45,12 @@ type EventMessage struct {
MessageID string `json:"message_id"`
RootID string `json:"root_id"`
ParentID string `json:"parent_id"`
// ThreadID is the definitive "this message lives inside a thread" signal
// per Lark docs. Unlike RootID (which is populated on ANY reply — including
// plain quote replies), ThreadID is only present when the message is in an
// actual topic thread. Used to decide whether to reply via the reply
// endpoint with reply_in_thread=true.
ThreadID string `json:"thread_id"`
ChatID string `json:"chat_id"`
ChatType string `json:"chat_type"`
MessageType string `json:"message_type"`
+17 -13
View File
@@ -34,22 +34,25 @@ func (c *Channel) uploadFile(ctx context.Context, data io.Reader, fileName, file
// --- Send media ---
// sendImage sends an image message using an image_key.
func (c *Channel) sendImage(ctx context.Context, chatID, receiveIDType, imageKey string) error {
// sendImage sends an image message using an image_key. When replyTargetID is
// non-empty, the image is posted through the reply endpoint so it lands inside
// the same thread as the triggering message. Falls back to the new-message
// endpoint on reply errors (see deliverMessage).
func (c *Channel) sendImage(ctx context.Context, chatID, receiveIDType, imageKey, replyTargetID string) error {
contentBytes, err := json.Marshal(map[string]string{"image_key": imageKey})
if err != nil {
return fmt.Errorf("marshal image content: %w", err)
}
_, err = c.client.SendMessage(ctx, receiveIDType, chatID, "image", string(contentBytes))
if err != nil {
if err := c.deliverMessage(ctx, chatID, receiveIDType, replyTargetID, "image", string(contentBytes)); err != nil {
return fmt.Errorf("feishu send image: %w", err)
}
return nil
}
// sendFile sends a file message using a file_key.
// msgType: "file" for documents, "media" for audio/video.
func (c *Channel) sendFile(ctx context.Context, chatID, receiveIDType, fileKey, msgType string) error {
// sendFile sends a file message using a file_key. When replyTargetID is
// non-empty, the file is posted through the reply endpoint to stay inside the
// thread. msgType: "file" for documents, "media" for audio/video.
func (c *Channel) sendFile(ctx context.Context, chatID, receiveIDType, fileKey, msgType, replyTargetID string) error {
if msgType == "" {
msgType = "file"
}
@@ -57,8 +60,7 @@ func (c *Channel) sendFile(ctx context.Context, chatID, receiveIDType, fileKey,
if err != nil {
return fmt.Errorf("marshal file content: %w", err)
}
_, err = c.client.SendMessage(ctx, receiveIDType, chatID, msgType, string(contentBytes))
if err != nil {
if err := c.deliverMessage(ctx, chatID, receiveIDType, replyTargetID, msgType, string(contentBytes)); err != nil {
return fmt.Errorf("feishu send file: %w", err)
}
return nil
@@ -68,7 +70,9 @@ func (c *Channel) sendFile(ctx context.Context, chatID, receiveIDType, fileKey,
// sendMediaAttachment uploads and sends a media attachment routed by MIME type.
// Images → image message, audio/video → media message (inline playable), others → file message.
func (c *Channel) sendMediaAttachment(ctx context.Context, chatID, receiveIDType string, att bus.MediaAttachment) error {
// When replyTargetID is non-empty, the message is posted through the Lark
// reply endpoint so it lands inside the same thread as the triggering message.
func (c *Channel) sendMediaAttachment(ctx context.Context, chatID, receiveIDType string, att bus.MediaAttachment, replyTargetID string) error {
filePath := att.URL
if filePath == "" {
return nil
@@ -88,7 +92,7 @@ func (c *Channel) sendMediaAttachment(ctx context.Context, chatID, receiveIDType
if err != nil {
return fmt.Errorf("upload image: %w", err)
}
return c.sendImage(ctx, chatID, receiveIDType, imageKey)
return c.sendImage(ctx, chatID, receiveIDType, imageKey, replyTargetID)
case strings.HasPrefix(ct, "video/"), strings.HasPrefix(ct, "audio/"):
// Lark "media" message type plays audio/video inline.
@@ -98,7 +102,7 @@ func (c *Channel) sendMediaAttachment(ctx context.Context, chatID, receiveIDType
if err != nil {
return fmt.Errorf("upload media: %w", err)
}
return c.sendFile(ctx, chatID, receiveIDType, fileKey, "media")
return c.sendFile(ctx, chatID, receiveIDType, fileKey, "media", replyTargetID)
default:
fileName := filepath.Base(filePath)
@@ -107,7 +111,7 @@ func (c *Channel) sendMediaAttachment(ctx context.Context, chatID, receiveIDType
if err != nil {
return fmt.Errorf("upload file: %w", err)
}
return c.sendFile(ctx, chatID, receiveIDType, fileKey, "file")
return c.sendFile(ctx, chatID, receiveIDType, fileKey, "file", replyTargetID)
}
}
@@ -0,0 +1,254 @@
package feishu
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// recordedRequest captures what the helper sent to the mocked Lark server
// so the test can assert which endpoint was hit.
type recordedRequest struct {
method string
path string
body map[string]any
}
// newMockLarkServer returns an httptest server that records the first non-token
// request it receives. Subsequent requests still succeed with code=0 but are
// not recorded.
func newMockLarkServer(t *testing.T) (*httptest.Server, *recordedRequest) {
t.Helper()
rec := &recordedRequest{}
var recorded bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`))
return
}
if !recorded {
rec.method = r.Method
rec.path = r.URL.Path
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &rec.body)
recorded = true
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"msg":"","data":{"message_id":"om_out"}}`))
}))
t.Cleanup(srv.Close)
return srv, rec
}
// TestSendText_InThread_RoutesToReplyEndpoint verifies that when replyTargetID
// is non-empty, sendText uses the reply endpoint with reply_in_thread=true.
func TestSendText_InThread_RoutesToReplyEndpoint(t *testing.T) {
srv, rec := newMockLarkServer(t)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.sendText(context.Background(), "oc_chat_1", "chat_id", "hello in thread", "om_trigger_42")
if err != nil {
t.Fatalf("sendText returned error: %v", err)
}
wantPath := "/open-apis/im/v1/messages/om_trigger_42/reply"
if rec.path != wantPath {
t.Errorf("path: got %q, want %q", rec.path, wantPath)
}
if rec.method != http.MethodPost {
t.Errorf("method: got %q, want POST", rec.method)
}
if rit, _ := rec.body["reply_in_thread"].(bool); !rit {
t.Errorf("reply_in_thread: got %v, want true", rec.body["reply_in_thread"])
}
if mt, _ := rec.body["msg_type"].(string); mt != "post" {
t.Errorf("msg_type: got %v, want %q", rec.body["msg_type"], "post")
}
// content is the Lark "post" structure encoded as a JSON string (double-encoded).
content, ok := rec.body["content"].(string)
if !ok || content == "" {
t.Errorf("content: got %v, want non-empty string", rec.body["content"])
}
if !strings.Contains(content, "hello in thread") {
t.Errorf("content missing text: %q", content)
}
}
// TestSendText_NoThread_RoutesToNewMessageEndpoint verifies that when
// replyTargetID is empty, sendText falls through to the original SendMessage
// path (preserving existing UX for DMs and non-thread group messages).
func TestSendText_NoThread_RoutesToNewMessageEndpoint(t *testing.T) {
srv, rec := newMockLarkServer(t)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.sendText(context.Background(), "oc_chat_1", "chat_id", "plain msg", "")
if err != nil {
t.Fatalf("sendText returned error: %v", err)
}
// New-message path — path is /open-apis/im/v1/messages (receive_id_type is a query param).
wantPath := "/open-apis/im/v1/messages"
if rec.path != wantPath {
t.Errorf("path: got %q, want %q", rec.path, wantPath)
}
// No reply_in_thread field expected on new-message endpoint.
if _, present := rec.body["reply_in_thread"]; present {
t.Errorf("reply_in_thread should be absent on new-message endpoint, got %v", rec.body["reply_in_thread"])
}
if rid, _ := rec.body["receive_id"].(string); rid != "oc_chat_1" {
t.Errorf("receive_id: got %v, want %q", rec.body["receive_id"], "oc_chat_1")
}
}
// TestSendImage_InThread_RoutesToReplyEndpoint verifies image sends route to
// the reply endpoint when the message originated inside a thread.
func TestSendImage_InThread_RoutesToReplyEndpoint(t *testing.T) {
srv, rec := newMockLarkServer(t)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.sendImage(context.Background(), "oc_chat_1", "chat_id", "img_key_123", "om_trigger_img")
if err != nil {
t.Fatalf("sendImage returned error: %v", err)
}
wantPath := "/open-apis/im/v1/messages/om_trigger_img/reply"
if rec.path != wantPath {
t.Errorf("path: got %q, want %q", rec.path, wantPath)
}
if mt, _ := rec.body["msg_type"].(string); mt != "image" {
t.Errorf("msg_type: got %v, want %q", rec.body["msg_type"], "image")
}
if rit, _ := rec.body["reply_in_thread"].(bool); !rit {
t.Errorf("reply_in_thread: got %v, want true", rec.body["reply_in_thread"])
}
if !strings.Contains(rec.body["content"].(string), "img_key_123") {
t.Errorf("content missing image key: %v", rec.body["content"])
}
}
// TestSendFile_InThread_RoutesToReplyEndpoint verifies file sends (documents +
// audio/video via "media" msg_type) route to reply endpoint inside threads.
func TestSendFile_InThread_RoutesToReplyEndpoint(t *testing.T) {
srv, rec := newMockLarkServer(t)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.sendFile(context.Background(), "oc_chat_1", "chat_id", "file_key_abc", "file", "om_trigger_file")
if err != nil {
t.Fatalf("sendFile returned error: %v", err)
}
wantPath := "/open-apis/im/v1/messages/om_trigger_file/reply"
if rec.path != wantPath {
t.Errorf("path: got %q, want %q", rec.path, wantPath)
}
if mt, _ := rec.body["msg_type"].(string); mt != "file" {
t.Errorf("msg_type: got %v, want %q", rec.body["msg_type"], "file")
}
}
// TestSendMarkdownCard_InThread_RoutesToReplyEndpoint verifies card sends also
// respect thread routing.
func TestSendMarkdownCard_InThread_RoutesToReplyEndpoint(t *testing.T) {
srv, rec := newMockLarkServer(t)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.sendMarkdownCard(context.Background(), "oc_chat_1", "chat_id", "**bold**", "om_trigger_99", nil)
if err != nil {
t.Fatalf("sendMarkdownCard returned error: %v", err)
}
wantPath := "/open-apis/im/v1/messages/om_trigger_99/reply"
if rec.path != wantPath {
t.Errorf("path: got %q, want %q", rec.path, wantPath)
}
if mt, _ := rec.body["msg_type"].(string); mt != "interactive" {
t.Errorf("msg_type: got %v, want %q", rec.body["msg_type"], "interactive")
}
if rit, _ := rec.body["reply_in_thread"].(bool); !rit {
t.Errorf("reply_in_thread: got %v, want true", rec.body["reply_in_thread"])
}
}
// TestDeliverMessage_ReplyFailure_FallsBackToSendMessage verifies that when
// the reply endpoint returns a Lark error (e.g. thread root deleted), the
// helper falls through to the new-message endpoint so the user still receives
// the response even though thread placement is lost.
func TestDeliverMessage_ReplyFailure_FallsBackToSendMessage(t *testing.T) {
var replyHits, sendHits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`))
return
}
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.URL.Path, "/reply") {
replyHits++
// 230002 = message not found per research report.
_, _ = w.Write([]byte(`{"code":230002,"msg":"message not found","data":{}}`))
return
}
sendHits++
_, _ = w.Write([]byte(`{"code":0,"msg":"","data":{"message_id":"om_ok"}}`))
}))
defer srv.Close()
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.deliverMessage(context.Background(), "oc_chat_1", "chat_id", "om_deleted_root", "text", `{"text":"hi"}`)
if err != nil {
t.Fatalf("deliverMessage should have succeeded via fallback, got: %v", err)
}
if replyHits != 1 {
t.Errorf("reply hits: got %d, want 1", replyHits)
}
if sendHits != 1 {
t.Errorf("send hits: got %d, want 1 (fallback)", sendHits)
}
}
// TestParseMessageEvent_ThreadIDOnlyStampedForActualThreads guards H1: plain
// quote replies MUST NOT be stamped as thread replies. Only messages where
// Lark's event payload carries a non-empty `thread_id` should flow through
// the reply endpoint. This test asserts parseMessageEvent preserves the
// distinction between root_id (populated on any reply) and thread_id (only
// inside topic threads).
func TestParseMessageEvent_ThreadIDOnlyStampedForActualThreads(t *testing.T) {
ch := &Channel{} // botOpenID empty — treat mentions as bot for test simplicity
cases := []struct {
name string
rootID string
threadID string
wantThread string
wantRoot string
}{
{"plain standalone message", "", "", "", ""},
{"plain quote reply (root set, no thread)", "om_old_msg", "", "", "om_old_msg"},
{"actual thread message", "om_thread_root", "om_thread_root", "om_thread_root", "om_thread_root"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ev := &MessageEvent{}
ev.Event.Message.MessageID = "om_current"
ev.Event.Message.ChatID = "oc_chat"
ev.Event.Message.ChatType = "group"
ev.Event.Message.MessageType = "text"
ev.Event.Message.Content = `{"text":"hi"}`
ev.Event.Message.RootID = tc.rootID
ev.Event.Message.ThreadID = tc.threadID
mc := ch.parseMessageEvent(ev)
if mc == nil {
t.Fatal("parseMessageEvent returned nil")
}
if mc.ThreadID != tc.wantThread {
t.Errorf("ThreadID: got %q, want %q", mc.ThreadID, tc.wantThread)
}
if mc.RootID != tc.wantRoot {
t.Errorf("RootID: got %q, want %q", mc.RootID, tc.wantRoot)
}
})
}
}