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,
|
ChatID: lastCM.ConversationID,
|
||||||
SenderID: lastCM.SenderID,
|
SenderID: lastCM.SenderID,
|
||||||
SenderName: lastCM.SenderNick,
|
SenderName: lastCM.SenderNick,
|
||||||
|
Locale: "zh-cn",
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"session_webhook": lastCM.SessionWebhook,
|
"session_webhook": lastCM.SessionWebhook,
|
||||||
"conversation_type": lastCM.ConversationType,
|
"conversation_type": lastCM.ConversationType,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ func sendContent(ctx context.Context, sessionWebhook string, content interface{}
|
||||||
if strings.TrimSpace(c) == "" {
|
if strings.TrimSpace(c) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return dtapi.SendMarkdownMessage(ctx, sessionWebhook, "Reply", c)
|
return dtapi.SendMarkdownMessage(ctx, sessionWebhook, "Reply", dtapi.FormatDingTalkMarkdown(c))
|
||||||
|
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
return sendParts(ctx, sessionWebhook, c)
|
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 {
|
if err := flushText(ctx, sessionWebhook, &textBuf); err != nil {
|
||||||
return err
|
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":
|
case "file":
|
||||||
if err := flushText(ctx, sessionWebhook, &textBuf); err != nil {
|
if err := flushText(ctx, sessionWebhook, &textBuf); err != nil {
|
||||||
return err
|
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)
|
return flushText(ctx, sessionWebhook, &textBuf)
|
||||||
|
|
@ -84,10 +108,24 @@ func sendPartsTyped(ctx context.Context, sessionWebhook string, parts []agentcon
|
||||||
switch part.Type {
|
switch part.Type {
|
||||||
case agentcontext.ContentText:
|
case agentcontext.ContentText:
|
||||||
textBuf.WriteString(part.Text)
|
textBuf.WriteString(part.Text)
|
||||||
case agentcontext.ContentImageURL, agentcontext.ContentFile:
|
case agentcontext.ContentImageURL:
|
||||||
if err := flushText(ctx, sessionWebhook, &textBuf); err != nil {
|
if err := flushText(ctx, sessionWebhook, &textBuf); err != nil {
|
||||||
return err
|
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)
|
return flushText(ctx, sessionWebhook, &textBuf)
|
||||||
|
|
@ -99,7 +137,7 @@ func flushText(ctx context.Context, sessionWebhook string, buf *strings.Builder)
|
||||||
}
|
}
|
||||||
text := buf.String()
|
text := buf.String()
|
||||||
buf.Reset()
|
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) {
|
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,
|
ChatID: lastCM.ChannelID,
|
||||||
SenderID: lastCM.AuthorID,
|
SenderID: lastCM.AuthorID,
|
||||||
SenderName: lastCM.AuthorName,
|
SenderName: lastCM.AuthorName,
|
||||||
|
Locale: events.NormalizeLocale(discordLocale(lastCM.Locale)),
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"discord_message_id": lastCM.MessageID,
|
"discord_message_id": lastCM.MessageID,
|
||||||
"guild_id": lastCM.GuildID,
|
"guild_id": lastCM.GuildID,
|
||||||
|
|
@ -128,3 +129,10 @@ func mergeContentParts(parts []interface{}) interface{} {
|
||||||
|
|
||||||
return parts
|
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"
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||||
events "github.com/yaoapp/yao/agent/robot/events"
|
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.
|
// 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) == "" {
|
if strings.TrimSpace(c) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
formatted := dcapi.FormatDiscordMarkdown(c)
|
||||||
if replyToID != "" {
|
if replyToID != "" {
|
||||||
_, err := entry.bot.SendMessageReply(channelID, c, replyToID)
|
_, err := entry.bot.SendMessageReply(channelID, formatted, replyToID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := entry.bot.SendMessage(channelID, c)
|
_, err := entry.bot.SendMessage(channelID, formatted)
|
||||||
return err
|
return err
|
||||||
|
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
|
|
@ -108,10 +110,24 @@ func (a *Adapter) sendPartsTyped(ctx context.Context, entry *botEntry, channelID
|
||||||
switch part.Type {
|
switch part.Type {
|
||||||
case agentcontext.ContentText:
|
case agentcontext.ContentText:
|
||||||
textBuf.WriteString(part.Text)
|
textBuf.WriteString(part.Text)
|
||||||
case agentcontext.ContentImageURL, agentcontext.ContentFile:
|
case agentcontext.ContentImageURL:
|
||||||
if err := a.flushText(entry, channelID, replyToID, &textBuf); err != nil {
|
if err := a.flushText(entry, channelID, replyToID, &textBuf); err != nil {
|
||||||
return err
|
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)
|
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 {
|
if buf.Len() == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
text := buf.String()
|
text := dcapi.FormatDiscordMarkdown(buf.String())
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
|
|
||||||
if replyToID != "" {
|
if replyToID != "" {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
|
|
||||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||||
events "github.com/yaoapp/yao/agent/robot/events"
|
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.
|
// 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) == "" {
|
if strings.TrimSpace(c) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if replyToMsgID != "" {
|
return a.sendMarkdown(ctx, entry, chatID, replyToMsgID, c)
|
||||||
_, err := entry.bot.ReplyTextMessage(ctx, replyToMsgID, c)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := entry.bot.SendTextMessage(ctx, chatID, c)
|
|
||||||
return err
|
|
||||||
|
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
return a.sendParts(ctx, entry, chatID, replyToMsgID, c)
|
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 {
|
if ok {
|
||||||
return a.sendPartsTyped(ctx, entry, chatID, replyToMsgID, parts)
|
return a.sendPartsTyped(ctx, entry, chatID, replyToMsgID, parts)
|
||||||
}
|
}
|
||||||
text := fmt.Sprintf("%v", content)
|
return a.sendMarkdown(ctx, entry, chatID, replyToMsgID, fmt.Sprintf("%v", content))
|
||||||
_, err := entry.bot.SendTextMessage(ctx, chatID, text)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
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 {
|
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 {
|
if err := a.flushText(ctx, entry, chatID, replyToMsgID, &textBuf); err != nil {
|
||||||
return err
|
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":
|
case "file":
|
||||||
if err := a.flushText(ctx, entry, chatID, replyToMsgID, &textBuf); err != nil {
|
if err := a.flushText(ctx, entry, chatID, replyToMsgID, &textBuf); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if fileURL, ok := m["file_url"].(string); ok && fileURL != "" {
|
fileURL, _ := m["file_url"].(string)
|
||||||
if err := a.sendFileContent(ctx, entry, chatID, fileURL); err != nil {
|
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)
|
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 {
|
switch part.Type {
|
||||||
case agentcontext.ContentText:
|
case agentcontext.ContentText:
|
||||||
textBuf.WriteString(part.Text)
|
textBuf.WriteString(part.Text)
|
||||||
case agentcontext.ContentImageURL, agentcontext.ContentFile:
|
case agentcontext.ContentImageURL:
|
||||||
if err := a.flushText(ctx, entry, chatID, replyToMsgID, &textBuf); err != nil {
|
if err := a.flushText(ctx, entry, chatID, replyToMsgID, &textBuf); err != nil {
|
||||||
return err
|
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)
|
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()
|
text := buf.String()
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
|
return a.sendMarkdown(ctx, entry, chatID, replyToMsgID, text)
|
||||||
if replyToMsgID != "" {
|
|
||||||
_, err := entry.bot.ReplyTextMessage(ctx, replyToMsgID, text)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := entry.bot.SendTextMessage(ctx, chatID, text)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adapter) sendFileContent(ctx context.Context, entry *botEntry, chatID, fileURL string) error {
|
func sendImageOrWrapper(ctx context.Context, entry *botEntry, chatID, url, caption string) error {
|
||||||
if strings.Contains(fileURL, "://") && !strings.HasPrefix(fileURL, "http") {
|
if isWrapper(url) {
|
||||||
_, fileID, ok := attachment.Parse(fileURL)
|
return entry.bot.SendImageFromWrapper(ctx, chatID, url, caption)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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) {
|
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)
|
text, media := fsapi.ParseMessageContent(msgType, content)
|
||||||
|
|
||||||
cm := &fsapi.ConvertedMessage{
|
cm := &fsapi.ConvertedMessage{
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
ChatType: chatType,
|
ChatType: chatType,
|
||||||
Text: text,
|
Text: text,
|
||||||
MediaItems: media,
|
MediaItems: media,
|
||||||
EventID: event.EventV2Base.Header.EventID,
|
EventID: event.EventV2Base.Header.EventID,
|
||||||
|
LanguageCode: "zh",
|
||||||
}
|
}
|
||||||
|
|
||||||
if sender != nil && sender.SenderId != nil {
|
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"`
|
IsBot bool `json:"is_bot"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
MediaItems []MediaItem `json:"media,omitempty"`
|
MediaItems []MediaItem `json:"media,omitempty"`
|
||||||
|
Locale string `json:"locale,omitempty"`
|
||||||
ReplyTo string `json:"reply_to,omitempty"`
|
ReplyTo string `json:"reply_to,omitempty"`
|
||||||
IsDM bool `json:"is_dm"`
|
IsDM bool `json:"is_dm"`
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +72,7 @@ func ConvertMessage(m *discordgo.Message) *ConvertedMessage {
|
||||||
cm.AuthorID = m.Author.ID
|
cm.AuthorID = m.Author.ID
|
||||||
cm.AuthorName = m.Author.Username
|
cm.AuthorName = m.Author.Username
|
||||||
cm.IsBot = m.Author.Bot
|
cm.IsBot = m.Author.Bot
|
||||||
|
cm.Locale = m.Author.Locale
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.MessageReference != nil {
|
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"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
|
"github.com/yaoapp/yao/attachment"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SendTextMessage sends a text message to a chat.
|
// 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))
|
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.
|
// SendImageMessage sends an image by image_key to a chat.
|
||||||
func (b *Bot) SendImageMessage(ctx context.Context, chatID, imageKey string) (string, error) {
|
func (b *Bot) SendImageMessage(ctx context.Context, chatID, imageKey string) (string, error) {
|
||||||
content, _ := json.Marshal(map[string]string{"image_key": imageKey})
|
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
|
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) {
|
func (b *Bot) replyMessage(ctx context.Context, messageID, msgType, content string) (string, error) {
|
||||||
req := larkim.NewReplyMessageReqBuilder().
|
req := larkim.NewReplyMessageReqBuilder().
|
||||||
MessageId(messageID).
|
MessageId(messageID).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue