@@ -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.
143144func (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".
216259type 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 ---
0 commit comments