yao/integrations/feishu/message.go
Max 09af247a7c feat(robot): add Weixin integration and enhance existing adapters
- Introduced Weixin integration support with new configuration options for WeChat iLink Bot.
- Updated existing adapters (DingTalk, Discord, Feishu, Telegram) to include sender_id and app_id in message metadata for improved context handling.
- Enhanced dispatcher logic to accommodate the new Weixin adapter and ensure proper initialization and shutdown processes.
- Improved message handling across integrations to support typing indicators, providing a more interactive user experience.
2026-03-23 23:47:25 +08:00

284 lines
8.9 KiB
Go

package feishu
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"
)
// SendTyping is a placeholder for typing indicator support.
// Feishu does not provide a public typing status API; this is a no-op
// so callers can use a uniform interface across all adapters.
func (b *Bot) SendTyping(ctx context.Context, chatID string) error {
return nil
}
// SendTextMessage sends a text message to a chat.
func (b *Bot) SendTextMessage(ctx context.Context, chatID, text string) (string, error) {
content, _ := json.Marshal(map[string]string{"text": text})
return b.sendMessage(ctx, "chat_id", chatID, "text", string(content))
}
// SendTextToUser sends a text message to a user by open_id.
func (b *Bot) SendTextToUser(ctx context.Context, openID, text string) (string, error) {
content, _ := json.Marshal(map[string]string{"text": text})
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})
return b.sendMessage(ctx, "chat_id", chatID, "image", string(content))
}
// SendFileMessage sends a file by file_key to a chat.
func (b *Bot) SendFileMessage(ctx context.Context, chatID, fileKey string) (string, error) {
content, _ := json.Marshal(map[string]string{"file_key": fileKey})
return b.sendMessage(ctx, "chat_id", chatID, "file", string(content))
}
// ReplyTextMessage replies to a message with text.
func (b *Bot) ReplyTextMessage(ctx context.Context, messageID, text string) (string, error) {
content, _ := json.Marshal(map[string]string{"text": text})
return b.replyMessage(ctx, messageID, "text", string(content))
}
func (b *Bot) sendMessage(ctx context.Context, receiveIDType, receiveID, msgType, content string) (string, error) {
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(receiveIDType).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(receiveID).
MsgType(msgType).
Content(content).
Build()).
Build()
resp, err := b.client.Im.Message.Create(ctx, req)
if err != nil {
return "", fmt.Errorf("feishu send message: %w", err)
}
if !resp.Success() {
return "", fmt.Errorf("feishu send message: code=%d msg=%s", resp.Code, resp.Msg)
}
if resp.Data != nil && resp.Data.MessageId != nil {
return *resp.Data.MessageId, 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) {
req := larkim.NewReplyMessageReqBuilder().
MessageId(messageID).
Body(larkim.NewReplyMessageReqBodyBuilder().
MsgType(msgType).
Content(content).
Build()).
Build()
resp, err := b.client.Im.Message.Reply(ctx, req)
if err != nil {
return "", fmt.Errorf("feishu reply message: %w", err)
}
if !resp.Success() {
return "", fmt.Errorf("feishu reply message: code=%d msg=%s", resp.Code, resp.Msg)
}
if resp.Data != nil && resp.Data.MessageId != nil {
return *resp.Data.MessageId, nil
}
return "", nil
}