chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,561 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/config"
|
||||
|
||||
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher/callback"
|
||||
)
|
||||
|
||||
func TestStartReturnsMissingWebSocketSecret(t *testing.T) {
|
||||
t.Setenv("FEISHU_TEST_SECRET", "")
|
||||
a := New(config.FeishuBotConfig{
|
||||
AppID: "cli-test",
|
||||
AppSecretEnv: "FEISHU_TEST_SECRET",
|
||||
Mode: "websocket",
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
err := a.Start(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "FEISHU_TEST_SECRET") {
|
||||
t.Fatalf("Start error = %v, want missing secret env", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationTokenValidRequiresConfiguredToken(t *testing.T) {
|
||||
a := &adapter{cfg: config.FeishuBotConfig{VerificationToken: "expected"}}
|
||||
|
||||
if a.verificationTokenValid("") {
|
||||
t.Fatal("missing token should be rejected when verification token is configured")
|
||||
}
|
||||
if a.verificationTokenValid("wrong") {
|
||||
t.Fatal("wrong token should be rejected")
|
||||
}
|
||||
if !a.verificationTokenValid("expected") {
|
||||
t.Fatal("matching token should be accepted")
|
||||
}
|
||||
|
||||
a.cfg.VerificationToken = ""
|
||||
if a.verificationTokenValid("") {
|
||||
t.Fatal("unconfigured verification token should deny all callers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSeenConcurrent(t *testing.T) {
|
||||
a := &adapter{seen: make(map[string]bool)}
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_ = a.markSeen(fmt.Sprintf("evt-%d", i%5))
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := len(a.seen); got != 5 {
|
||||
t.Fatalf("seen size = %d, want 5", got)
|
||||
}
|
||||
if a.markSeen("evt-1") != true {
|
||||
t.Fatal("second markSeen call should report duplicate")
|
||||
}
|
||||
if a.markSeen("") {
|
||||
t.Fatal("empty event id should not be treated as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCardActionUsesChatType(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw := []byte(`{
|
||||
"event": {
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "open-user"}
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "msg-1",
|
||||
"open_chat_id": "chat-1"
|
||||
},
|
||||
"action": {
|
||||
"value": {
|
||||
"command": "/approve approval-1",
|
||||
"chat_type": "dm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if !a.handleCardAction(raw) {
|
||||
t.Fatal("handleCardAction returned false")
|
||||
}
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.ChatType != bot.ChatDM {
|
||||
t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDM)
|
||||
}
|
||||
if msg.Text != "/approve approval-1" {
|
||||
t.Fatalf("text = %q, want /approve approval-1", msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCardActionEnqueuesAskAnswerCommand(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw := []byte(`{
|
||||
"event": {
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "open-user"}
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "msg-ask",
|
||||
"open_chat_id": "chat-ask"
|
||||
},
|
||||
"action": {
|
||||
"value": {
|
||||
"command": "/answer ask-1 2",
|
||||
"chat_type": "dm",
|
||||
"user_id": "allowed-user"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if !a.handleCardAction(raw) {
|
||||
t.Fatal("handleCardAction returned false")
|
||||
}
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.Text != "/answer ask-1 2" {
|
||||
t.Fatalf("text = %q, want /answer ask-1 2", msg.Text)
|
||||
}
|
||||
if msg.UserID != "allowed-user" {
|
||||
t.Fatalf("user id = %q, want allowed-user", msg.UserID)
|
||||
}
|
||||
if msg.OperatorID != "open-user" {
|
||||
t.Fatalf("operator id = %q, want open-user (the actual clicker, not the card requester)", msg.OperatorID)
|
||||
}
|
||||
if msg.ChatID != "chat-ask" || msg.MessageID != "msg-ask" {
|
||||
t.Fatalf("message routing = chat %q msg %q, want chat-ask/msg-ask", msg.ChatID, msg.MessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCardActionAcceptsDirectOperatorID(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw := []byte(`{
|
||||
"event": {
|
||||
"operator": {
|
||||
"open_id": "open-user-direct"
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "msg-1",
|
||||
"open_chat_id": "chat-1"
|
||||
},
|
||||
"action": {
|
||||
"value": {
|
||||
"command": "/approve approval-1",
|
||||
"chat_type": "dm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if !a.handleCardAction(raw) {
|
||||
t.Fatal("handleCardAction returned false")
|
||||
}
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.UserID != "open-user-direct" {
|
||||
t.Fatalf("user id = %q, want open-user-direct", msg.UserID)
|
||||
}
|
||||
if msg.OperatorID != "open-user-direct" {
|
||||
t.Fatalf("operator id = %q, want open-user-direct", msg.OperatorID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCardActionDoesNotTrustCardRequesterAsOperator(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw := []byte(`{
|
||||
"event": {
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "clicker"}
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "msg-1",
|
||||
"open_chat_id": "chat-1"
|
||||
},
|
||||
"action": {
|
||||
"value": {
|
||||
"command": "/approve approval-1",
|
||||
"chat_type": "group",
|
||||
"user_id": "requester"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if !a.handleCardAction(raw) {
|
||||
t.Fatal("handleCardAction returned false")
|
||||
}
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.UserID != "requester" {
|
||||
t.Fatalf("user id = %q, want requester (routing follows the card value)", msg.UserID)
|
||||
}
|
||||
if msg.OperatorID != "clicker" {
|
||||
t.Fatalf("operator id = %q, want clicker (gate follows the real button presser)", msg.OperatorID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageTreatsTopicGroupAsGroup(t *testing.T) {
|
||||
a := &adapter{
|
||||
cfg: config.FeishuBotConfig{RequireMention: true},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-topic",
|
||||
ChatID: "chat-topic",
|
||||
ChatType: "topic_group",
|
||||
MsgType: "text",
|
||||
Content: `{"text":"hello"}`,
|
||||
Sender: feishuSender{SenderID: struct {
|
||||
UserID string `json:"user_id"`
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
}{OpenID: "open-user"}},
|
||||
Mentions: []feishuMention{{Key: "@_user_1"}},
|
||||
})
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.ChatType != bot.ChatGroup {
|
||||
t.Fatalf("chat type = %q, want group", msg.ChatType)
|
||||
}
|
||||
if msg.ChatID != "chat-topic" || msg.UserID != "open-user" {
|
||||
t.Fatalf("message = %+v, want topic group routing", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageRequiresMentionInTopicGroup(t *testing.T) {
|
||||
a := &adapter{
|
||||
cfg: config.FeishuBotConfig{RequireMention: true},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-topic",
|
||||
ChatID: "chat-topic",
|
||||
ChatType: "topic_group",
|
||||
MsgType: "text",
|
||||
Content: `{"text":"hello"}`,
|
||||
Sender: feishuSender{SenderID: struct {
|
||||
UserID string `json:"user_id"`
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
}{OpenID: "open-user"}},
|
||||
})
|
||||
|
||||
select {
|
||||
case msg := <-a.msgCh:
|
||||
t.Fatalf("message without mention was queued: %+v", msg)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketDispatcherHandlesCardActionTrigger(t *testing.T) {
|
||||
a := &adapter{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
}
|
||||
raw := []byte(`{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt-card-1",
|
||||
"event_type": "card.action.trigger",
|
||||
"token": ""
|
||||
},
|
||||
"event": {
|
||||
"operator": {
|
||||
"operator_id": {
|
||||
"open_id": "open-user",
|
||||
"union_id": "union-user"
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "msg-card-1",
|
||||
"open_chat_id": "chat-card-1"
|
||||
},
|
||||
"action": {
|
||||
"value": {
|
||||
"command": "/approve approval-2",
|
||||
"chat_type": "dm",
|
||||
"user_id": "allowed-user"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resp, err := a.newEventDispatcher().Do(context.Background(), raw)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatcher.Do returned error: %v", err)
|
||||
}
|
||||
toast, ok := resp.(*callback.CardActionTriggerResponse)
|
||||
if !ok {
|
||||
t.Fatalf("response = %T, want *callback.CardActionTriggerResponse", resp)
|
||||
}
|
||||
if toast.Toast == nil || toast.Toast.Type != "success" {
|
||||
t.Fatalf("toast = %#v, want success toast", toast.Toast)
|
||||
}
|
||||
|
||||
msg := <-a.msgCh
|
||||
if msg.Text != "/approve approval-2" {
|
||||
t.Fatalf("text = %q, want /approve approval-2", msg.Text)
|
||||
}
|
||||
if msg.ChatID != "chat-card-1" {
|
||||
t.Fatalf("chat id = %q, want chat-card-1", msg.ChatID)
|
||||
}
|
||||
if msg.UserID != "allowed-user" {
|
||||
t.Fatalf("user id = %q, want allowed-user", msg.UserID)
|
||||
}
|
||||
|
||||
_, err = a.newEventDispatcher().Do(context.Background(), raw)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate dispatcher.Do returned error: %v", err)
|
||||
}
|
||||
select {
|
||||
case duplicate := <-a.msgCh:
|
||||
t.Fatalf("duplicate card action enqueued message: %#v", duplicate)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// pngHeader 是合法 PNG 签名,足够 http.DetectContentType 识别为 image/png。
|
||||
var pngHeader = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}
|
||||
|
||||
func newTestAdapter(fetch func(ctx context.Context, messageID, key, typ string) ([]byte, string, error)) *adapter {
|
||||
return &adapter{
|
||||
cfg: config.FeishuBotConfig{},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
msgCh: make(chan bot.InboundMessage, 1),
|
||||
fetchResource: fetch,
|
||||
}
|
||||
}
|
||||
|
||||
func testSender(openID string) feishuSender {
|
||||
return feishuSender{SenderID: struct {
|
||||
UserID string `json:"user_id"`
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
}{OpenID: openID}}
|
||||
}
|
||||
|
||||
func TestHandleMessageDefersImageDownload(t *testing.T) {
|
||||
fetchCalls := 0
|
||||
a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
||||
fetchCalls++
|
||||
if messageID != "msg-img" || key != "img-key-1" || typ != "image" {
|
||||
t.Fatalf("fetch args = %s/%s/%s, want msg-img/img-key-1/image", messageID, key, typ)
|
||||
}
|
||||
return pngHeader, "", nil
|
||||
})
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-img",
|
||||
ChatID: "chat-1",
|
||||
ChatType: "p2p",
|
||||
MsgType: "image",
|
||||
Content: `{"image_key":"img-key-1"}`,
|
||||
Sender: testSender("open-user"),
|
||||
})
|
||||
|
||||
msg := <-a.msgCh
|
||||
if len(msg.Media) != 1 {
|
||||
t.Fatalf("media items = %d, want 1", len(msg.Media))
|
||||
}
|
||||
if fetchCalls != 0 {
|
||||
t.Fatalf("resource fetched %d times before gateway admission, want 0", fetchCalls)
|
||||
}
|
||||
data, _, err := msg.Media[0].Load(context.Background())
|
||||
if err != nil || !strings.HasPrefix(string(data), string(pngHeader)) {
|
||||
t.Fatalf("deferred load = %x, %v; want png bytes", data, err)
|
||||
}
|
||||
if fetchCalls != 1 {
|
||||
t.Fatalf("resource fetched %d times after load, want 1", fetchCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageFileDownloadFailureKeepsDeferredPlaceholder(t *testing.T) {
|
||||
fetchCalls := 0
|
||||
a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
||||
fetchCalls++
|
||||
return nil, "", fmt.Errorf("boom")
|
||||
})
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-file",
|
||||
ChatID: "chat-1",
|
||||
ChatType: "p2p",
|
||||
MsgType: "file",
|
||||
Content: `{"file_key":"file-key-1","file_name":"report.pdf"}`,
|
||||
Sender: testSender("open-user"),
|
||||
})
|
||||
|
||||
msg := <-a.msgCh
|
||||
if len(msg.Media) != 1 || fetchCalls != 0 {
|
||||
t.Fatalf("media items/fetches = %d/%d, want one deferred item and no pre-admission fetch", len(msg.Media), fetchCalls)
|
||||
}
|
||||
if _, _, err := msg.Media[0].Load(context.Background()); err == nil {
|
||||
t.Fatal("deferred load should report the injected failure")
|
||||
}
|
||||
if !strings.Contains(msg.Media[0].FailureText, "report.pdf") {
|
||||
t.Fatalf("fallback = %q, want download-failure placeholder naming the file", msg.Media[0].FailureText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageParsesPostContent(t *testing.T) {
|
||||
fetchCalls := 0
|
||||
a := newTestAdapter(func(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
||||
fetchCalls++
|
||||
if key != "post-img-1" || typ != "image" {
|
||||
t.Fatalf("fetch args = %s/%s, want post-img-1/image", key, typ)
|
||||
}
|
||||
return pngHeader, "", nil
|
||||
})
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-post",
|
||||
ChatID: "chat-1",
|
||||
ChatType: "p2p",
|
||||
MsgType: "post",
|
||||
Content: `{"title":"周报","content":[[{"tag":"text","text":"进展见 "},{"tag":"a","text":"文档","href":"https://example.com/doc"},{"tag":"at","user_name":"张三"}],[{"tag":"img","image_key":"post-img-1"}]]}`,
|
||||
Sender: testSender("open-user"),
|
||||
})
|
||||
|
||||
msg := <-a.msgCh
|
||||
for _, want := range []string{"周报", "进展见", "文档 (https://example.com/doc)", "@张三"} {
|
||||
if !strings.Contains(msg.Text, want) {
|
||||
t.Fatalf("text = %q, want it to contain %q", msg.Text, want)
|
||||
}
|
||||
}
|
||||
if len(msg.Media) != 1 {
|
||||
t.Fatalf("media items = %d, want one deferred embedded image", len(msg.Media))
|
||||
}
|
||||
if fetchCalls != 0 {
|
||||
t.Fatalf("post image fetched %d times before gateway admission, want 0", fetchCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageUnsupportedTypeIgnored(t *testing.T) {
|
||||
a := newTestAdapter(nil)
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-audio",
|
||||
ChatID: "chat-1",
|
||||
ChatType: "p2p",
|
||||
MsgType: "audio",
|
||||
Content: `{"file_key":"audio-key"}`,
|
||||
Sender: testSender("open-user"),
|
||||
})
|
||||
|
||||
select {
|
||||
case msg := <-a.msgCh:
|
||||
t.Fatalf("unsupported message type was queued: %+v", msg)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceMentionPlaceholdersStripsBotAndNamesOthers(t *testing.T) {
|
||||
a := newTestAdapter(nil)
|
||||
a.botID = "ou-bot"
|
||||
got := a.replaceMentionPlaceholders("@_user_1 帮 @_user_2 看看这个", []mentionRef{
|
||||
{Key: "@_user_1", OpenID: "ou-bot", Name: "Reasonix"},
|
||||
{Key: "@_user_2", OpenID: "ou-zhang", Name: "张三"},
|
||||
})
|
||||
if got != "帮 @张三 看看这个" {
|
||||
t.Fatalf("text = %q, want bot mention stripped and peer mention named", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMentionGatingRequiresBotWhenIdentityKnown(t *testing.T) {
|
||||
a := newTestAdapter(nil)
|
||||
a.cfg.RequireMention = true
|
||||
a.botID = "ou-bot"
|
||||
a.handleMessage(context.Background(), feishuMsgEvent{
|
||||
MessageID: "msg-other",
|
||||
ChatID: "chat-group",
|
||||
ChatType: "group",
|
||||
MsgType: "text",
|
||||
Content: `{"text":"@_user_1 在吗"}`,
|
||||
Sender: testSender("open-user"),
|
||||
Mentions: []feishuMention{{Key: "@_user_1", Name: "张三", ID: struct {
|
||||
OpenID string `json:"open_id"`
|
||||
}{OpenID: "ou-zhang"}}},
|
||||
})
|
||||
|
||||
select {
|
||||
case msg := <-a.msgCh:
|
||||
t.Fatalf("message mentioning someone else was queued: %+v", msg)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMarkdownCard(t *testing.T) {
|
||||
content, err := buildMarkdownCard("hello [docs](https://example.com)")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMarkdownCard: %v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Schema string `json:"schema"`
|
||||
Config struct {
|
||||
UpdateMulti bool `json:"update_multi"`
|
||||
} `json:"config"`
|
||||
Body struct {
|
||||
Elements []struct {
|
||||
Tag string `json:"tag"`
|
||||
Content string `json:"content"`
|
||||
} `json:"elements"`
|
||||
} `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &payload); err != nil {
|
||||
t.Fatalf("card content should be valid json: %v", err)
|
||||
}
|
||||
if payload.Schema != "2.0" {
|
||||
t.Fatalf("schema = %q, want 2.0", payload.Schema)
|
||||
}
|
||||
// update_multi must be set or Im.Message.Patch (streaming) is rejected.
|
||||
if !payload.Config.UpdateMulti {
|
||||
t.Fatal("card config.update_multi = false, want true so the card is patchable")
|
||||
}
|
||||
if len(payload.Body.Elements) != 1 || payload.Body.Elements[0].Tag != "markdown" {
|
||||
t.Fatalf("elements = %#v, want one markdown element", payload.Body.Elements)
|
||||
}
|
||||
if payload.Body.Elements[0].Content != "hello [docs](https://example.com)" {
|
||||
t.Fatalf("content = %q, want original markdown", payload.Body.Elements[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyFallbackOnlyForRecalledMessage(t *testing.T) {
|
||||
if isReplyFallbackError(fmt.Errorf("i/o timeout")) {
|
||||
t.Fatal("ambiguous transport errors must not fall back to Create")
|
||||
}
|
||||
if isReplyFallbackError(&feishuAPIError{op: "reply", code: 230013, msg: "no availability"}) {
|
||||
t.Fatal("permission errors must not fall back to Create")
|
||||
}
|
||||
if !isReplyFallbackError(&feishuAPIError{op: "reply", code: feishuReplyRecalledCode, msg: "recalled"}) {
|
||||
t.Fatal("a recalled target should fall back to Create")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
// maxFeishuMediaBytes matches the bot gateway's inbound media cap.
|
||||
const maxFeishuMediaBytes = 25 * 1024 * 1024
|
||||
|
||||
const resourceDownloadTimeout = 30 * time.Second
|
||||
|
||||
type imageContent struct {
|
||||
ImageKey string `json:"image_key"`
|
||||
}
|
||||
|
||||
type fileContent struct {
|
||||
FileKey string `json:"file_key"`
|
||||
FileName string `json:"file_name"`
|
||||
}
|
||||
|
||||
// mentionRef 是 SDK 事件与 webhook 事件两种 mention 表示的归一化形态。
|
||||
type mentionRef struct {
|
||||
Key string
|
||||
OpenID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func sdkMentionRefs(mentions []*larkim.MentionEvent) []mentionRef {
|
||||
refs := make([]mentionRef, 0, len(mentions))
|
||||
for _, m := range mentions {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
ref := mentionRef{Key: stringPtrValue(m.Key), Name: stringPtrValue(m.Name)}
|
||||
if m.Id != nil {
|
||||
ref.OpenID = stringPtrValue(m.Id.OpenId)
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// mentionsBot 判断消息是否 @ 了本 bot。bot open_id 未知时退回“任意 @ 均放行”
|
||||
// 的旧行为,避免因为 bot/v3/info 拉取失败而完全失聪。
|
||||
func (a *adapter) mentionsBot(mentions []mentionRef) bool {
|
||||
botID := a.botOpenID()
|
||||
if botID == "" {
|
||||
return len(mentions) > 0
|
||||
}
|
||||
for _, m := range mentions {
|
||||
if m.OpenID != "" && m.OpenID == botID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// replaceMentionPlaceholders 把 "@_user_N" 占位符还原为可读的 "@显示名";
|
||||
// bot 自己的占位符直接移除,模型看到的输入不再包含对 bot 的 @。
|
||||
func (a *adapter) replaceMentionPlaceholders(text string, mentions []mentionRef) string {
|
||||
botID := a.botOpenID()
|
||||
for _, m := range mentions {
|
||||
if m.Key == "" {
|
||||
continue
|
||||
}
|
||||
replacement := ""
|
||||
if (botID == "" || m.OpenID != botID) && m.Name != "" {
|
||||
replacement = "@" + m.Name
|
||||
}
|
||||
text = strings.ReplaceAll(text, m.Key, replacement)
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// parseInboundContent parses Feishu content without fetching remote resources.
|
||||
// Media loaders run later, after the gateway allowlist admits the sender.
|
||||
func (a *adapter) parseInboundContent(msgType, content, messageID string) (text string, media []bot.InboundMedia, ok bool) {
|
||||
switch msgType {
|
||||
case "text":
|
||||
var tc textContent
|
||||
if err := json.Unmarshal([]byte(content), &tc); err != nil {
|
||||
a.logger.Warn("feishu message ignored", "reason", "bad_content", "message", logHash(messageID), "err", err)
|
||||
return "", nil, false
|
||||
}
|
||||
return tc.Text, nil, true
|
||||
case "image":
|
||||
var ic imageContent
|
||||
if err := json.Unmarshal([]byte(content), &ic); err != nil || strings.TrimSpace(ic.ImageKey) == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
return "", []bot.InboundMedia{a.deferredMedia(messageID, ic.ImageKey, "image", "", "[图片下载失败]")}, true
|
||||
case "sticker":
|
||||
var fc fileContent
|
||||
if err := json.Unmarshal([]byte(content), &fc); err != nil || strings.TrimSpace(fc.FileKey) == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
return "", []bot.InboundMedia{a.deferredMedia(messageID, fc.FileKey, "image", "", "[sticker]")}, true
|
||||
case "file":
|
||||
var fc fileContent
|
||||
if err := json.Unmarshal([]byte(content), &fc); err != nil || strings.TrimSpace(fc.FileKey) == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
return "", []bot.InboundMedia{a.deferredMedia(messageID, fc.FileKey, "file", fc.FileName, fmt.Sprintf("[文件下载失败: %s]", fc.FileName))}, true
|
||||
case "post":
|
||||
return a.parsePostContent(content, messageID)
|
||||
default:
|
||||
return "", nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// parsePostContent 解析富文本(post)消息:抽取文本、链接、@,并下载内嵌图片。
|
||||
func (a *adapter) parsePostContent(content, messageID string) (string, []bot.InboundMedia, bool) {
|
||||
var post struct {
|
||||
Title string `json:"title"`
|
||||
Content [][]struct {
|
||||
Tag string `json:"tag"`
|
||||
Text string `json:"text"`
|
||||
Href string `json:"href"`
|
||||
UserName string `json:"user_name"`
|
||||
ImageKey string `json:"image_key"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &post); err != nil {
|
||||
a.logger.Warn("feishu message ignored", "reason", "bad_post_content", "message", logHash(messageID), "err", err)
|
||||
return "", nil, false
|
||||
}
|
||||
var b strings.Builder
|
||||
var media []bot.InboundMedia
|
||||
if title := strings.TrimSpace(post.Title); title != "" {
|
||||
b.WriteString(title)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, paragraph := range post.Content {
|
||||
for _, run := range paragraph {
|
||||
switch run.Tag {
|
||||
case "text", "code_block", "md":
|
||||
b.WriteString(run.Text)
|
||||
case "a":
|
||||
switch {
|
||||
case run.Text != "" && run.Href != "" && run.Text != run.Href:
|
||||
fmt.Fprintf(&b, "%s (%s)", run.Text, run.Href)
|
||||
case run.Href != "":
|
||||
b.WriteString(run.Href)
|
||||
default:
|
||||
b.WriteString(run.Text)
|
||||
}
|
||||
case "at":
|
||||
if run.UserName != "" {
|
||||
b.WriteString("@" + run.UserName)
|
||||
}
|
||||
case "img":
|
||||
if strings.TrimSpace(run.ImageKey) == "" {
|
||||
continue
|
||||
}
|
||||
media = append(media, a.deferredMedia(messageID, run.ImageKey, "image", "", "[图片下载失败]"))
|
||||
case "media":
|
||||
b.WriteString("[视频]")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n"), media, true
|
||||
}
|
||||
|
||||
func (a *adapter) deferredMedia(messageID, key, typ, name, failureText string) bot.InboundMedia {
|
||||
return bot.InboundMedia{
|
||||
Name: name,
|
||||
FailureText: failureText,
|
||||
Load: func(ctx context.Context) ([]byte, string, error) {
|
||||
fetch := a.fetchResource
|
||||
if fetch == nil {
|
||||
fetch = a.sdkFetchResource
|
||||
}
|
||||
data, fetchedName, err := fetch(ctx, messageID, key, typ)
|
||||
if err != nil {
|
||||
a.logger.Warn("feishu media download failed", "message", logHash(messageID), "type", typ, "err", err)
|
||||
return nil, "", err
|
||||
}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
fetchedName = name
|
||||
}
|
||||
return data, fetchedName, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// sdkFetchResource 经 SDK 鉴权接口下载消息资源(图片/文件)。
|
||||
func (a *adapter) sdkFetchResource(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
||||
client, err := a.sdkClient()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, resourceDownloadTimeout)
|
||||
defer cancel()
|
||||
var data []byte
|
||||
var fileName string
|
||||
err = withTransientRetry(ctx, a.logger, "get message resource", func(ctx context.Context) error {
|
||||
req := larkim.NewGetMessageResourceReqBuilder().
|
||||
MessageId(messageID).
|
||||
FileKey(key).
|
||||
Type(typ).
|
||||
Build()
|
||||
resp, err := client.Im.MessageResource.Get(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("feishu resource error: empty response")
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu resource error: %s", feishuCodeError(resp.Code, resp.Msg))
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.File, maxFeishuMediaBytes+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) == 0 || len(raw) > maxFeishuMediaBytes {
|
||||
return fmt.Errorf("feishu resource must be between 1 byte and 25 MB")
|
||||
}
|
||||
data, fileName = raw, resp.FileName
|
||||
return nil
|
||||
})
|
||||
return data, fileName, err
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
// EditMessage 原地编辑一条已发送的消息(Im.Message.Patch,仅对 interactive
|
||||
// card 消息有效)。bot 渲染器用它实现回合中的流式输出。
|
||||
func (a *adapter) EditMessage(ctx context.Context, messageID string, msg bot.OutboundMessage) error {
|
||||
messageID = strings.TrimSpace(messageID)
|
||||
if messageID == "" {
|
||||
return fmt.Errorf("feishu edit: empty message id")
|
||||
}
|
||||
content, err := buildMarkdownCard(msg.Text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := a.sdkClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return withTransientRetry(ctx, a.logger, "patch message", func(ctx context.Context) error {
|
||||
req := larkim.NewPatchMessageReqBuilder().
|
||||
MessageId(messageID).
|
||||
Body(larkim.NewPatchMessageReqBodyBuilder().Content(content).Build()).
|
||||
Build()
|
||||
resp, err := client.Im.Message.Patch(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("feishu patch error: empty response")
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu patch error: %s", feishuCodeError(resp.Code, resp.Msg))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
maxOutboundMediaBytes = 25 * 1024 * 1024
|
||||
maxOutboundMediaTotalBytes = maxOutboundMediaBytes
|
||||
)
|
||||
|
||||
type outboundMedia struct {
|
||||
name string
|
||||
data []byte
|
||||
}
|
||||
|
||||
// loadOutboundMedia validates and reads every requested item before any remote
|
||||
// message is sent. This keeps local policy failures from producing a successful
|
||||
// text message followed by a retryable /send error.
|
||||
func (a *adapter) loadOutboundMedia(refs []string) ([]outboundMedia, error) {
|
||||
media := make([]outboundMedia, 0, len(refs))
|
||||
totalBytes := 0
|
||||
for _, ref := range refs {
|
||||
data, name, err := a.readOutboundFile(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxOutboundMediaTotalBytes-totalBytes {
|
||||
return nil, fmt.Errorf("feishu outbound media: total payload must not exceed 25 MB")
|
||||
}
|
||||
totalBytes += len(data)
|
||||
media = append(media, outboundMedia{name: name, data: data})
|
||||
}
|
||||
return media, nil
|
||||
}
|
||||
|
||||
func (a *adapter) sendMedia(ctx context.Context, msg bot.OutboundMessage, media []outboundMedia) (bot.SendResult, error) {
|
||||
var result bot.SendResult
|
||||
for _, item := range media {
|
||||
res, err := a.sendOneMedia(ctx, msg, item)
|
||||
if err != nil {
|
||||
a.logger.Warn("feishu media send failed", "err", err)
|
||||
return result, err
|
||||
}
|
||||
result.Merge(res)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *adapter) sendOneMedia(ctx context.Context, msg bot.OutboundMessage, media outboundMedia) (bot.SendResult, error) {
|
||||
mimeType := http.DetectContentType(media.data[:min(len(media.data), 512)])
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
imageKey, err := a.uploadImage(ctx, media.data)
|
||||
if err == nil {
|
||||
content, _ := json.Marshal(map[string]string{"image_key": imageKey})
|
||||
return a.sendSDKContent(ctx, msg, larkim.MsgTypeImage, string(content))
|
||||
}
|
||||
a.logger.Warn("feishu image upload failed; falling back to file", "err", err)
|
||||
}
|
||||
fileKey, err := a.uploadFile(ctx, media.name, media.data)
|
||||
if err != nil {
|
||||
return bot.SendResult{}, err
|
||||
}
|
||||
content, _ := json.Marshal(map[string]string{"file_key": fileKey})
|
||||
return a.sendSDKContent(ctx, msg, larkim.MsgTypeFile, string(content))
|
||||
}
|
||||
|
||||
// readOutboundFile reads a bare filename from exactly one configured root.
|
||||
// os.Root pins each root directory and prevents symlink traversal outside it;
|
||||
// the selected file is then sized and read through the same open handle.
|
||||
func (a *adapter) readOutboundFile(ref string) ([]byte, string, error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: empty ref")
|
||||
}
|
||||
if len(a.cfg.OutboundMediaRoots) == 0 {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: local file sending is disabled (set outbound_media_roots)")
|
||||
}
|
||||
name := filepath.Base(ref)
|
||||
if name != ref || name == "." || name == ".." || name == string(filepath.Separator) {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: ref must be a bare file name")
|
||||
}
|
||||
|
||||
var selected *os.File
|
||||
var selectedSize int64
|
||||
for i, root := range a.cfg.OutboundMediaRoots {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsAbs(root) {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: configured root %d must be absolute", i+1)
|
||||
}
|
||||
rootHandle, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, "", fmt.Errorf("feishu outbound media: configured root %d is unavailable: %w", i+1, err)
|
||||
}
|
||||
file, openErr := rootHandle.Open(name)
|
||||
closeErr := rootHandle.Close()
|
||||
if openErr != nil {
|
||||
if os.IsNotExist(openErr) {
|
||||
continue
|
||||
}
|
||||
return nil, "", fmt.Errorf("feishu outbound media: cannot open %q in configured root %d: %w", name, i+1, openErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, "", fmt.Errorf("feishu outbound media: close configured root %d: %w", i+1, closeErr)
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, "", fmt.Errorf("feishu outbound media: stat %q: %w", name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
_ = file.Close()
|
||||
continue
|
||||
}
|
||||
if selected != nil {
|
||||
_ = file.Close()
|
||||
return nil, "", fmt.Errorf("feishu outbound media: %q exists in more than one configured root", name)
|
||||
}
|
||||
selected = file
|
||||
selectedSize = info.Size()
|
||||
defer selected.Close()
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: %q not found in any configured root", name)
|
||||
}
|
||||
if selectedSize == 0 || selectedSize > maxOutboundMediaBytes {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: %q must be between 1 byte and 25 MB", name)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(selected, maxOutboundMediaBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: read %q: %w", name, err)
|
||||
}
|
||||
if len(raw) == 0 || len(raw) > maxOutboundMediaBytes {
|
||||
return nil, "", fmt.Errorf("feishu outbound media: %q must be between 1 byte and 25 MB", name)
|
||||
}
|
||||
return raw, name, nil
|
||||
}
|
||||
|
||||
func (a *adapter) uploadImage(ctx context.Context, data []byte) (string, error) {
|
||||
client, err := a.sdkClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var key string
|
||||
err = withTransientRetry(ctx, a.logger, "upload image", func(ctx context.Context) error {
|
||||
req := larkim.NewCreateImageReqBuilder().
|
||||
Body(larkim.NewCreateImageReqBodyBuilder().
|
||||
ImageType(larkim.CreateImageImageTypeMessage).
|
||||
Image(bytes.NewReader(data)).
|
||||
Build()).
|
||||
Build()
|
||||
resp, err := client.Im.Image.Create(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("feishu image upload error: empty response")
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu image upload error: %s", feishuCodeError(resp.Code, resp.Msg))
|
||||
}
|
||||
if resp.Data == nil || resp.Data.ImageKey == nil {
|
||||
return fmt.Errorf("feishu image upload error: missing image key")
|
||||
}
|
||||
key = *resp.Data.ImageKey
|
||||
return nil
|
||||
})
|
||||
return key, err
|
||||
}
|
||||
|
||||
func (a *adapter) uploadFile(ctx context.Context, name string, data []byte) (string, error) {
|
||||
client, err := a.sdkClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = "media.bin"
|
||||
}
|
||||
var key string
|
||||
err = withTransientRetry(ctx, a.logger, "upload file", func(ctx context.Context) error {
|
||||
req := larkim.NewCreateFileReqBuilder().
|
||||
Body(larkim.NewCreateFileReqBodyBuilder().
|
||||
FileType(feishuFileType(name)).
|
||||
FileName(name).
|
||||
File(bytes.NewReader(data)).
|
||||
Build()).
|
||||
Build()
|
||||
resp, err := client.Im.File.Create(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("feishu file upload error: empty response")
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu file upload error: %s", feishuCodeError(resp.Code, resp.Msg))
|
||||
}
|
||||
if resp.Data == nil || resp.Data.FileKey == nil {
|
||||
return fmt.Errorf("feishu file upload error: missing file key")
|
||||
}
|
||||
key = *resp.Data.FileKey
|
||||
return nil
|
||||
})
|
||||
return key, err
|
||||
}
|
||||
|
||||
func feishuFileType(name string) string {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".pdf":
|
||||
return "pdf"
|
||||
case ".doc", ".docx":
|
||||
return "doc"
|
||||
case ".xls", ".xlsx":
|
||||
return "xls"
|
||||
case ".ppt", ".pptx":
|
||||
return "ppt"
|
||||
case ".mp4":
|
||||
return "mp4"
|
||||
case ".opus":
|
||||
return "opus"
|
||||
default:
|
||||
return "stream"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func TestReadOutboundFileConfinement(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "ok.txt"), []byte("hello"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(t.TempDir(), "secret.txt")
|
||||
if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{root}}}
|
||||
|
||||
data, name, err := a.readOutboundFile("ok.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("file inside root should be readable: %v", err)
|
||||
}
|
||||
if string(data) != "hello" || name != "ok.txt" {
|
||||
t.Fatalf("got %q/%q, want hello/ok.txt", data, name)
|
||||
}
|
||||
|
||||
// Paths are never aliases for a staged filename: callers must send a bare
|
||||
// filename, even when an absolute path happens to point inside a root.
|
||||
if _, _, err := a.readOutboundFile(filepath.Join(root, "ok.txt")); err == nil {
|
||||
t.Fatal("absolute paths must be rejected")
|
||||
}
|
||||
|
||||
// Outside every root: rejected before any lookup.
|
||||
if _, _, err := a.readOutboundFile(outside); err == nil {
|
||||
t.Fatal("file outside the roots must be rejected")
|
||||
}
|
||||
|
||||
// Traversal that resolves outside the root: rejected.
|
||||
if _, _, err := a.readOutboundFile(filepath.Join(root, "..", filepath.Base(outside))); err == nil {
|
||||
t.Fatal("traversal out of the root must be rejected")
|
||||
}
|
||||
|
||||
// Relative path: rejected.
|
||||
if _, _, err := a.readOutboundFile("relative/path"); err == nil {
|
||||
t.Fatal("relative path must be rejected")
|
||||
}
|
||||
|
||||
// No roots configured: local sending disabled.
|
||||
off := &adapter{cfg: config.FeishuBotConfig{}}
|
||||
if _, _, err := off.readOutboundFile("ok.txt"); err == nil {
|
||||
t.Fatal("local file sending must be disabled when no roots are set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOutboundFileRejectsAmbiguousName(t *testing.T) {
|
||||
first := t.TempDir()
|
||||
second := t.TempDir()
|
||||
for _, root := range []string{first, second} {
|
||||
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte(root), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{first, second}}}
|
||||
if _, _, err := a.readOutboundFile("report.pdf"); err == nil {
|
||||
t.Fatal("the same filename in multiple roots must be rejected as ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOutboundFileRequiresAbsoluteRoots(t *testing.T) {
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{"relative-root"}}}
|
||||
if _, _, err := a.readOutboundFile("report.pdf"); err == nil {
|
||||
t.Fatal("relative outbound media roots must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOutboundFileEnforcesActualReadLimit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "large.bin")
|
||||
if err := os.WriteFile(path, []byte{1}, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Truncate(path, maxOutboundMediaBytes+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{root}}}
|
||||
if _, _, err := a.readOutboundFile("large.bin"); err == nil {
|
||||
t.Fatal("files larger than the actual read limit must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOutboundMediaEnforcesAggregateLimit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"first.bin", "second.bin"} {
|
||||
path := filepath.Join(root, name)
|
||||
if err := os.WriteFile(path, []byte{1}, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Truncate(path, maxOutboundMediaBytes/2+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{root}}}
|
||||
if _, err := a.loadOutboundMedia([]string{"first.bin", "second.bin"}); err == nil {
|
||||
t.Fatal("aggregate outbound media larger than 25 MB must be rejected before sending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOutboundFileRejectsSymlinkEscape(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation is unreliable on Windows CI")
|
||||
}
|
||||
root := t.TempDir()
|
||||
secret := filepath.Join(t.TempDir(), "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("top secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A symlink living inside the allowed root but pointing outside it.
|
||||
link := filepath.Join(root, "escape.txt")
|
||||
if err := os.Symlink(secret, link); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{root}}}
|
||||
if _, _, err := a.readOutboundFile("escape.txt"); err == nil {
|
||||
t.Fatal("a symlink escaping the root must be rejected (symlink resolution)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOutboundFileAcceptsSymlinkWithinRoot(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation is unreliable on Windows CI")
|
||||
}
|
||||
root := t.TempDir()
|
||||
real := filepath.Join(root, "real.txt")
|
||||
if err := os.WriteFile(real, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "link.txt")
|
||||
if err := os.Symlink(filepath.Base(real), link); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
a := &adapter{cfg: config.FeishuBotConfig{OutboundMediaRoots: []string{root}}}
|
||||
if _, _, err := a.readOutboundFile("link.txt"); err != nil {
|
||||
t.Fatalf("a symlink staying within the root should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
mrand "math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
)
|
||||
|
||||
// newIdempotencyKey returns a random key for the Feishu create/reply `uuid`
|
||||
// dedup field. It is generated once per logical send and reused across
|
||||
// transient retries, so a retry after the request already reached the server
|
||||
// (response read failed) does not post a duplicate visible message. An empty
|
||||
// return (rand failure) simply omits the key — retries then behave as before.
|
||||
func newIdempotencyKey() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
const (
|
||||
transientRetryAttempts = 3
|
||||
transientRetryBaseDelay = 500 * time.Millisecond
|
||||
transientRetryMaxDelay = 5 * time.Second
|
||||
)
|
||||
|
||||
// withTransientRetry retries fn on transport-level failures (connection reset,
|
||||
// timeout, broken pipe) with exponential backoff and jitter. Feishu API-level
|
||||
// errors (rate limit, size limit, permission) are returned as-is — retrying
|
||||
// those blindly would only make things worse.
|
||||
func withTransientRetry(ctx context.Context, logger *slog.Logger, op string, fn func(context.Context) error) error {
|
||||
delay := transientRetryBaseDelay
|
||||
for attempt := 1; ; attempt++ {
|
||||
err := fn(ctx)
|
||||
if err == nil || attempt >= transientRetryAttempts || ctx.Err() != nil || !isTransientError(err) {
|
||||
return err
|
||||
}
|
||||
wait := delay + time.Duration(mrand.Int63n(int64(delay/4)+1))
|
||||
logger.Warn("feishu transient error; retrying", "op", op, "attempt", attempt, "wait", wait, "err", err)
|
||||
if !bot.SleepCtx(ctx, wait) {
|
||||
return err
|
||||
}
|
||||
delay *= 2
|
||||
if delay > transientRetryMaxDelay {
|
||||
delay = transientRetryMaxDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isTransientError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return true
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
for _, marker := range []string{
|
||||
"connection reset", "broken pipe", "i/o timeout",
|
||||
"tls handshake timeout", "connection refused", "unexpected eof",
|
||||
} {
|
||||
if strings.Contains(msg, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user