From 3eb43a59ab0ddc1a3b8896f28bb7e89a922d783d Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 2 Mar 2026 09:00:56 +0800 Subject: [PATCH] Enhance message handling for integrations with improved formatting and localization - Add locale support for DingTalk and Discord integrations to ensure messages are sent in the correct language. - Update message formatting logic in DingTalk and Discord to utilize dedicated formatting functions for Markdown. - Implement image and file handling improvements in Feishu integration, allowing for better attachment processing and error handling. - Refactor message sending methods to streamline the process and enhance code clarity across integrations. --- .../events/integrations/dingtalk/message.go | 1 + .../events/integrations/dingtalk/reply.go | 44 +++- .../events/integrations/discord/message.go | 8 + .../events/integrations/discord/reply.go | 24 ++- .../robot/events/integrations/feishu/reply.go | 103 +++++++--- .../events/integrations/feishu/stream.go | 13 +- integrations/dingtalk/format.go | 170 +++++++++++++++ integrations/discord/convert.go | 2 + integrations/discord/format.go | 151 ++++++++++++++ integrations/feishu/format.go | 193 ++++++++++++++++++ integrations/feishu/message.go | 193 ++++++++++++++++++ 11 files changed, 861 insertions(+), 41 deletions(-) create mode 100644 integrations/dingtalk/format.go create mode 100644 integrations/discord/format.go create mode 100644 integrations/feishu/format.go diff --git a/agent/robot/events/integrations/dingtalk/message.go b/agent/robot/events/integrations/dingtalk/message.go index b0d84e80..6239ecb4 100644 --- a/agent/robot/events/integrations/dingtalk/message.go +++ b/agent/robot/events/integrations/dingtalk/message.go @@ -57,6 +57,7 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*dt ChatID: lastCM.ConversationID, SenderID: lastCM.SenderID, SenderName: lastCM.SenderNick, + Locale: "zh-cn", Extra: map[string]any{ "session_webhook": lastCM.SessionWebhook, "conversation_type": lastCM.ConversationType, diff --git a/agent/robot/events/integrations/dingtalk/reply.go b/agent/robot/events/integrations/dingtalk/reply.go index 01491a67..27d53201 100644 --- a/agent/robot/events/integrations/dingtalk/reply.go +++ b/agent/robot/events/integrations/dingtalk/reply.go @@ -38,7 +38,7 @@ func sendContent(ctx context.Context, sessionWebhook string, content interface{} if strings.TrimSpace(c) == "" { return nil } - return dtapi.SendMarkdownMessage(ctx, sessionWebhook, "Reply", c) + return dtapi.SendMarkdownMessage(ctx, sessionWebhook, "Reply", dtapi.FormatDingTalkMarkdown(c)) case []interface{}: return sendParts(ctx, sessionWebhook, c) @@ -69,10 +69,34 @@ func sendParts(ctx context.Context, sessionWebhook string, parts []interface{}) if err := flushText(ctx, sessionWebhook, &textBuf); err != nil { return err } + if imgMap, ok := m["image_url"].(map[string]interface{}); ok { + if url, ok := imgMap["url"].(string); ok { + if strings.HasPrefix(url, "http") { + textBuf.WriteString(fmt.Sprintf("\n![image](%s)\n", url)) + } + } + } case "file": if err := flushText(ctx, sessionWebhook, &textBuf); err != nil { return err } + fileURL, _ := m["file_url"].(string) + fileName, _ := m["file_name"].(string) + if fileURL == "" { + if fileMap, ok := m["file"].(map[string]interface{}); ok { + fileURL, _ = fileMap["url"].(string) + if fn, ok := fileMap["filename"].(string); ok && fn != "" { + fileName = fn + } + } + } + if fileURL != "" && strings.HasPrefix(fileURL, "http") { + label := fileName + if label == "" { + label = "file" + } + textBuf.WriteString(fmt.Sprintf("\n[%s](%s)\n", label, fileURL)) + } } } return flushText(ctx, sessionWebhook, &textBuf) @@ -84,10 +108,24 @@ func sendPartsTyped(ctx context.Context, sessionWebhook string, parts []agentcon switch part.Type { case agentcontext.ContentText: textBuf.WriteString(part.Text) - case agentcontext.ContentImageURL, agentcontext.ContentFile: + case agentcontext.ContentImageURL: if err := flushText(ctx, sessionWebhook, &textBuf); err != nil { return err } + if part.ImageURL != nil && strings.HasPrefix(part.ImageURL.URL, "http") { + textBuf.WriteString(fmt.Sprintf("\n![image](%s)\n", part.ImageURL.URL)) + } + case agentcontext.ContentFile: + if err := flushText(ctx, sessionWebhook, &textBuf); err != nil { + return err + } + if part.File != nil && part.File.URL != "" && strings.HasPrefix(part.File.URL, "http") { + label := part.File.Filename + if label == "" { + label = "file" + } + textBuf.WriteString(fmt.Sprintf("\n[%s](%s)\n", label, part.File.URL)) + } } } return flushText(ctx, sessionWebhook, &textBuf) @@ -99,7 +137,7 @@ func flushText(ctx context.Context, sessionWebhook string, buf *strings.Builder) } text := buf.String() buf.Reset() - return dtapi.SendMarkdownMessage(ctx, sessionWebhook, "Reply", text) + return dtapi.SendMarkdownMessage(ctx, sessionWebhook, "Reply", dtapi.FormatDingTalkMarkdown(text)) } func toContentParts(content interface{}) ([]agentcontext.ContentPart, bool) { diff --git a/agent/robot/events/integrations/discord/message.go b/agent/robot/events/integrations/discord/message.go index 4218d541..8b83b4fd 100644 --- a/agent/robot/events/integrations/discord/message.go +++ b/agent/robot/events/integrations/discord/message.go @@ -62,6 +62,7 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*dc ChatID: lastCM.ChannelID, SenderID: lastCM.AuthorID, SenderName: lastCM.AuthorName, + Locale: events.NormalizeLocale(discordLocale(lastCM.Locale)), Extra: map[string]any{ "discord_message_id": lastCM.MessageID, "guild_id": lastCM.GuildID, @@ -128,3 +129,10 @@ func mergeContentParts(parts []interface{}) interface{} { return parts } + +func discordLocale(locale string) string { + if locale == "" { + return "en" + } + return locale +} diff --git a/agent/robot/events/integrations/discord/reply.go b/agent/robot/events/integrations/discord/reply.go index 048e2470..9ae4fadf 100644 --- a/agent/robot/events/integrations/discord/reply.go +++ b/agent/robot/events/integrations/discord/reply.go @@ -7,6 +7,7 @@ import ( agentcontext "github.com/yaoapp/yao/agent/context" events "github.com/yaoapp/yao/agent/robot/events" + dcapi "github.com/yaoapp/yao/integrations/discord" ) // Reply sends the assistant message back to the originating Discord channel. @@ -38,11 +39,12 @@ func (a *Adapter) sendContent(ctx context.Context, entry *botEntry, channelID, r if strings.TrimSpace(c) == "" { return nil } + formatted := dcapi.FormatDiscordMarkdown(c) if replyToID != "" { - _, err := entry.bot.SendMessageReply(channelID, c, replyToID) + _, err := entry.bot.SendMessageReply(channelID, formatted, replyToID) return err } - _, err := entry.bot.SendMessage(channelID, c) + _, err := entry.bot.SendMessage(channelID, formatted) return err case []interface{}: @@ -108,10 +110,24 @@ func (a *Adapter) sendPartsTyped(ctx context.Context, entry *botEntry, channelID switch part.Type { case agentcontext.ContentText: textBuf.WriteString(part.Text) - case agentcontext.ContentImageURL, agentcontext.ContentFile: + case agentcontext.ContentImageURL: if err := a.flushText(entry, channelID, replyToID, &textBuf); err != nil { return err } + if part.ImageURL != nil { + if err := sendFileOrWrapper(entry, channelID, part.ImageURL.URL, ""); err != nil { + log.Error("discord reply: send image: %v", err) + } + } + case agentcontext.ContentFile: + if err := a.flushText(entry, channelID, replyToID, &textBuf); err != nil { + return err + } + if part.File != nil { + if err := sendFileOrWrapper(entry, channelID, part.File.URL, part.File.Filename); err != nil { + log.Error("discord reply: send file: %v", err) + } + } } } return a.flushText(entry, channelID, replyToID, &textBuf) @@ -121,7 +137,7 @@ func (a *Adapter) flushText(entry *botEntry, channelID, replyToID string, buf *s if buf.Len() == 0 { return nil } - text := buf.String() + text := dcapi.FormatDiscordMarkdown(buf.String()) buf.Reset() if replyToID != "" { diff --git a/agent/robot/events/integrations/feishu/reply.go b/agent/robot/events/integrations/feishu/reply.go index 111d2848..871cb4e9 100644 --- a/agent/robot/events/integrations/feishu/reply.go +++ b/agent/robot/events/integrations/feishu/reply.go @@ -7,7 +7,7 @@ import ( agentcontext "github.com/yaoapp/yao/agent/context" events "github.com/yaoapp/yao/agent/robot/events" - "github.com/yaoapp/yao/attachment" + fsapi "github.com/yaoapp/yao/integrations/feishu" ) // Reply sends the assistant message back to the originating Feishu chat. @@ -39,12 +39,7 @@ func (a *Adapter) sendContent(ctx context.Context, entry *botEntry, chatID, repl if strings.TrimSpace(c) == "" { return nil } - if replyToMsgID != "" { - _, err := entry.bot.ReplyTextMessage(ctx, replyToMsgID, c) - return err - } - _, err := entry.bot.SendTextMessage(ctx, chatID, c) - return err + return a.sendMarkdown(ctx, entry, chatID, replyToMsgID, c) case []interface{}: return a.sendParts(ctx, entry, chatID, replyToMsgID, c) @@ -54,10 +49,19 @@ func (a *Adapter) sendContent(ctx context.Context, entry *botEntry, chatID, repl if ok { return a.sendPartsTyped(ctx, entry, chatID, replyToMsgID, parts) } - text := fmt.Sprintf("%v", content) - _, err := entry.bot.SendTextMessage(ctx, chatID, text) + return a.sendMarkdown(ctx, entry, chatID, replyToMsgID, fmt.Sprintf("%v", content)) + } +} + +// sendMarkdown converts standard Markdown to Feishu lark_md and sends as an interactive card. +func (a *Adapter) sendMarkdown(ctx context.Context, entry *botEntry, chatID, replyToMsgID, text string) error { + formatted := fsapi.FormatFeishuMarkdown(text) + if replyToMsgID != "" { + _, err := entry.bot.ReplyCardMessage(ctx, replyToMsgID, formatted) return err } + _, err := entry.bot.SendCardMessage(ctx, chatID, formatted) + return err } func (a *Adapter) sendParts(ctx context.Context, entry *botEntry, chatID, replyToMsgID string, parts []interface{}) error { @@ -77,12 +81,25 @@ func (a *Adapter) sendParts(ctx context.Context, entry *botEntry, chatID, replyT if err := a.flushText(ctx, entry, chatID, replyToMsgID, &textBuf); err != nil { return err } + if imgMap, ok := m["image_url"].(map[string]interface{}); ok { + if url, ok := imgMap["url"].(string); ok { + if err := sendImageOrWrapper(ctx, entry, chatID, url, ""); err != nil { + log.Error("feishu reply: send image: %v", err) + } + } + } case "file": if err := a.flushText(ctx, entry, chatID, replyToMsgID, &textBuf); err != nil { return err } - if fileURL, ok := m["file_url"].(string); ok && fileURL != "" { - if err := a.sendFileContent(ctx, entry, chatID, fileURL); err != nil { + fileURL, _ := m["file_url"].(string) + if fileURL == "" { + if fileMap, ok := m["file"].(map[string]interface{}); ok { + fileURL, _ = fileMap["url"].(string) + } + } + if fileURL != "" { + if err := sendFileOrWrapper(ctx, entry, chatID, fileURL, ""); err != nil { log.Error("feishu reply: send file: %v", err) } } @@ -97,10 +114,24 @@ func (a *Adapter) sendPartsTyped(ctx context.Context, entry *botEntry, chatID, r switch part.Type { case agentcontext.ContentText: textBuf.WriteString(part.Text) - case agentcontext.ContentImageURL, agentcontext.ContentFile: + case agentcontext.ContentImageURL: if err := a.flushText(ctx, entry, chatID, replyToMsgID, &textBuf); err != nil { return err } + if part.ImageURL != nil { + if err := sendImageOrWrapper(ctx, entry, chatID, part.ImageURL.URL, ""); err != nil { + log.Error("feishu reply: send image: %v", err) + } + } + case agentcontext.ContentFile: + if err := a.flushText(ctx, entry, chatID, replyToMsgID, &textBuf); err != nil { + return err + } + if part.File != nil { + if err := sendFileOrWrapper(ctx, entry, chatID, part.File.URL, part.File.Filename); err != nil { + log.Error("feishu reply: send file: %v", err) + } + } } } return a.flushText(ctx, entry, chatID, replyToMsgID, &textBuf) @@ -112,25 +143,41 @@ func (a *Adapter) flushText(ctx context.Context, entry *botEntry, chatID, replyT } text := buf.String() buf.Reset() - - if replyToMsgID != "" { - _, err := entry.bot.ReplyTextMessage(ctx, replyToMsgID, text) - return err - } - _, err := entry.bot.SendTextMessage(ctx, chatID, text) - return err + return a.sendMarkdown(ctx, entry, chatID, replyToMsgID, text) } -func (a *Adapter) sendFileContent(ctx context.Context, entry *botEntry, chatID, fileURL string) error { - if strings.Contains(fileURL, "://") && !strings.HasPrefix(fileURL, "http") { - _, fileID, ok := attachment.Parse(fileURL) - if !ok { - return fmt.Errorf("parse wrapper: invalid format %s", fileURL) - } - _, _ = fileID, chatID - log.Warn("feishu: file wrapper send not yet implemented, wrapper=%s", fileURL) +func sendImageOrWrapper(ctx context.Context, entry *botEntry, chatID, url, caption string) error { + if isWrapper(url) { + return entry.bot.SendImageFromWrapper(ctx, chatID, url, caption) } - return nil + if strings.HasPrefix(url, "http") { + text := url + if caption != "" { + text = caption + "\n" + url + } + _, err := entry.bot.SendTextMessage(ctx, chatID, text) + return err + } + return fmt.Errorf("unsupported image URL scheme: %s", url) +} + +func sendFileOrWrapper(ctx context.Context, entry *botEntry, chatID, url, caption string) error { + if isWrapper(url) { + return entry.bot.SendFileFromWrapper(ctx, chatID, url, caption) + } + if strings.HasPrefix(url, "http") { + text := url + if caption != "" { + text = caption + "\n" + url + } + _, err := entry.bot.SendTextMessage(ctx, chatID, text) + return err + } + return fmt.Errorf("unsupported file URL scheme: %s", url) +} + +func isWrapper(url string) bool { + return strings.Contains(url, "://") && !strings.HasPrefix(url, "http") } func toContentParts(content interface{}) ([]agentcontext.ContentPart, bool) { diff --git a/agent/robot/events/integrations/feishu/stream.go b/agent/robot/events/integrations/feishu/stream.go index 7c6e3553..9ae1a3eb 100644 --- a/agent/robot/events/integrations/feishu/stream.go +++ b/agent/robot/events/integrations/feishu/stream.go @@ -86,12 +86,13 @@ func (a *Adapter) onMessageReceive(ctx context.Context, entry *botEntry, event * text, media := fsapi.ParseMessageContent(msgType, content) cm := &fsapi.ConvertedMessage{ - MessageID: messageID, - ChatID: chatID, - ChatType: chatType, - Text: text, - MediaItems: media, - EventID: event.EventV2Base.Header.EventID, + MessageID: messageID, + ChatID: chatID, + ChatType: chatType, + Text: text, + MediaItems: media, + EventID: event.EventV2Base.Header.EventID, + LanguageCode: "zh", } if sender != nil && sender.SenderId != nil { diff --git a/integrations/dingtalk/format.go b/integrations/dingtalk/format.go new file mode 100644 index 00000000..4e7d2407 --- /dev/null +++ b/integrations/dingtalk/format.go @@ -0,0 +1,170 @@ +package dingtalk + +import ( + "regexp" + "strings" +) + +// FormatDingTalkMarkdown converts standard Markdown to DingTalk's Markdown subset. +// +// DingTalk webhook Markdown supports: +// - # headings (1-6) +// - **bold**, *italic* +// - > blockquote +// - - unordered list +// - [link](url) +// - ![image](url) +// - --- divider +// +// NOT supported (must be degraded): +// - ~~strikethrough~~ → plain text +// - ``` code blocks → indented text +// - `inline code` → plain text +// - tables → pre-formatted text +// - ordered lists → "N. " text (passthrough, may not render) +func FormatDingTalkMarkdown(md string) string { + md = strings.ReplaceAll(md, "\r\n", "\n") + + var out strings.Builder + lines := strings.Split(md, "\n") + + inCodeBlock := false + var codeLines []string + + inTable := false + var tableRows [][]string + + for i := 0; i < len(lines); i++ { + line := lines[i] + + if strings.HasPrefix(line, "```") { + if !inCodeBlock { + inCodeBlock = true + codeLines = nil + } else { + inCodeBlock = false + out.WriteString("\n") + for _, cl := range codeLines { + out.WriteString(" " + cl + "\n") + } + out.WriteString("\n") + } + continue + } + if inCodeBlock { + codeLines = append(codeLines, line) + continue + } + + if dtIsTableRow(line) { + if !inTable { + inTable = true + tableRows = nil + } + if dtIsTableSep(line) { + continue + } + tableRows = append(tableRows, dtParseTableRow(line)) + continue + } + if inTable { + dtFlushTable(&out, tableRows) + inTable = false + tableRows = nil + } + + line = dtReStrikethrough.ReplaceAllString(line, "$1") + line = dtReInlineCode.ReplaceAllString(line, "$1") + + out.WriteString(line + "\n") + } + + if inCodeBlock && len(codeLines) > 0 { + out.WriteString("\n") + for _, cl := range codeLines { + out.WriteString(" " + cl + "\n") + } + out.WriteString("\n") + } + if inTable { + dtFlushTable(&out, tableRows) + } + + return strings.TrimRight(out.String(), "\n") +} + +var ( + dtReStrikethrough = regexp.MustCompile(`~~(.+?)~~`) + dtReInlineCode = regexp.MustCompile("`([^`]+)`") + dtReTableRow = regexp.MustCompile(`^\|.*\|$`) + dtReTableSep = regexp.MustCompile(`^\|[\s\-:|]+\|$`) +) + +func dtIsTableRow(line string) bool { + return dtReTableRow.MatchString(strings.TrimSpace(line)) +} + +func dtIsTableSep(line string) bool { + return dtReTableSep.MatchString(strings.TrimSpace(line)) +} + +func dtParseTableRow(line string) []string { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "|") + line = strings.TrimSuffix(line, "|") + cells := strings.Split(line, "|") + for i := range cells { + cells[i] = strings.TrimSpace(cells[i]) + } + return cells +} + +func dtFlushTable(out *strings.Builder, rows [][]string) { + if len(rows) == 0 { + return + } + colWidths := make([]int, len(rows[0])) + for _, row := range rows { + for i, cell := range row { + if i < len(colWidths) && len([]rune(cell)) > colWidths[i] { + colWidths[i] = len([]rune(cell)) + } + } + } + out.WriteString("\n") + for ri, row := range rows { + for ci, cell := range row { + if ci > 0 { + out.WriteString(" | ") + } + w := 0 + if ci < len(colWidths) { + w = colWidths[ci] + } + out.WriteString(dtPadRight(cell, w)) + } + out.WriteString("\n") + if ri == 0 && len(rows) > 1 { + for ci := range row { + if ci > 0 { + out.WriteString("-+-") + } + w := 0 + if ci < len(colWidths) { + w = colWidths[ci] + } + out.WriteString(strings.Repeat("-", w)) + } + out.WriteString("\n") + } + } + out.WriteString("\n") +} + +func dtPadRight(s string, width int) string { + runes := []rune(s) + if len(runes) >= width { + return s + } + return s + strings.Repeat(" ", width-len(runes)) +} diff --git a/integrations/discord/convert.go b/integrations/discord/convert.go index b406413e..d46a2680 100644 --- a/integrations/discord/convert.go +++ b/integrations/discord/convert.go @@ -14,6 +14,7 @@ type ConvertedMessage struct { IsBot bool `json:"is_bot"` Text string `json:"text,omitempty"` MediaItems []MediaItem `json:"media,omitempty"` + Locale string `json:"locale,omitempty"` ReplyTo string `json:"reply_to,omitempty"` IsDM bool `json:"is_dm"` } @@ -71,6 +72,7 @@ func ConvertMessage(m *discordgo.Message) *ConvertedMessage { cm.AuthorID = m.Author.ID cm.AuthorName = m.Author.Username cm.IsBot = m.Author.Bot + cm.Locale = m.Author.Locale } if m.MessageReference != nil { diff --git a/integrations/discord/format.go b/integrations/discord/format.go new file mode 100644 index 00000000..2f6f3314 --- /dev/null +++ b/integrations/discord/format.go @@ -0,0 +1,151 @@ +package discord + +import ( + "regexp" + "strings" +) + +// FormatDiscordMarkdown converts standard Markdown to Discord-compatible Markdown. +// +// Discord supports most standard Markdown: +// - **bold**, *italic*, ~~strikethrough~~ +// - `inline code`, ``` code blocks ``` +// - > blockquote +// - - unordered list, 1. ordered list +// - [link](url) (auto-embeds) +// - # heading (rendered as large bold text) +// +// NOT supported (must be degraded): +// - tables → pre-formatted code block +// - ![image](url) → just the URL (Discord auto-embeds images from URLs) +// +// Discord has a 2000 character message limit; this function does not truncate. +func FormatDiscordMarkdown(md string) string { + md = strings.ReplaceAll(md, "\r\n", "\n") + + var out strings.Builder + lines := strings.Split(md, "\n") + + inCodeBlock := false + inTable := false + var tableRows [][]string + + for i := 0; i < len(lines); i++ { + line := lines[i] + + if strings.HasPrefix(line, "```") { + inCodeBlock = !inCodeBlock + out.WriteString(line + "\n") + continue + } + if inCodeBlock { + out.WriteString(line + "\n") + continue + } + + if dcIsTableRow(line) { + if !inTable { + inTable = true + tableRows = nil + } + if dcIsTableSep(line) { + continue + } + tableRows = append(tableRows, dcParseTableRow(line)) + continue + } + if inTable { + dcFlushTable(&out, tableRows) + inTable = false + tableRows = nil + } + + if m := dcReImage.FindStringSubmatch(line); m != nil { + out.WriteString(m[2] + "\n") + continue + } + + out.WriteString(line + "\n") + } + + if inTable { + dcFlushTable(&out, tableRows) + } + + return strings.TrimRight(out.String(), "\n") +} + +var ( + dcReImage = regexp.MustCompile(`^!\[([^\]]*)\]\(([^)]+)\)$`) + dcReTableRow = regexp.MustCompile(`^\|.*\|$`) + dcReTableSep = regexp.MustCompile(`^\|[\s\-:|]+\|$`) +) + +func dcIsTableRow(line string) bool { + return dcReTableRow.MatchString(strings.TrimSpace(line)) +} + +func dcIsTableSep(line string) bool { + return dcReTableSep.MatchString(strings.TrimSpace(line)) +} + +func dcParseTableRow(line string) []string { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "|") + line = strings.TrimSuffix(line, "|") + cells := strings.Split(line, "|") + for i := range cells { + cells[i] = strings.TrimSpace(cells[i]) + } + return cells +} + +func dcFlushTable(out *strings.Builder, rows [][]string) { + if len(rows) == 0 { + return + } + colWidths := make([]int, len(rows[0])) + for _, row := range rows { + for i, cell := range row { + if i < len(colWidths) && len([]rune(cell)) > colWidths[i] { + colWidths[i] = len([]rune(cell)) + } + } + } + out.WriteString("```\n") + for ri, row := range rows { + for ci, cell := range row { + if ci > 0 { + out.WriteString(" | ") + } + w := 0 + if ci < len(colWidths) { + w = colWidths[ci] + } + out.WriteString(dcPadRight(cell, w)) + } + out.WriteString("\n") + if ri == 0 && len(rows) > 1 { + for ci := range row { + if ci > 0 { + out.WriteString("-+-") + } + w := 0 + if ci < len(colWidths) { + w = colWidths[ci] + } + out.WriteString(strings.Repeat("-", w)) + } + out.WriteString("\n") + } + } + out.WriteString("```\n") +} + +func dcPadRight(s string, width int) string { + runes := []rune(s) + if len(runes) >= width { + return s + } + return s + strings.Repeat(" ", width-len(runes)) +} diff --git a/integrations/feishu/format.go b/integrations/feishu/format.go new file mode 100644 index 00000000..38618ea6 --- /dev/null +++ b/integrations/feishu/format.go @@ -0,0 +1,193 @@ +package feishu + +import ( + "regexp" + "strings" +) + +// FormatFeishuMarkdown converts standard Markdown to Feishu's lark_md subset. +// +// Feishu lark_md (in card div elements) supports: +// - **bold**, *italic*, ~~strikethrough~~ +// - `inline code` +// - [link](url) +// - --- (divider) +// +// NOT supported (must be converted/degraded): +// - # headings → **bold text** +// - ``` code blocks → plain text indented +// - > blockquotes → text with "│ " prefix +// - tables → pre-formatted text +// - - list items → "• " prefixed text +// - 1. ordered list → "N. " prefixed text +// - ![](url) images → [image](url) link +func FormatFeishuMarkdown(md string) string { + md = strings.ReplaceAll(md, "\r\n", "\n") + + var out strings.Builder + lines := strings.Split(md, "\n") + + inCodeBlock := false + var codeLines []string + + inTable := false + var tableRows [][]string + + for i := 0; i < len(lines); i++ { + line := lines[i] + + if strings.HasPrefix(line, "```") { + if !inCodeBlock { + inCodeBlock = true + codeLines = nil + } else { + inCodeBlock = false + for _, cl := range codeLines { + out.WriteString(" " + cl + "\n") + } + } + continue + } + if inCodeBlock { + codeLines = append(codeLines, line) + continue + } + + if fmtIsTableRow(line) { + if !inTable { + inTable = true + tableRows = nil + } + if fmtIsTableSep(line) { + continue + } + tableRows = append(tableRows, fmtParseTableRow(line)) + continue + } + if inTable { + fmtFlushTable(&out, tableRows) + inTable = false + tableRows = nil + } + + if line == "---" || line == "***" || line == "___" { + out.WriteString("---\n") + continue + } + + if m := fmtReHeading.FindStringSubmatch(line); m != nil { + out.WriteString("**" + m[2] + "**\n") + continue + } + + if m := fmtReBlockquote.FindStringSubmatch(line); m != nil { + out.WriteString("│ " + m[1] + "\n") + continue + } + + if m := fmtReUnorderedList.FindStringSubmatch(line); m != nil { + out.WriteString("• " + m[1] + "\n") + continue + } + + if m := fmtReOrderedList.FindStringSubmatch(line); m != nil { + out.WriteString(m[1] + ". " + m[2] + "\n") + continue + } + + if m := fmtReImage.FindStringSubmatch(line); m != nil { + out.WriteString("[" + m[1] + "](" + m[2] + ")\n") + continue + } + + out.WriteString(line + "\n") + } + + if inCodeBlock && len(codeLines) > 0 { + for _, cl := range codeLines { + out.WriteString(" " + cl + "\n") + } + } + if inTable { + fmtFlushTable(&out, tableRows) + } + + return strings.TrimRight(out.String(), "\n") +} + +var ( + fmtReHeading = regexp.MustCompile(`^(#{1,6})\s+(.+)$`) + fmtReBlockquote = regexp.MustCompile(`^>\s*(.*)$`) + fmtReUnorderedList = regexp.MustCompile(`^[\s]*[-*+]\s+(.+)$`) + fmtReOrderedList = regexp.MustCompile(`^[\s]*(\d+)[.)]\s+(.+)$`) + fmtReImage = regexp.MustCompile(`^!\[([^\]]*)\]\(([^)]+)\)$`) + fmtReTableRow = regexp.MustCompile(`^\|.*\|$`) + fmtReTableSep = regexp.MustCompile(`^\|[\s\-:|]+\|$`) +) + +func fmtIsTableRow(line string) bool { + return fmtReTableRow.MatchString(strings.TrimSpace(line)) +} + +func fmtIsTableSep(line string) bool { + return fmtReTableSep.MatchString(strings.TrimSpace(line)) +} + +func fmtParseTableRow(line string) []string { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "|") + line = strings.TrimSuffix(line, "|") + cells := strings.Split(line, "|") + for i := range cells { + cells[i] = strings.TrimSpace(cells[i]) + } + return cells +} + +func fmtFlushTable(out *strings.Builder, rows [][]string) { + if len(rows) == 0 { + return + } + colWidths := make([]int, len(rows[0])) + for _, row := range rows { + for i, cell := range row { + if i < len(colWidths) && len([]rune(cell)) > colWidths[i] { + colWidths[i] = len([]rune(cell)) + } + } + } + for ri, row := range rows { + for ci, cell := range row { + if ci > 0 { + out.WriteString(" | ") + } + w := 0 + if ci < len(colWidths) { + w = colWidths[ci] + } + out.WriteString(fmtPadRight(cell, w)) + } + out.WriteString("\n") + if ri == 0 && len(rows) > 1 { + for ci := range row { + if ci > 0 { + out.WriteString("-+-") + } + w := 0 + if ci < len(colWidths) { + w = colWidths[ci] + } + out.WriteString(strings.Repeat("-", w)) + } + out.WriteString("\n") + } + } +} + +func fmtPadRight(s string, width int) string { + runes := []rune(s) + if len(runes) >= width { + return s + } + return s + strings.Repeat(" ", width-len(runes)) +} diff --git a/integrations/feishu/message.go b/integrations/feishu/message.go index 35480cd8..76d65827 100644 --- a/integrations/feishu/message.go +++ b/integrations/feishu/message.go @@ -4,8 +4,12 @@ import ( "context" "encoding/json" "fmt" + "io" + "path/filepath" + "strings" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + "github.com/yaoapp/yao/attachment" ) // SendTextMessage sends a text message to a chat. @@ -20,6 +24,40 @@ func (b *Bot) SendTextToUser(ctx context.Context, openID, text string) (string, return b.sendMessage(ctx, "open_id", openID, "text", string(content)) } +// SendCardMessage sends a Markdown-rendered interactive card message to a chat. +// Feishu's text type doesn't render Markdown; the interactive card type does. +func (b *Bot) SendCardMessage(ctx context.Context, chatID, markdown string) (string, error) { + card := buildMarkdownCard(markdown) + content, _ := json.Marshal(card) + return b.sendMessage(ctx, "chat_id", chatID, "interactive", string(content)) +} + +// ReplyCardMessage replies with a Markdown-rendered interactive card. +func (b *Bot) ReplyCardMessage(ctx context.Context, messageID, markdown string) (string, error) { + card := buildMarkdownCard(markdown) + content, _ := json.Marshal(card) + return b.replyMessage(ctx, messageID, "interactive", string(content)) +} + +// buildMarkdownCard constructs a Feishu interactive card with lark_md content. +// Uses the non-template card structure: config + elements (div with lark_md). +func buildMarkdownCard(markdown string) map[string]interface{} { + return map[string]interface{}{ + "config": map[string]interface{}{ + "wide_screen_mode": true, + }, + "elements": []interface{}{ + map[string]interface{}{ + "tag": "div", + "text": map[string]interface{}{ + "tag": "lark_md", + "content": markdown, + }, + }, + }, + } +} + // SendImageMessage sends an image by image_key to a chat. func (b *Bot) SendImageMessage(ctx context.Context, chatID, imageKey string) (string, error) { content, _ := json.Marshal(map[string]string{"image_key": imageKey}) @@ -61,6 +99,161 @@ func (b *Bot) sendMessage(ctx context.Context, receiveIDType, receiveID, msgType return "", nil } +// UploadImage uploads an image to Feishu and returns the image_key. +func (b *Bot) UploadImage(ctx context.Context, filename string, reader io.Reader) (string, error) { + req := larkim.NewCreateImageReqBuilder(). + Body(larkim.NewCreateImageReqBodyBuilder(). + ImageType("message"). + Image(reader). + Build()). + Build() + + resp, err := b.client.Im.Image.Create(ctx, req) + if err != nil { + return "", fmt.Errorf("feishu upload image: %w", err) + } + if !resp.Success() { + return "", fmt.Errorf("feishu upload image: code=%d msg=%s", resp.Code, resp.Msg) + } + if resp.Data == nil || resp.Data.ImageKey == nil { + return "", fmt.Errorf("feishu upload image: empty image_key in response") + } + return *resp.Data.ImageKey, nil +} + +// UploadFile uploads a file to Feishu and returns the file_key. +// fileType must be one of: opus, mp4, pdf, doc, xls, ppt, stream. +func (b *Bot) UploadFile(ctx context.Context, filename, fileType string, reader io.Reader) (string, error) { + req := larkim.NewCreateFileReqBuilder(). + Body(larkim.NewCreateFileReqBodyBuilder(). + FileType(fileType). + FileName(filename). + File(reader). + Build()). + Build() + + resp, err := b.client.Im.File.Create(ctx, req) + if err != nil { + return "", fmt.Errorf("feishu upload file: %w", err) + } + if !resp.Success() { + return "", fmt.Errorf("feishu upload file: code=%d msg=%s", resp.Code, resp.Msg) + } + if resp.Data == nil || resp.Data.FileKey == nil { + return "", fmt.Errorf("feishu upload file: empty file_key in response") + } + return *resp.Data.FileKey, nil +} + +// SendImageFromWrapper sends an image from a Yao attachment wrapper (e.g. "__yao.attachment://xxx"). +func (b *Bot) SendImageFromWrapper(ctx context.Context, chatID, wrapper, caption string) error { + managerName, fileID, ok := attachment.Parse(wrapper) + if !ok { + return fmt.Errorf("invalid attachment wrapper: %s", wrapper) + } + + manager, exists := attachment.Managers[managerName] + if !exists { + return fmt.Errorf("attachment manager %s not found", managerName) + } + + resp, err := manager.Download(ctx, fileID) + if err != nil { + return fmt.Errorf("attachment download %s: %w", fileID, err) + } + defer resp.Reader.Close() + + filename := fileID + resp.Extension + imageKey, err := b.UploadImage(ctx, filename, resp.Reader) + if err != nil { + return err + } + + if caption != "" { + if _, err := b.SendTextMessage(ctx, chatID, caption); err != nil { + return err + } + } + _, err = b.SendImageMessage(ctx, chatID, imageKey) + return err +} + +// SendFileFromWrapper sends a file from a Yao attachment wrapper (e.g. "__yao.attachment://xxx"). +func (b *Bot) SendFileFromWrapper(ctx context.Context, chatID, wrapper, caption string) error { + managerName, fileID, ok := attachment.Parse(wrapper) + if !ok { + return fmt.Errorf("invalid attachment wrapper: %s", wrapper) + } + + manager, exists := attachment.Managers[managerName] + if !exists { + return fmt.Errorf("attachment manager %s not found", managerName) + } + + resp, err := manager.Download(ctx, fileID) + if err != nil { + return fmt.Errorf("attachment download %s: %w", fileID, err) + } + defer resp.Reader.Close() + + filename := fileID + resp.Extension + fileType := detectFeishuFileType(resp.ContentType, resp.Extension) + + fileKey, err := b.UploadFile(ctx, filename, fileType, resp.Reader) + if err != nil { + return err + } + + if caption != "" { + if _, err := b.SendTextMessage(ctx, chatID, caption); err != nil { + return err + } + } + _, err = b.SendFileMessage(ctx, chatID, fileKey) + return err +} + +// detectFeishuFileType maps a MIME type / extension to a Feishu file type. +func detectFeishuFileType(contentType, ext string) string { + lower := strings.ToLower(contentType) + switch { + case strings.Contains(lower, "audio/ogg"), strings.Contains(lower, "audio/opus"): + return "opus" + case strings.HasPrefix(lower, "video/"): + return "mp4" + case strings.Contains(lower, "pdf"): + return "pdf" + case strings.Contains(lower, "msword"), + strings.Contains(lower, "wordprocessingml"), + strings.Contains(lower, "opendocument.text"): + return "doc" + case strings.Contains(lower, "ms-excel"), + strings.Contains(lower, "spreadsheetml"), + strings.Contains(lower, "opendocument.spreadsheet"): + return "xls" + case strings.Contains(lower, "ms-powerpoint"), + strings.Contains(lower, "presentationml"), + strings.Contains(lower, "opendocument.presentation"): + return "ppt" + } + + switch strings.ToLower(filepath.Ext(ext)) { + case ".pdf": + return "pdf" + case ".doc", ".docx": + return "doc" + case ".xls", ".xlsx": + return "xls" + case ".ppt", ".pptx": + return "ppt" + case ".mp4", ".mov", ".avi": + return "mp4" + case ".opus", ".ogg": + return "opus" + } + return "stream" +} + func (b *Bot) replyMessage(ctx context.Context, messageID, msgType, content string) (string, error) { req := larkim.NewReplyMessageReqBuilder(). MessageId(messageID).