chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:00:08 +08:00
commit 6345505628
1516 changed files with 473769 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+561
View File
@@ -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")
}
}
+233
View File
@@ -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
}
+45
View File
@@ -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
})
}
+244
View File
@@ -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"
}
}
+150
View File
@@ -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)
}
}
+82
View File
@@ -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
}