- 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.
284 lines
8.9 KiB
Go
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
|
|
}
|