Skip to content

Commit 9970e5a

Browse files
authored
Merge pull request #61 from leisair/fix/feishu-preserve-mentions
fix(feishu): preserve mentions for group routing
2 parents ffe4b15 + c90fc25 commit 9970e5a

3 files changed

Lines changed: 211 additions & 28 deletions

File tree

internal/channels/feishu.go

Lines changed: 100 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,11 @@ type Feishu struct {
6868

6969
httpClient *http.Client
7070

71-
mu sync.Mutex
72-
accessTok string
71+
mu sync.Mutex
72+
accessTok string
7373
accessTokExp time.Time
74-
botName string // populated on Start via /bot/v3/info; best-effort
75-
botOpenID string
74+
botName string // populated on Start via /bot/v3/info; best-effort
75+
botOpenID string
7676
}
7777

7878
// NewFeishu creates a Feishu adapter. verificationToken matches the value
@@ -135,19 +135,17 @@ func (l *Feishu) Send(chatID, text string) error {
135135
return l.SendMessage(bus.OutboundMessage{ChatID: chatID, Text: text})
136136
}
137137

138-
// SendMessage delivers Text + (optionally) MediaItems. Feishu's text
139-
// shape is `{"text":"..."}` JSON-stringified inside the `content`
140-
// field. MediaItems are deferred — sending images requires uploading
141-
// to Feishu's CDN first via /im/v1/images, which is a separate dance
142-
// we don't need until users complain.
138+
// SendMessage delivers Text + (optionally) MediaItems. Text is always sent
139+
// as a JSON 2.0 interactive card so Feishu can render standard Markdown
140+
// directly. Plain text is kept only as an error fallback when the card API
141+
// fails. MediaItems are deferred — sending images requires uploading to
142+
// Feishu's CDN first via /im/v1/images, which is a separate dance we don't
143+
// need until users complain.
143144
func (l *Feishu) SendMessage(msg bus.OutboundMessage) error {
144145
if msg.Text == "" && len(msg.MediaItems) == 0 {
145146
return nil
146147
}
147148
if msg.Text == "" {
148-
// MediaItems-only without an upload path — skip rather than
149-
// posting an empty bubble. Logged so it's debuggable if it
150-
// ever happens in practice.
151149
slog.Debug("feishu send: media-only message dropped (image upload not implemented)",
152150
"account", l.accountID, "chat", msg.ChatID)
153151
return nil
@@ -156,19 +154,64 @@ func (l *Feishu) SendMessage(msg bus.OutboundMessage) error {
156154
if err != nil {
157155
return fmt.Errorf("feishu token: %w", err)
158156
}
159-
// Feishu's `msg_type:"text"` path renders no markdown — GFM tables
160-
// would arrive as literal `|cell|cell|` rows. Collapse them to
161-
// label:value or middle-dot lines first.
162-
text := FlattenMarkdownTables(msg.Text)
157+
if err := l.sendMarkdownCard(tok, msg.ChatID, msg.Text); err != nil {
158+
slog.Warn("feishu card send failed, falling back to plain text",
159+
"account", l.accountID, "chat", msg.ChatID, "error", err)
160+
return l.sendPlainTextMessage(tok, msg.ChatID, msg.Text)
161+
}
162+
return nil
163+
}
164+
165+
// sendMarkdownCard sends a JSON 2.0 interactive card with one markdown body.
166+
func (l *Feishu) sendMarkdownCard(tok, chatID, text string) error {
167+
cardJSON, err := buildFeishuMarkdownCardJSON(text)
168+
if err != nil {
169+
return err
170+
}
171+
payload := map[string]string{
172+
"receive_id": chatID,
173+
"content": string(cardJSON),
174+
"msg_type": "interactive",
175+
}
176+
return l.doSend(tok, payload)
177+
}
178+
179+
func buildFeishuMarkdownCardJSON(text string) ([]byte, error) {
180+
card := map[string]any{
181+
"schema": "2.0",
182+
"body": map[string]any{
183+
"elements": []map[string]any{
184+
{
185+
"tag": "markdown",
186+
"content": text,
187+
},
188+
},
189+
},
190+
}
191+
cardJSON, err := json.Marshal(card)
192+
if err != nil {
193+
return nil, fmt.Errorf("feishu marshal card: %w", err)
194+
}
195+
return cardJSON, nil
196+
}
197+
198+
// sendPlainTextMessage sends a plain text message. Used only as a fallback
199+
// if the card API fails.
200+
func (l *Feishu) sendPlainTextMessage(tok, chatID, text string) error {
163201
contentJSON, err := json.Marshal(map[string]string{"text": text})
164202
if err != nil {
165203
return fmt.Errorf("feishu marshal content: %w", err)
166204
}
167205
payload := map[string]string{
168-
"receive_id": msg.ChatID,
206+
"receive_id": chatID,
169207
"content": string(contentJSON),
170208
"msg_type": "text",
171209
}
210+
return l.doSend(tok, payload)
211+
}
212+
213+
// doSend posts a message payload to Feishu's send API.
214+
func (l *Feishu) doSend(tok string, payload map[string]string) error {
172215
body, err := json.Marshal(payload)
173216
if err != nil {
174217
return fmt.Errorf("feishu marshal: %w", err)
@@ -214,9 +257,9 @@ func (l *Feishu) SendTyping(_ string) error { return nil }
214257
// FeishuEventEnvelope is the v2 schema Feishu uses for event subscriptions.
215258
// We match on header.event_type == "im.message.receive_v1".
216259
type FeishuEventEnvelope struct {
217-
Schema string `json:"schema"`
260+
Schema string `json:"schema"`
218261
Header FeishuEventHeader `json:"header"`
219-
Event json.RawMessage `json:"event"`
262+
Event json.RawMessage `json:"event"`
220263

221264
// v1 url_verification challenge fields (also surfaced here for the
222265
// initial subscribe-time handshake; Feishu's v2 events use
@@ -246,14 +289,18 @@ type feishuMessageEvent struct {
246289
SenderType string `json:"sender_type"`
247290
} `json:"sender"`
248291
Message struct {
249-
MessageID string `json:"message_id"`
250-
RootID string `json:"root_id,omitempty"`
251-
ParentID string `json:"parent_id,omitempty"`
252-
CreateTime string `json:"create_time"`
253-
ChatID string `json:"chat_id"`
254-
ChatType string `json:"chat_type"` // "p2p" | "group"
255-
MessageType string `json:"message_type"`
256-
Content string `json:"content"`
292+
MessageID string `json:"message_id"`
293+
RootID string `json:"root_id,omitempty"`
294+
ParentID string `json:"parent_id,omitempty"`
295+
CreateTime string `json:"create_time"`
296+
ChatID string `json:"chat_id"`
297+
ChatType string `json:"chat_type"` // "p2p" | "group"
298+
MessageType string `json:"message_type"`
299+
Content string `json:"content"`
300+
Mentions []struct {
301+
Key string `json:"key,omitempty"`
302+
Name string `json:"name,omitempty"`
303+
} `json:"mentions,omitempty"`
257304
} `json:"message"`
258305
}
259306

@@ -403,7 +450,8 @@ func (l *Feishu) dispatchInbound(ev feishuMessageEvent) {
403450
"account", l.accountID,
404451
"from", ev.Sender.SenderID.OpenID,
405452
"chat", ev.Message.ChatID,
406-
"len", len(content.Text))
453+
"len", len(content.Text),
454+
"mentions", feishuMentionNames(ev))
407455

408456
l.bus.Inbound <- bus.InboundMessage{
409457
Channel: "feishu",
@@ -413,7 +461,31 @@ func (l *Feishu) dispatchInbound(ev feishuMessageEvent) {
413461
MessageID: msgID,
414462
Text: content.Text,
415463
PeerKind: peerKind,
464+
Mentions: feishuMentionNames(ev),
465+
}
466+
}
467+
468+
func feishuMentionNames(ev feishuMessageEvent) []string {
469+
if len(ev.Message.Mentions) == 0 {
470+
return nil
471+
}
472+
mentions := make([]string, 0, len(ev.Message.Mentions))
473+
seen := make(map[string]struct{}, len(ev.Message.Mentions))
474+
for _, m := range ev.Message.Mentions {
475+
name := m.Name
476+
if name == "" {
477+
name = m.Key
478+
}
479+
if name == "" {
480+
continue
481+
}
482+
if _, ok := seen[name]; ok {
483+
continue
484+
}
485+
seen[name] = struct{}{}
486+
mentions = append(mentions, name)
416487
}
488+
return mentions
417489
}
418490

419491
// --- HTTP plumbing ---

internal/channels/feishu_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package channels
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
"time"
7+
8+
"github.com/fastclaw-ai/fastclaw/internal/bus"
9+
)
10+
11+
func TestFeishuWebhookPreservesMentions(t *testing.T) {
12+
mb := bus.New()
13+
ch, err := NewFeishu("cli_test", "secret", "verify-token", "", false, "cli_test", mb)
14+
if err != nil {
15+
t.Fatalf("NewFeishu: %v", err)
16+
}
17+
18+
event := map[string]any{
19+
"schema": "2.0",
20+
"header": map[string]any{
21+
"event_id": "ev_1",
22+
"event_type": "im.message.receive_v1",
23+
"token": "verify-token",
24+
"app_id": "cli_test",
25+
},
26+
"event": map[string]any{
27+
"sender": map[string]any{
28+
"sender_id": map[string]any{"open_id": "ou_sender"},
29+
"sender_type": "user",
30+
},
31+
"message": map[string]any{
32+
"message_id": "om_1",
33+
"chat_id": "oc_group",
34+
"chat_type": "group",
35+
"message_type": "text",
36+
"content": `{"text":"@机器人 你好"}`,
37+
"mentions": []map[string]any{
38+
{"key": "@_user_1", "name": "机器人"},
39+
},
40+
},
41+
},
42+
}
43+
body, err := json.Marshal(event)
44+
if err != nil {
45+
t.Fatalf("marshal event: %v", err)
46+
}
47+
48+
if _, status, err := ch.HandleWebhook(body); err != nil || status != 200 {
49+
t.Fatalf("HandleWebhook status=%d err=%v", status, err)
50+
}
51+
52+
select {
53+
case got := <-mb.Inbound:
54+
if len(got.Mentions) != 1 || got.Mentions[0] != "机器人" {
55+
t.Fatalf("mentions = %#v, want [机器人]", got.Mentions)
56+
}
57+
case <-time.After(time.Second):
58+
t.Fatal("timed out waiting for inbound message")
59+
}
60+
}
61+
62+
func TestBuildFeishuMarkdownCardJSON(t *testing.T) {
63+
in := "**hello**\n\n| A | B |\n|---|---|\n| 1 | 2 |"
64+
got, err := buildFeishuMarkdownCardJSON(in)
65+
if err != nil {
66+
t.Fatalf("buildFeishuMarkdownCardJSON: %v", err)
67+
}
68+
69+
var card map[string]any
70+
if err := json.Unmarshal(got, &card); err != nil {
71+
t.Fatalf("unmarshal card json: %v", err)
72+
}
73+
if card["schema"] != "2.0" {
74+
t.Fatalf("schema = %v, want 2.0", card["schema"])
75+
}
76+
77+
body, ok := card["body"].(map[string]any)
78+
if !ok {
79+
t.Fatalf("body = %#v, want object", card["body"])
80+
}
81+
elements, ok := body["elements"].([]any)
82+
if !ok {
83+
t.Fatalf("body.elements = %#v, want array", body["elements"])
84+
}
85+
if len(elements) != 1 {
86+
t.Fatalf("len(body.elements) = %d, want 1", len(elements))
87+
}
88+
89+
element, ok := elements[0].(map[string]any)
90+
if !ok {
91+
t.Fatalf("element = %#v, want object", elements[0])
92+
}
93+
if element["tag"] != "markdown" {
94+
t.Fatalf("element.tag = %v, want markdown", element["tag"])
95+
}
96+
if element["content"] != in {
97+
t.Fatalf("element.content = %q, want %q", element["content"], in)
98+
}
99+
}

internal/channels/feishu_ws.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ func sdkEventToInternal(ev *larkim.P2MessageReceiveV1) feishuMessageEvent {
6565
out.Message.ChatType = derefStr(m.ChatType)
6666
out.Message.MessageType = derefStr(m.MessageType)
6767
out.Message.Content = derefStr(m.Content)
68+
for _, mention := range m.Mentions {
69+
if mention == nil {
70+
continue
71+
}
72+
out.Message.Mentions = append(out.Message.Mentions, struct {
73+
Key string `json:"key,omitempty"`
74+
Name string `json:"name,omitempty"`
75+
}{
76+
Key: derefStr(mention.Key),
77+
Name: derefStr(mention.Name),
78+
})
79+
}
6880
}
6981
return out
7082
}

0 commit comments

Comments
 (0)