refactor(feishu): extract reply functions to feishu_reply.go
- Move reply-related functions to new feishu_reply.go - Move corresponding tests to feishu_reply_test.go - Extract magic number 600 to maxReplyContextLen constant - Unify replyTargetID/replyTargetFromMessage (prefer parent_id, fallback root_id) - Add source comment for containsFeishuUpgradePlaceholder
This commit is contained in:
parent
906adedc0e
commit
4b47541c87
4 changed files with 519 additions and 474 deletions
|
|
@ -6,6 +6,11 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||||
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
|
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -14,13 +19,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
|
||||||
|
|
||||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
||||||
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
|
||||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
|
||||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
|
@ -103,7 +101,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
c.SetRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoC("feishu", "Feishu channel started (websocket mode, replyctx-v4)")
|
logger.InfoC("feishu", "Feishu channel started (websocket mode)")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := wsClient.Start(runCtx); err != nil {
|
if err := wsClient.Start(runCtx); err != nil {
|
||||||
|
|
@ -483,270 +481,6 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
|
||||||
|
|
||||||
// --- Internal helpers ---
|
// --- Internal helpers ---
|
||||||
|
|
||||||
// buildInboundMetadata builds Feishu-specific metadata for downstream routing/context.
|
|
||||||
func buildInboundMetadata(message *larkim.EventMessage, sender *larkim.EventSender) map[string]string {
|
|
||||||
metadata := map[string]string{}
|
|
||||||
if message == nil {
|
|
||||||
return metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
messageID := stringValue(message.MessageId)
|
|
||||||
if messageID != "" {
|
|
||||||
metadata["message_id"] = messageID
|
|
||||||
}
|
|
||||||
|
|
||||||
messageType := stringValue(message.MessageType)
|
|
||||||
if messageType != "" {
|
|
||||||
metadata["message_type"] = messageType
|
|
||||||
}
|
|
||||||
|
|
||||||
chatType := stringValue(message.ChatType)
|
|
||||||
if chatType != "" {
|
|
||||||
metadata["chat_type"] = chatType
|
|
||||||
}
|
|
||||||
|
|
||||||
parentID := stringValue(message.ParentId)
|
|
||||||
if parentID != "" {
|
|
||||||
metadata["parent_id"] = parentID
|
|
||||||
}
|
|
||||||
|
|
||||||
rootID := stringValue(message.RootId)
|
|
||||||
if rootID != "" {
|
|
||||||
metadata["root_id"] = rootID
|
|
||||||
}
|
|
||||||
|
|
||||||
if replyTo := replyTargetMessageID(message); replyTo != "" {
|
|
||||||
metadata["reply_to_message_id"] = replyTo
|
|
||||||
}
|
|
||||||
|
|
||||||
threadID := stringValue(message.ThreadId)
|
|
||||||
if threadID != "" {
|
|
||||||
metadata["thread_id"] = threadID
|
|
||||||
}
|
|
||||||
|
|
||||||
if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" {
|
|
||||||
metadata["tenant_key"] = *sender.TenantKey
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
// prependReplyContext best-effort fetches replied message content and prepends it
|
|
||||||
// so session history keeps local conversational context for reply messages.
|
|
||||||
func (c *FeishuChannel) prependReplyContext(
|
|
||||||
ctx context.Context,
|
|
||||||
message *larkim.EventMessage,
|
|
||||||
chatID string,
|
|
||||||
content string,
|
|
||||||
mediaRefs []string,
|
|
||||||
) (string, []string) {
|
|
||||||
if message == nil {
|
|
||||||
return content, mediaRefs
|
|
||||||
}
|
|
||||||
|
|
||||||
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
targetMessageID := c.resolveReplyTargetMessageID(lookupCtx, message)
|
|
||||||
if targetMessageID == "" {
|
|
||||||
logger.DebugCF("feishu", "No reply target resolved; skip reply context", map[string]any{
|
|
||||||
"message_id": stringValue(message.MessageId),
|
|
||||||
"parent_id": stringValue(message.ParentId),
|
|
||||||
"root_id": stringValue(message.RootId),
|
|
||||||
"thread_id": stringValue(message.ThreadId),
|
|
||||||
})
|
|
||||||
return content, mediaRefs
|
|
||||||
}
|
|
||||||
|
|
||||||
repliedMessage, err := c.fetchMessageByID(lookupCtx, targetMessageID)
|
|
||||||
if err != nil {
|
|
||||||
logger.DebugCF("feishu", "Failed to fetch replied message context", map[string]any{
|
|
||||||
"target_message_id": targetMessageID,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return content, mediaRefs
|
|
||||||
}
|
|
||||||
|
|
||||||
messageType := stringValue(repliedMessage.MsgType)
|
|
||||||
rawContent := ""
|
|
||||||
if repliedMessage.Body != nil {
|
|
||||||
rawContent = stringValue(repliedMessage.Body.Content)
|
|
||||||
}
|
|
||||||
|
|
||||||
var repliedMediaRefs []string
|
|
||||||
if store := c.GetMediaStore(); store != nil {
|
|
||||||
repliedMediaRefs = c.downloadInboundMedia(lookupCtx, chatID, targetMessageID, messageType, rawContent, store)
|
|
||||||
if messageType == larkim.MsgTypeInteractive {
|
|
||||||
_, externalURLs := extractCardImageKeys(rawContent)
|
|
||||||
if len(externalURLs) > 0 {
|
|
||||||
repliedMediaRefs = append(repliedMediaRefs, externalURLs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repliedContent := normalizeRepliedContent(messageType, rawContent, repliedMediaRefs)
|
|
||||||
if len(repliedMediaRefs) > 0 {
|
|
||||||
mediaRefs = append(mediaRefs, repliedMediaRefs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatReplyContext(targetMessageID, repliedContent, content), mediaRefs
|
|
||||||
}
|
|
||||||
|
|
||||||
func replyTargetMessageID(message *larkim.EventMessage) string {
|
|
||||||
if message == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if parentID := stringValue(message.ParentId); parentID != "" {
|
|
||||||
return parentID
|
|
||||||
}
|
|
||||||
return stringValue(message.RootId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveReplyTargetMessageID resolves the replied target message ID.
|
|
||||||
// It first checks event fields, then falls back to querying current message detail.
|
|
||||||
func (c *FeishuChannel) resolveReplyTargetMessageID(ctx context.Context, message *larkim.EventMessage) string {
|
|
||||||
if targetID := replyTargetMessageID(message); targetID != "" {
|
|
||||||
logger.DebugCF("feishu", "Resolved reply target from event payload", map[string]any{
|
|
||||||
"message_id": stringValue(message.MessageId),
|
|
||||||
"parent_id": stringValue(message.ParentId),
|
|
||||||
"root_id": stringValue(message.RootId),
|
|
||||||
"target_id": targetID,
|
|
||||||
})
|
|
||||||
return targetID
|
|
||||||
}
|
|
||||||
|
|
||||||
currentMessageID := stringValue(message.MessageId)
|
|
||||||
if currentMessageID == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, err := c.fetchMessageByID(ctx, currentMessageID)
|
|
||||||
if err != nil {
|
|
||||||
logger.DebugCF("feishu", "Failed to query current message detail for reply info", map[string]any{
|
|
||||||
"message_id": currentMessageID,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
targetID := replyTargetFromMessage(msg)
|
|
||||||
if targetID != "" {
|
|
||||||
logger.DebugCF("feishu", "Resolved reply target from message detail", map[string]any{
|
|
||||||
"message_id": currentMessageID,
|
|
||||||
"parent_id": stringValue(msg.ParentId),
|
|
||||||
"root_id": stringValue(msg.RootId),
|
|
||||||
"target_id": targetID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return targetID
|
|
||||||
}
|
|
||||||
|
|
||||||
func replyTargetFromMessage(message *larkim.Message) string {
|
|
||||||
if message == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if parentID := stringValue(message.ParentId); parentID != "" {
|
|
||||||
return parentID
|
|
||||||
}
|
|
||||||
return stringValue(message.RootId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *FeishuChannel) fetchMessageByID(ctx context.Context, messageID string) (*larkim.Message, error) {
|
|
||||||
req := larkim.NewGetMessageReqBuilder().
|
|
||||||
MessageId(messageID).
|
|
||||||
Build()
|
|
||||||
|
|
||||||
resp, err := c.client.Im.V1.Message.Get(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("feishu get message: %w", err)
|
|
||||||
}
|
|
||||||
if !resp.Success() {
|
|
||||||
c.invalidateTokenOnAuthError(resp.Code)
|
|
||||||
return nil, fmt.Errorf("feishu get message api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
|
||||||
}
|
|
||||||
if resp.Data == nil || len(resp.Data.Items) == 0 || resp.Data.Items[0] == nil {
|
|
||||||
return nil, fmt.Errorf("feishu get message: empty response")
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.Data.Items[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeRepliedContent(messageType, rawContent string, mediaRefs []string) string {
|
|
||||||
content := extractContent(messageType, rawContent)
|
|
||||||
|
|
||||||
if containsFeishuUpgradePlaceholder(rawContent) || containsFeishuUpgradePlaceholder(content) {
|
|
||||||
content = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
content = appendMediaTags(content, messageType, mediaRefs)
|
|
||||||
if strings.TrimSpace(content) != "" {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
switch messageType {
|
|
||||||
case larkim.MsgTypeImage:
|
|
||||||
return "[replied image]"
|
|
||||||
case larkim.MsgTypeFile:
|
|
||||||
return "[replied file]"
|
|
||||||
case larkim.MsgTypeAudio:
|
|
||||||
return "[replied audio]"
|
|
||||||
case larkim.MsgTypeMedia:
|
|
||||||
return "[replied video]"
|
|
||||||
case larkim.MsgTypeInteractive:
|
|
||||||
return "[replied interactive card]"
|
|
||||||
default:
|
|
||||||
return "[replied message content unavailable]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsFeishuUpgradePlaceholder(s string) bool {
|
|
||||||
const upgradePromptPrefix = "\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef"
|
|
||||||
const upgradePromptPrefixEscaped = "\\u8bf7\\u5347\\u7ea7\\u81f3\\u6700\\u65b0\\u7248\\u672c\\u5ba2\\u6237\\u7aef"
|
|
||||||
return strings.Contains(s, upgradePromptPrefix) || strings.Contains(s, upgradePromptPrefixEscaped)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatReplyContext(parentID, repliedContent, content string) string {
|
|
||||||
parentID = strings.TrimSpace(parentID)
|
|
||||||
repliedContent = strings.TrimSpace(repliedContent)
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
|
|
||||||
if parentID == "" || repliedContent == "" {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
repliedContent = utils.Truncate(repliedContent, 600)
|
|
||||||
repliedContent = sanitizeReplyContextContent(repliedContent)
|
|
||||||
content = sanitizeReplyContextContent(content)
|
|
||||||
header := fmt.Sprintf("[replied_message id=%q]", parentID)
|
|
||||||
footer := "[/replied_message]"
|
|
||||||
if content == "" {
|
|
||||||
return header + "\n" + repliedContent + "\n" + footer
|
|
||||||
}
|
|
||||||
if hasLeadingCommandPrefix(content) {
|
|
||||||
return content + "\n\n" + header + "\n" + repliedContent + "\n" + footer
|
|
||||||
}
|
|
||||||
return header + "\n" + repliedContent + "\n" + footer + "\n\n[current_message]\n" + content + "\n[/current_message]"
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasLeadingCommandPrefix(s string) bool {
|
|
||||||
tokens := strings.Fields(strings.TrimSpace(s))
|
|
||||||
if len(tokens) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
first := tokens[0]
|
|
||||||
return strings.HasPrefix(first, "/") || strings.HasPrefix(first, "!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeReplyContextContent(s string) string {
|
|
||||||
tagEscaper := strings.NewReplacer(
|
|
||||||
"[replied_message", `\\[replied_message`,
|
|
||||||
"[/replied_message]", `\\[/replied_message]`,
|
|
||||||
"[current_message]", `\\[current_message]`,
|
|
||||||
"[/current_message]", `\\[/current_message]`,
|
|
||||||
)
|
|
||||||
return tagEscaper.Replace(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchBotOpenID calls the Feishu bot info API to retrieve and store the bot's open_id.
|
// fetchBotOpenID calls the Feishu bot info API to retrieve and store the bot's open_id.
|
||||||
func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error {
|
func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error {
|
||||||
resp, err := c.client.Do(ctx, &larkcore.ApiReq{
|
resp, err := c.client.Do(ctx, &larkcore.ApiReq{
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
package feishu
|
package feishu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
|
|
@ -281,221 +280,26 @@ func TestExtractFeishuSenderID(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildInboundMetadata(t *testing.T) {
|
|
||||||
strPtr := func(s string) *string { return &s }
|
|
||||||
|
|
||||||
t.Run("includes basic and reply fields", func(t *testing.T) {
|
|
||||||
message := &larkim.EventMessage{
|
|
||||||
MessageId: strPtr("om_msg_1"),
|
|
||||||
MessageType: strPtr("text"),
|
|
||||||
ChatType: strPtr("group"),
|
|
||||||
ParentId: strPtr("om_parent_1"),
|
|
||||||
RootId: strPtr("om_root_1"),
|
|
||||||
ThreadId: strPtr("omt_thread_1"),
|
|
||||||
}
|
|
||||||
sender := &larkim.EventSender{TenantKey: strPtr("tenant_x")}
|
|
||||||
|
|
||||||
got := buildInboundMetadata(message, sender)
|
|
||||||
|
|
||||||
if got["message_id"] != "om_msg_1" {
|
|
||||||
t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_1")
|
|
||||||
}
|
|
||||||
if got["message_type"] != "text" {
|
|
||||||
t.Fatalf("message_type = %q, want %q", got["message_type"], "text")
|
|
||||||
}
|
|
||||||
if got["chat_type"] != "group" {
|
|
||||||
t.Fatalf("chat_type = %q, want %q", got["chat_type"], "group")
|
|
||||||
}
|
|
||||||
if got["parent_id"] != "om_parent_1" {
|
|
||||||
t.Fatalf("parent_id = %q, want %q", got["parent_id"], "om_parent_1")
|
|
||||||
}
|
|
||||||
if got["reply_to_message_id"] != "om_parent_1" {
|
|
||||||
t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_parent_1")
|
|
||||||
}
|
|
||||||
if got["root_id"] != "om_root_1" {
|
|
||||||
t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_1")
|
|
||||||
}
|
|
||||||
if got["thread_id"] != "omt_thread_1" {
|
|
||||||
t.Fatalf("thread_id = %q, want %q", got["thread_id"], "omt_thread_1")
|
|
||||||
}
|
|
||||||
if got["tenant_key"] != "tenant_x" {
|
|
||||||
t.Fatalf("tenant_key = %q, want %q", got["tenant_key"], "tenant_x")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("falls back reply_to_message_id to root_id", func(t *testing.T) {
|
|
||||||
message := &larkim.EventMessage{
|
|
||||||
MessageId: strPtr("om_msg_3"),
|
|
||||||
RootId: strPtr("om_root_3"),
|
|
||||||
}
|
|
||||||
|
|
||||||
got := buildInboundMetadata(message, nil)
|
|
||||||
|
|
||||||
if got["root_id"] != "om_root_3" {
|
|
||||||
t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_3")
|
|
||||||
}
|
|
||||||
if got["reply_to_message_id"] != "om_root_3" {
|
|
||||||
t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_root_3")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("omits empty values", func(t *testing.T) {
|
|
||||||
message := &larkim.EventMessage{
|
|
||||||
MessageId: strPtr("om_msg_2"),
|
|
||||||
}
|
|
||||||
|
|
||||||
got := buildInboundMetadata(message, nil)
|
|
||||||
|
|
||||||
if got["message_id"] != "om_msg_2" {
|
|
||||||
t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_2")
|
|
||||||
}
|
|
||||||
if _, ok := got["parent_id"]; ok {
|
|
||||||
t.Fatalf("parent_id should be absent, got %q", got["parent_id"])
|
|
||||||
}
|
|
||||||
if _, ok := got["reply_to_message_id"]; ok {
|
|
||||||
t.Fatalf("reply_to_message_id should be absent, got %q", got["reply_to_message_id"])
|
|
||||||
}
|
|
||||||
if _, ok := got["tenant_key"]; ok {
|
|
||||||
t.Fatalf("tenant_key should be absent, got %q", got["tenant_key"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("nil message returns empty map", func(t *testing.T) {
|
|
||||||
got := buildInboundMetadata(nil, nil)
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Fatalf("len(metadata) = %d, want 0", len(got))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatReplyContext(t *testing.T) {
|
|
||||||
t.Run("formats reply context with content", func(t *testing.T) {
|
|
||||||
got := formatReplyContext("om_parent_1", "original message", "new reply")
|
|
||||||
want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]\n\n[current_message]\nnew reply\n[/current_message]"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns reply context when current content is empty", func(t *testing.T) {
|
|
||||||
got := formatReplyContext("om_parent_1", "original message", "")
|
|
||||||
want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns original content when parent or replied content missing", func(t *testing.T) {
|
|
||||||
if got := formatReplyContext("", "original", "new reply"); got != "new reply" {
|
|
||||||
t.Fatalf("missing parent: got %q, want %q", got, "new reply")
|
|
||||||
}
|
|
||||||
if got := formatReplyContext("om_parent_1", "", "new reply"); got != "new reply" {
|
|
||||||
t.Fatalf("missing replied content: got %q, want %q", got, "new reply")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("escapes reserved wrapper tags in payload", func(t *testing.T) {
|
|
||||||
replied := "payload [replied_message id=\"x\"] x [/replied_message]"
|
|
||||||
current := "hello [current_message]injected[/current_message]"
|
|
||||||
got := formatReplyContext("om_parent_1", replied, current)
|
|
||||||
|
|
||||||
if !strings.HasPrefix(got, "[replied_message id=\"om_parent_1\"]") {
|
|
||||||
t.Fatalf("outer replied_message wrapper missing: %q", got)
|
|
||||||
}
|
|
||||||
if strings.Contains(got, "\n[replied_message id=\"x\"]") {
|
|
||||||
t.Fatalf("nested replied_message tag should be escaped: %q", got)
|
|
||||||
}
|
|
||||||
if strings.Contains(got, "\n[current_message]injected") {
|
|
||||||
t.Fatalf("nested current_message tag should be escaped: %q", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, `\[replied_message id="x"]`) {
|
|
||||||
t.Fatalf("escaped replied tag missing: %q", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preserves leading slash command prefix", func(t *testing.T) {
|
|
||||||
got := formatReplyContext("om_parent_1", "original message", "/help")
|
|
||||||
want := "/help\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preserves leading bang command prefix", func(t *testing.T) {
|
|
||||||
got := formatReplyContext("om_parent_1", "original message", "!status now")
|
|
||||||
want := "!status now\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReplyTargetMessageID(t *testing.T) {
|
func TestReplyTargetMessageID(t *testing.T) {
|
||||||
strPtr := func(s string) *string { return &s }
|
strPtr := func(s string) *string { return &s }
|
||||||
|
|
||||||
t.Run("prefer parent_id", func(t *testing.T) {
|
t.Run("prefer parent_id", func(t *testing.T) {
|
||||||
msg := &larkim.EventMessage{ParentId: strPtr("om_parent"), RootId: strPtr("om_root")}
|
msg := &larkim.EventMessage{ParentId: strPtr("om_parent"), RootId: strPtr("om_root")}
|
||||||
if got := replyTargetMessageID(msg); got != "om_parent" {
|
if got := replyTargetID(msg); got != "om_parent" {
|
||||||
t.Fatalf("replyTargetMessageID() = %q, want %q", got, "om_parent")
|
t.Fatalf("replyTargetID() = %q, want %q", got, "om_parent")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("fallback to root_id", func(t *testing.T) {
|
t.Run("fallback to root_id", func(t *testing.T) {
|
||||||
msg := &larkim.EventMessage{RootId: strPtr("om_root")}
|
msg := &larkim.EventMessage{RootId: strPtr("om_root")}
|
||||||
if got := replyTargetMessageID(msg); got != "om_root" {
|
if got := replyTargetID(msg); got != "om_root" {
|
||||||
t.Fatalf("replyTargetMessageID() = %q, want %q", got, "om_root")
|
t.Fatalf("replyTargetID() = %q, want %q", got, "om_root")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("empty when no fields", func(t *testing.T) {
|
t.Run("empty when no fields", func(t *testing.T) {
|
||||||
if got := replyTargetMessageID(&larkim.EventMessage{}); got != "" {
|
if got := replyTargetID(&larkim.EventMessage{}); got != "" {
|
||||||
t.Fatalf("replyTargetMessageID() = %q, want empty", got)
|
t.Fatalf("replyTargetID() = %q, want empty", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeRepliedContent(t *testing.T) {
|
|
||||||
t.Run("filters feishu upgrade placeholder for interactive", func(t *testing.T) {
|
|
||||||
raw := `{"text":"\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef\uff0c\u4ee5\u67e5\u770b\u5185\u5bb9"}`
|
|
||||||
got := normalizeRepliedContent("interactive", raw, nil)
|
|
||||||
if got != "[replied interactive card]" {
|
|
||||||
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied interactive card]")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("keeps filename and file tag for replied file", func(t *testing.T) {
|
|
||||||
got := normalizeRepliedContent("file", `{"file_key":"file_xxx","file_name":"doc.pdf"}`, []string{"media://r1"})
|
|
||||||
if got != "doc.pdf [file]" {
|
|
||||||
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "doc.pdf [file]")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("falls back when file content missing", func(t *testing.T) {
|
|
||||||
got := normalizeRepliedContent("file", `{"file_key":"file_xxx"}`, nil)
|
|
||||||
if got != "[replied file]" {
|
|
||||||
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied file]")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasLeadingCommandPrefix(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{name: "slash command", input: "/help", want: true},
|
|
||||||
{name: "bang command", input: "!status", want: true},
|
|
||||||
{name: "leading spaces slash", input: " /ping arg", want: true},
|
|
||||||
{name: "normal text", input: "hello /help", want: false},
|
|
||||||
{name: "empty", input: "", want: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := hasLeadingCommandPrefix(tt.input); got != tt.want {
|
|
||||||
t.Fatalf("hasLeadingCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
278
pkg/channels/feishu/feishu_reply.go
Normal file
278
pkg/channels/feishu/feishu_reply.go
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||||
|
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxReplyContextLen = 600
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *FeishuChannel) prependReplyContext(
|
||||||
|
ctx context.Context,
|
||||||
|
message *larkim.EventMessage,
|
||||||
|
chatID string,
|
||||||
|
content string,
|
||||||
|
mediaRefs []string,
|
||||||
|
) (string, []string) {
|
||||||
|
if message == nil {
|
||||||
|
return content, mediaRefs
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
targetMessageID := c.resolveReplyTargetMessageID(lookupCtx, message)
|
||||||
|
if targetMessageID == "" {
|
||||||
|
logger.DebugCF("feishu", "No reply target resolved; skip reply context", map[string]any{
|
||||||
|
"message_id": stringValue(message.MessageId),
|
||||||
|
"parent_id": stringValue(message.ParentId),
|
||||||
|
"root_id": stringValue(message.RootId),
|
||||||
|
"thread_id": stringValue(message.ThreadId),
|
||||||
|
})
|
||||||
|
return content, mediaRefs
|
||||||
|
}
|
||||||
|
|
||||||
|
repliedMessage, err := c.fetchMessageByID(lookupCtx, targetMessageID)
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("feishu", "Failed to fetch replied message context", map[string]any{
|
||||||
|
"target_message_id": targetMessageID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return content, mediaRefs
|
||||||
|
}
|
||||||
|
|
||||||
|
messageType := stringValue(repliedMessage.MsgType)
|
||||||
|
rawContent := ""
|
||||||
|
if repliedMessage.Body != nil {
|
||||||
|
rawContent = stringValue(repliedMessage.Body.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
var repliedMediaRefs []string
|
||||||
|
if store := c.GetMediaStore(); store != nil {
|
||||||
|
repliedMediaRefs = c.downloadInboundMedia(lookupCtx, chatID, targetMessageID, messageType, rawContent, store)
|
||||||
|
if messageType == larkim.MsgTypeInteractive {
|
||||||
|
_, externalURLs := extractCardImageKeys(rawContent)
|
||||||
|
if len(externalURLs) > 0 {
|
||||||
|
repliedMediaRefs = append(repliedMediaRefs, externalURLs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repliedContent := normalizeRepliedContent(messageType, rawContent, repliedMediaRefs)
|
||||||
|
if len(repliedMediaRefs) > 0 {
|
||||||
|
mediaRefs = append(mediaRefs, repliedMediaRefs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatReplyContext(targetMessageID, repliedContent, content), mediaRefs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) resolveReplyTargetMessageID(ctx context.Context, message *larkim.EventMessage) string {
|
||||||
|
if targetID := replyTargetID(message); targetID != "" {
|
||||||
|
logger.DebugCF("feishu", "Resolved reply target from event payload", map[string]any{
|
||||||
|
"message_id": stringValue(message.MessageId),
|
||||||
|
"parent_id": stringValue(message.ParentId),
|
||||||
|
"root_id": stringValue(message.RootId),
|
||||||
|
"target_id": targetID,
|
||||||
|
})
|
||||||
|
return targetID
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMessageID := stringValue(message.MessageId)
|
||||||
|
if currentMessageID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := c.fetchMessageByID(ctx, currentMessageID)
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("feishu", "Failed to query current message detail for reply info", map[string]any{
|
||||||
|
"message_id": currentMessageID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID := replyTargetIDFromMessage(msg)
|
||||||
|
if targetID != "" {
|
||||||
|
logger.DebugCF("feishu", "Resolved reply target from message detail", map[string]any{
|
||||||
|
"message_id": currentMessageID,
|
||||||
|
"parent_id": stringValue(msg.ParentId),
|
||||||
|
"root_id": stringValue(msg.RootId),
|
||||||
|
"target_id": targetID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return targetID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) fetchMessageByID(ctx context.Context, messageID string) (*larkim.Message, error) {
|
||||||
|
req := larkim.NewGetMessageReqBuilder().
|
||||||
|
MessageId(messageID).
|
||||||
|
Build()
|
||||||
|
|
||||||
|
resp, err := c.client.Im.V1.Message.Get(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("feishu get message: %w", err)
|
||||||
|
}
|
||||||
|
if !resp.Success() {
|
||||||
|
c.invalidateTokenOnAuthError(resp.Code)
|
||||||
|
return nil, fmt.Errorf("feishu get message api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
if resp.Data == nil || len(resp.Data.Items) == 0 || resp.Data.Items[0] == nil {
|
||||||
|
return nil, fmt.Errorf("feishu get message: empty response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Data.Items[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replyTargetID(message *larkim.EventMessage) string {
|
||||||
|
if message == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if parentID := stringValue(message.ParentId); parentID != "" {
|
||||||
|
return parentID
|
||||||
|
}
|
||||||
|
return stringValue(message.RootId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replyTargetIDFromMessage(message *larkim.Message) string {
|
||||||
|
if message == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if parentID := stringValue(message.ParentId); parentID != "" {
|
||||||
|
return parentID
|
||||||
|
}
|
||||||
|
return stringValue(message.RootId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInboundMetadata(message *larkim.EventMessage, sender *larkim.EventSender) map[string]string {
|
||||||
|
metadata := map[string]string{}
|
||||||
|
if message == nil {
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
messageID := stringValue(message.MessageId)
|
||||||
|
if messageID != "" {
|
||||||
|
metadata["message_id"] = messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
messageType := stringValue(message.MessageType)
|
||||||
|
if messageType != "" {
|
||||||
|
metadata["message_type"] = messageType
|
||||||
|
}
|
||||||
|
|
||||||
|
chatType := stringValue(message.ChatType)
|
||||||
|
if chatType != "" {
|
||||||
|
metadata["chat_type"] = chatType
|
||||||
|
}
|
||||||
|
|
||||||
|
parentID := stringValue(message.ParentId)
|
||||||
|
if parentID != "" {
|
||||||
|
metadata["parent_id"] = parentID
|
||||||
|
}
|
||||||
|
|
||||||
|
rootID := stringValue(message.RootId)
|
||||||
|
if rootID != "" {
|
||||||
|
metadata["root_id"] = rootID
|
||||||
|
}
|
||||||
|
|
||||||
|
if replyTo := replyTargetID(message); replyTo != "" {
|
||||||
|
metadata["reply_to_message_id"] = replyTo
|
||||||
|
}
|
||||||
|
|
||||||
|
threadID := stringValue(message.ThreadId)
|
||||||
|
if threadID != "" {
|
||||||
|
metadata["thread_id"] = threadID
|
||||||
|
}
|
||||||
|
|
||||||
|
if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" {
|
||||||
|
metadata["tenant_key"] = *sender.TenantKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRepliedContent(messageType, rawContent string, mediaRefs []string) string {
|
||||||
|
content := extractContent(messageType, rawContent)
|
||||||
|
|
||||||
|
if containsFeishuUpgradePlaceholder(rawContent) || containsFeishuUpgradePlaceholder(content) {
|
||||||
|
content = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
content = appendMediaTags(content, messageType, mediaRefs)
|
||||||
|
if strings.TrimSpace(content) != "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
switch messageType {
|
||||||
|
case larkim.MsgTypeImage:
|
||||||
|
return "[replied image]"
|
||||||
|
case larkim.MsgTypeFile:
|
||||||
|
return "[replied file]"
|
||||||
|
case larkim.MsgTypeAudio:
|
||||||
|
return "[replied audio]"
|
||||||
|
case larkim.MsgTypeMedia:
|
||||||
|
return "[replied video]"
|
||||||
|
case larkim.MsgTypeInteractive:
|
||||||
|
return "[replied interactive card]"
|
||||||
|
default:
|
||||||
|
return "[replied message content unavailable]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFeishuUpgradePlaceholder(s string) bool {
|
||||||
|
upgradePrompt := "\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef"
|
||||||
|
upgradePromptEscaped := "\\u8bf7\\u5347\\u7ea7\\u81f3\\u6700\\u65b0\\u7248\\u672c\\u5ba2\\u6237\\u7aef"
|
||||||
|
return strings.Contains(s, upgradePrompt) || strings.Contains(s, upgradePromptEscaped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatReplyContext(parentID, repliedContent, content string) string {
|
||||||
|
parentID = strings.TrimSpace(parentID)
|
||||||
|
repliedContent = strings.TrimSpace(repliedContent)
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
|
||||||
|
if parentID == "" || repliedContent == "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
repliedContent = utils.Truncate(repliedContent, maxReplyContextLen)
|
||||||
|
repliedContent = sanitizeReplyContextContent(repliedContent)
|
||||||
|
content = sanitizeReplyContextContent(content)
|
||||||
|
header := fmt.Sprintf("[replied_message id=%q]", parentID)
|
||||||
|
footer := "[/replied_message]"
|
||||||
|
if content == "" {
|
||||||
|
return header + "\n" + repliedContent + "\n" + footer
|
||||||
|
}
|
||||||
|
if hasLeadingCommandPrefix(content) {
|
||||||
|
return content + "\n\n" + header + "\n" + repliedContent + "\n" + footer
|
||||||
|
}
|
||||||
|
return header + "\n" + repliedContent + "\n" + footer + "\n\n[current_message]\n" + content + "\n[/current_message]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasLeadingCommandPrefix(s string) bool {
|
||||||
|
tokens := strings.Fields(strings.TrimSpace(s))
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
first := tokens[0]
|
||||||
|
return strings.HasPrefix(first, "/") || strings.HasPrefix(first, "!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeReplyContextContent(s string) string {
|
||||||
|
tagEscaper := strings.NewReplacer(
|
||||||
|
"[replied_message", `\[replied_message`,
|
||||||
|
"[/replied_message]", `\[/replied_message]`,
|
||||||
|
"[current_message]", `\[current_message]`,
|
||||||
|
"[/current_message]", `\[/current_message]`,
|
||||||
|
)
|
||||||
|
return tagEscaper.Replace(s)
|
||||||
|
}
|
||||||
229
pkg/channels/feishu/feishu_reply_test.go
Normal file
229
pkg/channels/feishu/feishu_reply_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||||
|
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildInboundMetadata(t *testing.T) {
|
||||||
|
strPtr := func(s string) *string { return &s }
|
||||||
|
|
||||||
|
t.Run("includes basic and reply fields", func(t *testing.T) {
|
||||||
|
message := &larkim.EventMessage{
|
||||||
|
MessageId: strPtr("om_msg_1"),
|
||||||
|
MessageType: strPtr("text"),
|
||||||
|
ChatType: strPtr("group"),
|
||||||
|
ParentId: strPtr("om_parent_1"),
|
||||||
|
RootId: strPtr("om_root_1"),
|
||||||
|
ThreadId: strPtr("omt_thread_1"),
|
||||||
|
}
|
||||||
|
sender := &larkim.EventSender{TenantKey: strPtr("tenant_x")}
|
||||||
|
|
||||||
|
got := buildInboundMetadata(message, sender)
|
||||||
|
|
||||||
|
if got["message_id"] != "om_msg_1" {
|
||||||
|
t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_1")
|
||||||
|
}
|
||||||
|
if got["message_type"] != "text" {
|
||||||
|
t.Fatalf("message_type = %q, want %q", got["message_type"], "text")
|
||||||
|
}
|
||||||
|
if got["chat_type"] != "group" {
|
||||||
|
t.Fatalf("chat_type = %q, want %q", got["chat_type"], "group")
|
||||||
|
}
|
||||||
|
if got["parent_id"] != "om_parent_1" {
|
||||||
|
t.Fatalf("parent_id = %q, want %q", got["parent_id"], "om_parent_1")
|
||||||
|
}
|
||||||
|
if got["reply_to_message_id"] != "om_parent_1" {
|
||||||
|
t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_parent_1")
|
||||||
|
}
|
||||||
|
if got["root_id"] != "om_root_1" {
|
||||||
|
t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_1")
|
||||||
|
}
|
||||||
|
if got["thread_id"] != "omt_thread_1" {
|
||||||
|
t.Fatalf("thread_id = %q, want %q", got["thread_id"], "omt_thread_1")
|
||||||
|
}
|
||||||
|
if got["tenant_key"] != "tenant_x" {
|
||||||
|
t.Fatalf("tenant_key = %q, want %q", got["tenant_key"], "tenant_x")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("falls back reply_to_message_id to root_id", func(t *testing.T) {
|
||||||
|
message := &larkim.EventMessage{
|
||||||
|
MessageId: strPtr("om_msg_3"),
|
||||||
|
RootId: strPtr("om_root_3"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got := buildInboundMetadata(message, nil)
|
||||||
|
|
||||||
|
if got["root_id"] != "om_root_3" {
|
||||||
|
t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_3")
|
||||||
|
}
|
||||||
|
if got["reply_to_message_id"] != "om_root_3" {
|
||||||
|
t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_root_3")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("omits empty values", func(t *testing.T) {
|
||||||
|
message := &larkim.EventMessage{
|
||||||
|
MessageId: strPtr("om_msg_2"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got := buildInboundMetadata(message, nil)
|
||||||
|
|
||||||
|
if got["message_id"] != "om_msg_2" {
|
||||||
|
t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_2")
|
||||||
|
}
|
||||||
|
if _, ok := got["parent_id"]; ok {
|
||||||
|
t.Fatalf("parent_id should be absent, got %q", got["parent_id"])
|
||||||
|
}
|
||||||
|
if _, ok := got["reply_to_message_id"]; ok {
|
||||||
|
t.Fatalf("reply_to_message_id should be absent, got %q", got["reply_to_message_id"])
|
||||||
|
}
|
||||||
|
if _, ok := got["tenant_key"]; ok {
|
||||||
|
t.Fatalf("tenant_key should be absent, got %q", got["tenant_key"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil message returns empty map", func(t *testing.T) {
|
||||||
|
got := buildInboundMetadata(nil, nil)
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("len(metadata) = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatReplyContext(t *testing.T) {
|
||||||
|
t.Run("formats reply context with content", func(t *testing.T) {
|
||||||
|
got := formatReplyContext("om_parent_1", "original message", "new reply")
|
||||||
|
want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]\n\n[current_message]\nnew reply\n[/current_message]"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns reply context when current content is empty", func(t *testing.T) {
|
||||||
|
got := formatReplyContext("om_parent_1", "original message", "")
|
||||||
|
want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns original content when parent or replied content missing", func(t *testing.T) {
|
||||||
|
if got := formatReplyContext("", "original", "new reply"); got != "new reply" {
|
||||||
|
t.Fatalf("missing parent: got %q, want %q", got, "new reply")
|
||||||
|
}
|
||||||
|
if got := formatReplyContext("om_parent_1", "", "new reply"); got != "new reply" {
|
||||||
|
t.Fatalf("missing replied content: got %q, want %q", got, "new reply")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("escapes reserved wrapper tags in payload", func(t *testing.T) {
|
||||||
|
replied := "payload [replied_message id=\"x\"] x [/replied_message]"
|
||||||
|
current := "hello [current_message]injected[/current_message]"
|
||||||
|
got := formatReplyContext("om_parent_1", replied, current)
|
||||||
|
|
||||||
|
if !strings.HasPrefix(got, "[replied_message id=\"om_parent_1\"]") {
|
||||||
|
t.Fatalf("outer replied_message wrapper missing: %q", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "\n[replied_message id=\"x\"]") {
|
||||||
|
t.Fatalf("nested replied_message tag should be escaped: %q", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "\n[current_message]injected") {
|
||||||
|
t.Fatalf("nested current_message tag should be escaped: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `\[replied_message id="x"]`) {
|
||||||
|
t.Fatalf("escaped replied tag missing: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserves leading slash command prefix", func(t *testing.T) {
|
||||||
|
got := formatReplyContext("om_parent_1", "original message", "/help")
|
||||||
|
want := "/help\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserves leading bang command prefix", func(t *testing.T) {
|
||||||
|
got := formatReplyContext("om_parent_1", "original message", "!status now")
|
||||||
|
want := "!status now\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("formatReplyContext() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplyTargetID(t *testing.T) {
|
||||||
|
strPtr := func(s string) *string { return &s }
|
||||||
|
|
||||||
|
t.Run("prefer parent_id", func(t *testing.T) {
|
||||||
|
msg := &larkim.EventMessage{ParentId: strPtr("om_parent"), RootId: strPtr("om_root")}
|
||||||
|
if got := replyTargetID(msg); got != "om_parent" {
|
||||||
|
t.Fatalf("replyTargetID() = %q, want %q", got, "om_parent")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fallback to root_id", func(t *testing.T) {
|
||||||
|
msg := &larkim.EventMessage{RootId: strPtr("om_root")}
|
||||||
|
if got := replyTargetID(msg); got != "om_root" {
|
||||||
|
t.Fatalf("replyTargetID() = %q, want %q", got, "om_root")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty when no fields", func(t *testing.T) {
|
||||||
|
if got := replyTargetID(&larkim.EventMessage{}); got != "" {
|
||||||
|
t.Fatalf("replyTargetID() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeRepliedContent(t *testing.T) {
|
||||||
|
t.Run("filters feishu upgrade placeholder for interactive", func(t *testing.T) {
|
||||||
|
raw := `{"text":"\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef\uff0c\u4ee5\u67e5\u770b\u5185\u5bb9"}`
|
||||||
|
got := normalizeRepliedContent("interactive", raw, nil)
|
||||||
|
if got != "[replied interactive card]" {
|
||||||
|
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied interactive card]")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps filename and file tag for replied file", func(t *testing.T) {
|
||||||
|
got := normalizeRepliedContent("file", `{"file_key":"file_xxx","file_name":"doc.pdf"}`, []string{"media://r1"})
|
||||||
|
if got != "doc.pdf [file]" {
|
||||||
|
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "doc.pdf [file]")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("falls back when file content missing", func(t *testing.T) {
|
||||||
|
got := normalizeRepliedContent("file", `{"file_key":"file_xxx"}`, nil)
|
||||||
|
if got != "[replied file]" {
|
||||||
|
t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied file]")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasLeadingCommandPrefix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "slash command", input: "/help", want: true},
|
||||||
|
{name: "bang command", input: "!status", want: true},
|
||||||
|
{name: "leading spaces slash", input: " /ping arg", want: true},
|
||||||
|
{name: "normal text", input: "hello /help", want: false},
|
||||||
|
{name: "empty", input: "", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := hasLeadingCommandPrefix(tt.input); got != tt.want {
|
||||||
|
t.Fatalf("hasLeadingCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue