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.
This commit is contained in:
Max 2026-03-02 09:00:56 +08:00
parent 3fd27627a1
commit 3eb43a59ab
11 changed files with 861 additions and 41 deletions

View file

@ -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,

View file

@ -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) {

View file

@ -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
}

View file

@ -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 != "" {

View file

@ -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) {

View file

@ -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 {

View file

@ -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))
}

View file

@ -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 {

View file

@ -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))
}

View file

@ -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))
}

View file

@ -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).