mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
d3bf6bc90a
* feat(channel): add DingTalk channel adapter - Add DingTalk channel adapter (`internal/channel/adapters/dingtalk/`) using dingtalk-stream-sdk-go, supporting inbound message receiving and outbound text/markdown reply - Register DingTalk adapter in cmd/agent and cmd/memoh - Add go.mod dependency: github.com/memohai/dingtalk-stream-sdk-go - Add Dingtalk and Wecom SVG icons and Vue components to @memohai/icon - Refactor existing icon components to remove redundant inline wrappers - Add `channelTypeDisplayName` util for consistent channel label resolution - Add DingTalk/WeCom i18n entries (en/zh) for types and typesShort - Extend channel-icon, bot-channels, channel-settings-panel to support dingtalk/wecom - Use channelTypeDisplayName in profile page to replace ad-hoc i18n lookup * fix(channel,attachment): channel quality refactor & attachment pipeline fixes Channel module: - Fix RemoveAdapter not cleaning connectionMeta (stale status leak) - Fix preparedAttachmentTypeFromMime misclassifying image/gif - Fix sleepWithContext time.After goroutine/timer leak - Export IsDataURL/IsHTTPURL/IsDataPath, dedup across packages - Cache OutboundPolicy in managerOutboundStream to avoid repeated lookups - Split OutboundAttachmentStore: extract ContainerAttachmentIngester interface - Add ManagerOption funcs (WithInboundQueueSize, WithInboundWorkers, WithRefreshInterval) - Add thread-safety docs on OutboundStream / managerOutboundStream - Add debug logs on successful send/edit paths - Expand outbound_prepare_test.go with 21 new cases - Convert no-receiver adapter helpers to package-level funcs; drop unused params DingTalk adapter: - Implement AttachmentResolver: download inbound media via /v1.0/robot/messageFiles/download - Fix pure-image inbound messages failing due to missing resolver Attachment pipeline: - Fix images invisible to LLM in pipeline (DCP) path: inject InlineImages into last user message when cfg.Query is empty - Fix public_url fallback: skip direct URL-to-LLM when ContentHash is set, always prefer inlined persisted asset - Inject path: carry ImageParts through agent.InjectMessage; inline persisted attachments in resolver inject goroutine so mid-stream images reach the model - Fix ResolveMime for images: prefer content-sniffed MIME over platform-declared MIME (fixes Feishu sending image/png header for actual JPEG content → API 400)
424 lines
11 KiB
Go
424 lines
11 KiB
Go
package wecom
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5" //nolint:gosec // WeCom stream image payload requires MD5 checksum field.
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/memohai/memoh/internal/channel"
|
|
"github.com/memohai/memoh/internal/media"
|
|
)
|
|
|
|
func (a *WeComAdapter) ResolveAttachment(ctx context.Context, cfg channel.ChannelConfig, attachment channel.Attachment) (channel.AttachmentPayload, error) {
|
|
_ = cfg
|
|
if a.http == nil {
|
|
return channel.AttachmentPayload{}, errors.New("wecom http client not configured")
|
|
}
|
|
url := strings.TrimSpace(attachment.URL)
|
|
if url == "" {
|
|
return channel.AttachmentPayload{}, errors.New("wecom attachment url is required")
|
|
}
|
|
aesKey := ""
|
|
if attachment.Metadata != nil {
|
|
if value, ok := attachment.Metadata["aeskey"].(string); ok {
|
|
aesKey = strings.TrimSpace(value)
|
|
}
|
|
}
|
|
var file DownloadedFile
|
|
var err error
|
|
if aesKey != "" {
|
|
file, err = a.http.DownloadAndDecryptFile(ctx, url, aesKey)
|
|
} else {
|
|
file, err = a.http.DownloadFile(ctx, url)
|
|
}
|
|
if err != nil {
|
|
return channel.AttachmentPayload{}, err
|
|
}
|
|
return channel.AttachmentPayload{
|
|
Reader: io.NopCloser(bytes.NewReader(file.Data)),
|
|
Mime: strings.TrimSpace(file.ContentType),
|
|
Name: strings.TrimSpace(file.FileName),
|
|
Size: int64(len(file.Data)),
|
|
}, nil
|
|
}
|
|
|
|
type markdownPayload struct {
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
func buildSendPayload(msg channel.Message, targetID string) (any, string, string, error) {
|
|
if strings.TrimSpace(targetID) == "" {
|
|
return nil, "", "", errors.New("wecom target id is required")
|
|
}
|
|
reqID := NewReqID(WSCmdSendMessage)
|
|
if card, ok := readTemplateCard(msg.Metadata); ok {
|
|
return SendMessageTemplateCardBody{
|
|
ChatID: targetID,
|
|
MsgType: "template_card",
|
|
TemplateCard: card,
|
|
}, WSCmdSendMessage, reqID, nil
|
|
}
|
|
|
|
// aibot_send_msg currently supports markdown/template_card in official SDK.
|
|
// Attachments should be sent through callback-reply path (aibot_respond_msg).
|
|
if len(msg.Attachments) > 0 {
|
|
return nil, "", "", errors.New("wecom proactive send does not support attachments; use reply flow")
|
|
}
|
|
|
|
text := strings.TrimSpace(msg.PlainText())
|
|
if text == "" {
|
|
return nil, "", "", errors.New("wecom outbound text is required")
|
|
}
|
|
return SendMessageMarkdownBody{
|
|
ChatID: targetID,
|
|
MsgType: "markdown",
|
|
Markdown: markdownPayload{
|
|
Content: text,
|
|
},
|
|
}, WSCmdSendMessage, reqID, nil
|
|
}
|
|
|
|
func buildRespondPayload(msg channel.Message, replyReqID string) (any, string, string, error) {
|
|
return buildRespondPayloadWithStream(msg, replyReqID, "", true)
|
|
}
|
|
|
|
func buildPreparedRespondPayload(ctx context.Context, msg channel.PreparedMessage, replyReqID string) (any, string, string, error) {
|
|
return buildPreparedRespondPayloadWithStream(ctx, msg, replyReqID, "", true)
|
|
}
|
|
|
|
func buildRespondPayloadWithStream(msg channel.Message, replyReqID string, streamID string, finish bool) (any, string, string, error) {
|
|
reqID := strings.TrimSpace(replyReqID)
|
|
if reqID == "" {
|
|
return nil, "", "", errors.New("reply req_id is required")
|
|
}
|
|
if finish {
|
|
if body, ok := buildWelcomePayload(msg); ok {
|
|
return body, WSCmdRespondWelcome, reqID, nil
|
|
}
|
|
if body, ok := readUpdateTemplateCard(msg.Metadata); ok {
|
|
return body, WSCmdRespondUpdate, reqID, nil
|
|
}
|
|
}
|
|
text := strings.TrimSpace(msg.PlainText())
|
|
if finish && text == "" && len(msg.Attachments) == 0 {
|
|
return nil, "", "", errors.New("wecom reply payload is empty")
|
|
}
|
|
if !finish && text == "" {
|
|
return nil, "", "", errors.New("wecom stream delta content is empty")
|
|
}
|
|
streamID = strings.TrimSpace(streamID)
|
|
if streamID == "" {
|
|
streamID = NewReqID("stream")
|
|
}
|
|
stream := StreamReplyBlock{
|
|
ID: streamID,
|
|
Finish: finish,
|
|
Content: text,
|
|
}
|
|
if feedbackID := readFeedbackID(msg.Metadata); feedbackID != "" {
|
|
stream.Feedback = &StreamReplyFeedback{ID: feedbackID}
|
|
}
|
|
if finish {
|
|
stream.MsgItems = buildLegacyStreamReplyItems(msg)
|
|
}
|
|
if card, ok := readTemplateCard(msg.Metadata); ok {
|
|
return StreamWithTemplateCardReplyBody{
|
|
MsgType: "stream_with_template_card",
|
|
Stream: stream,
|
|
TemplateCard: card,
|
|
}, WSCmdRespond, reqID, nil
|
|
}
|
|
return StreamReplyBody{
|
|
MsgType: "stream",
|
|
Stream: stream,
|
|
}, WSCmdRespond, reqID, nil
|
|
}
|
|
|
|
func buildPreparedRespondPayloadWithStream(
|
|
ctx context.Context,
|
|
msg channel.PreparedMessage,
|
|
replyReqID string,
|
|
streamID string,
|
|
finish bool,
|
|
) (any, string, string, error) {
|
|
logical := msg.LogicalMessage()
|
|
reqID := strings.TrimSpace(replyReqID)
|
|
if reqID == "" {
|
|
return nil, "", "", errors.New("reply req_id is required")
|
|
}
|
|
if finish {
|
|
if body, ok := buildWelcomePayload(logical); ok {
|
|
return body, WSCmdRespondWelcome, reqID, nil
|
|
}
|
|
if body, ok := readUpdateTemplateCard(logical.Metadata); ok {
|
|
return body, WSCmdRespondUpdate, reqID, nil
|
|
}
|
|
}
|
|
text := strings.TrimSpace(logical.PlainText())
|
|
if finish && text == "" && len(logical.Attachments) == 0 && len(msg.Attachments) == 0 {
|
|
return nil, "", "", errors.New("wecom reply payload is empty")
|
|
}
|
|
if !finish && text == "" {
|
|
return nil, "", "", errors.New("wecom stream delta content is empty")
|
|
}
|
|
streamID = strings.TrimSpace(streamID)
|
|
if streamID == "" {
|
|
streamID = NewReqID("stream")
|
|
}
|
|
stream := StreamReplyBlock{
|
|
ID: streamID,
|
|
Finish: finish,
|
|
Content: text,
|
|
}
|
|
if feedbackID := readFeedbackID(logical.Metadata); feedbackID != "" {
|
|
stream.Feedback = &StreamReplyFeedback{ID: feedbackID}
|
|
}
|
|
if finish {
|
|
items, err := buildPreparedStreamReplyItems(ctx, msg)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
stream.MsgItems = items
|
|
}
|
|
if card, ok := readTemplateCard(logical.Metadata); ok {
|
|
return StreamWithTemplateCardReplyBody{
|
|
MsgType: "stream_with_template_card",
|
|
Stream: stream,
|
|
TemplateCard: card,
|
|
}, WSCmdRespond, reqID, nil
|
|
}
|
|
return StreamReplyBody{
|
|
MsgType: "stream",
|
|
Stream: stream,
|
|
}, WSCmdRespond, reqID, nil
|
|
}
|
|
|
|
func buildPreparedStreamReplyItems(ctx context.Context, msg channel.PreparedMessage) ([]StreamReplyItem, error) {
|
|
if len(msg.Attachments) == 0 {
|
|
return buildLegacyStreamReplyItems(msg.Message), nil
|
|
}
|
|
item, err := buildPreparedStreamReplyItem(ctx, msg.Attachments[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []StreamReplyItem{item}, nil
|
|
}
|
|
|
|
func buildPreparedStreamReplyItem(ctx context.Context, att channel.PreparedAttachment) (StreamReplyItem, error) {
|
|
if !isWeComReplyImageAttachment(att) {
|
|
return StreamReplyItem{}, errors.New("wecom reply attachments only support images")
|
|
}
|
|
if att.Open == nil {
|
|
return StreamReplyItem{}, errors.New("wecom reply attachment reader is required")
|
|
}
|
|
reader, err := att.Open(ctx)
|
|
if err != nil {
|
|
return StreamReplyItem{}, err
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
raw, err := media.ReadAllWithLimit(reader, media.MaxAssetBytes)
|
|
if err != nil {
|
|
return StreamReplyItem{}, err
|
|
}
|
|
if len(raw) == 0 {
|
|
return StreamReplyItem{}, errors.New("wecom reply attachment is empty")
|
|
}
|
|
return StreamReplyItem{
|
|
MsgType: "image",
|
|
Image: &StreamReplyImage{
|
|
Base64: base64.StdEncoding.EncodeToString(raw),
|
|
MD5: fmt.Sprintf("%x", md5.Sum(raw)), //nolint:gosec // WeCom protocol mandates md5 field for base64 images.
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func buildLegacyStreamReplyItems(msg channel.Message) []StreamReplyItem {
|
|
if len(msg.Attachments) == 0 {
|
|
return nil
|
|
}
|
|
first := msg.Attachments[0]
|
|
base64Data := extractBase64Content(first.Base64)
|
|
if base64Data == "" {
|
|
return nil
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(base64Data)
|
|
if err != nil || len(raw) == 0 {
|
|
return nil
|
|
}
|
|
return []StreamReplyItem{
|
|
{
|
|
MsgType: "image",
|
|
Image: &StreamReplyImage{
|
|
Base64: base64.StdEncoding.EncodeToString(raw),
|
|
MD5: fmt.Sprintf("%x", md5.Sum(raw)), //nolint:gosec // WeCom protocol mandates md5 field for base64 images.
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func isWeComReplyImageAttachment(att channel.PreparedAttachment) bool {
|
|
switch att.Logical.Type {
|
|
case channel.AttachmentImage, channel.AttachmentGIF:
|
|
return true
|
|
}
|
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(att.Mime)), "image/")
|
|
}
|
|
|
|
func buildWelcomePayload(msg channel.Message) (any, bool) {
|
|
if !readBool(msg.Metadata, "wecom_welcome") {
|
|
return nil, false
|
|
}
|
|
if card, ok := readTemplateCard(msg.Metadata); ok {
|
|
return WelcomeTemplateCardReplyBody{
|
|
MsgType: "template_card",
|
|
TemplateCard: card,
|
|
}, true
|
|
}
|
|
text := strings.TrimSpace(msg.PlainText())
|
|
if text == "" {
|
|
return nil, false
|
|
}
|
|
return WelcomeTextReplyBody{
|
|
MsgType: "text",
|
|
Text: welcomeTextBody{
|
|
Content: text,
|
|
},
|
|
}, true
|
|
}
|
|
|
|
func readTemplateCard(metadata map[string]any) (map[string]any, bool) {
|
|
if metadata == nil {
|
|
return nil, false
|
|
}
|
|
raw, ok := metadata["wecom_template_card"]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
card, ok := raw.(map[string]any)
|
|
if !ok || len(card) == 0 {
|
|
return nil, false
|
|
}
|
|
return card, true
|
|
}
|
|
|
|
func readUpdateTemplateCard(metadata map[string]any) (UpdateTemplateCardBody, bool) {
|
|
if metadata == nil {
|
|
return UpdateTemplateCardBody{}, false
|
|
}
|
|
raw, ok := metadata["wecom_update_template_card"]
|
|
if !ok {
|
|
return UpdateTemplateCardBody{}, false
|
|
}
|
|
card, ok := raw.(map[string]any)
|
|
if !ok || len(card) == 0 {
|
|
return UpdateTemplateCardBody{}, false
|
|
}
|
|
body := UpdateTemplateCardBody{
|
|
ResponseType: "update_template_card",
|
|
TemplateCard: card,
|
|
}
|
|
if userIDs := readStringSlice(metadata["wecom_update_userids"]); len(userIDs) > 0 {
|
|
body.UserIDs = userIDs
|
|
}
|
|
return body, true
|
|
}
|
|
|
|
func readStringSlice(raw any) []string {
|
|
switch v := raw.(type) {
|
|
case []string:
|
|
out := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
if s := strings.TrimSpace(item); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
|
|
out = append(out, strings.TrimSpace(s))
|
|
}
|
|
}
|
|
return out
|
|
case string:
|
|
if strings.TrimSpace(v) == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(v, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if s := strings.TrimSpace(part); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func readFeedbackID(metadata map[string]any) string {
|
|
if metadata == nil {
|
|
return ""
|
|
}
|
|
raw, ok := metadata["wecom_feedback_id"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if v, ok := raw.(string); ok {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readBool(metadata map[string]any, key string) bool {
|
|
if metadata == nil || strings.TrimSpace(key) == "" {
|
|
return false
|
|
}
|
|
raw, ok := metadata[key]
|
|
if !ok {
|
|
return false
|
|
}
|
|
v, ok := raw.(bool)
|
|
return ok && v
|
|
}
|
|
|
|
func extractBase64Content(v string) string {
|
|
value := strings.TrimSpace(v)
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
if idx := strings.Index(value, ","); idx > 0 && strings.Contains(strings.ToLower(value[:idx]), "base64") {
|
|
return strings.TrimSpace(value[idx+1:])
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (a *WeComAdapter) getClient(botID string) *WSClient {
|
|
key := strings.TrimSpace(botID)
|
|
if key == "" {
|
|
return nil
|
|
}
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
return a.clients[key]
|
|
}
|
|
|
|
func (a *WeComAdapter) lookupCallbackContext(reply *channel.ReplyRef) (callbackContext, bool) {
|
|
if a == nil || a.cache == nil || reply == nil {
|
|
return callbackContext{}, false
|
|
}
|
|
messageID := strings.TrimSpace(reply.MessageID)
|
|
if messageID == "" {
|
|
return callbackContext{}, false
|
|
}
|
|
return a.cache.Get(messageID)
|
|
}
|