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:
parent
3fd27627a1
commit
3eb43a59ab
11 changed files with 861 additions and 41 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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\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\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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
170
integrations/dingtalk/format.go
Normal file
170
integrations/dingtalk/format.go
Normal 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)
|
||||
// - 
|
||||
// - --- 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))
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
151
integrations/discord/format.go
Normal file
151
integrations/discord/format.go
Normal 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
|
||||
// -  → 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))
|
||||
}
|
||||
193
integrations/feishu/format.go
Normal file
193
integrations/feishu/format.go
Normal 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
|
||||
// -  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))
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue