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)
151 lines
4.0 KiB
Go
151 lines
4.0 KiB
Go
package attachment
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/memohai/memoh/internal/media"
|
|
)
|
|
|
|
// MapMediaType maps attachment type strings to media types.
|
|
func MapMediaType(rawType string) media.MediaType {
|
|
switch strings.ToLower(strings.TrimSpace(rawType)) {
|
|
case "image", "gif":
|
|
return media.MediaTypeImage
|
|
case "audio", "voice":
|
|
return media.MediaTypeAudio
|
|
case "video":
|
|
return media.MediaTypeVideo
|
|
default:
|
|
return media.MediaTypeFile
|
|
}
|
|
}
|
|
|
|
// NormalizeMime normalizes MIME to lowercase token form.
|
|
func NormalizeMime(raw string) string {
|
|
mime := strings.ToLower(strings.TrimSpace(raw))
|
|
if mime == "" {
|
|
return ""
|
|
}
|
|
if idx := strings.Index(mime, ";"); idx >= 0 {
|
|
mime = strings.TrimSpace(mime[:idx])
|
|
}
|
|
if !strings.Contains(mime, "/") {
|
|
return ""
|
|
}
|
|
return mime
|
|
}
|
|
|
|
// MimeFromDataURL extracts MIME from a data URL.
|
|
func MimeFromDataURL(raw string) string {
|
|
value := strings.TrimSpace(raw)
|
|
lower := strings.ToLower(value)
|
|
if !strings.HasPrefix(lower, "data:") {
|
|
return ""
|
|
}
|
|
rest := value[len("data:"):]
|
|
if idx := strings.Index(rest, ";"); idx >= 0 {
|
|
return NormalizeMime(rest[:idx])
|
|
}
|
|
if idx := strings.Index(rest, ","); idx >= 0 {
|
|
return NormalizeMime(rest[:idx])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ResolveMime resolves source MIME and sniffed MIME into final MIME.
|
|
//
|
|
// For image attachments, content sniffing takes precedence over the platform-reported
|
|
// MIME because platforms sometimes declare the wrong image subtype (e.g. "image/png"
|
|
// for a JPEG file). Using the wrong MIME causes multimodal API rejections (HTTP 400).
|
|
func ResolveMime(mediaType media.MediaType, sourceMime, sniffedMime string) string {
|
|
source := NormalizeMime(sourceMime)
|
|
sniffed := NormalizeMime(sniffedMime)
|
|
sourceGeneric := source == "" || source == "application/octet-stream"
|
|
|
|
if mediaType == media.MediaTypeImage {
|
|
// Prefer the sniffed MIME for images: it is derived from the actual file
|
|
// bytes and is always more accurate than the platform-declared value.
|
|
if strings.HasPrefix(sniffed, "image/") {
|
|
return sniffed
|
|
}
|
|
if strings.HasPrefix(source, "image/") {
|
|
return source
|
|
}
|
|
if !sourceGeneric {
|
|
return source
|
|
}
|
|
if sniffed != "" {
|
|
return sniffed
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
if !sourceGeneric {
|
|
return source
|
|
}
|
|
if sniffed != "" {
|
|
return sniffed
|
|
}
|
|
if source != "" {
|
|
return source
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
// PrepareReaderAndMime reads a small prefix for MIME sniffing and replays it.
|
|
func PrepareReaderAndMime(reader io.Reader, mediaType media.MediaType, sourceMime string) (io.Reader, string, error) {
|
|
if reader == nil {
|
|
return nil, "", errors.New("reader is required")
|
|
}
|
|
header := make([]byte, 512)
|
|
n, err := reader.Read(header)
|
|
if err != nil && err != io.EOF {
|
|
return nil, "", fmt.Errorf("read mime sniff bytes: %w", err)
|
|
}
|
|
header = header[:n]
|
|
sniffed := ""
|
|
if len(header) > 0 {
|
|
sniffed = NormalizeMime(http.DetectContentType(header))
|
|
}
|
|
finalMime := ResolveMime(mediaType, sourceMime, sniffed)
|
|
return io.MultiReader(bytes.NewReader(header), reader), finalMime, nil
|
|
}
|
|
|
|
// NormalizeBase64DataURL normalizes raw base64 into a data URL.
|
|
func NormalizeBase64DataURL(input, mime string) string {
|
|
value := strings.TrimSpace(input)
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(value), "data:") {
|
|
return value
|
|
}
|
|
mime = NormalizeMime(mime)
|
|
if mime == "" {
|
|
mime = "application/octet-stream"
|
|
}
|
|
return "data:" + mime + ";base64," + value
|
|
}
|
|
|
|
// DecodeBase64 decodes both raw base64 and data URL base64 content.
|
|
// The returned reader is bounded to maxBytes+1 for caller-side size validation.
|
|
func DecodeBase64(input string, maxBytes int64) (io.Reader, error) {
|
|
value := strings.TrimSpace(input)
|
|
if value == "" {
|
|
return nil, errors.New("base64 payload is empty")
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(value), "data:") {
|
|
if idx := strings.Index(value, ","); idx >= 0 {
|
|
value = value[idx+1:]
|
|
}
|
|
}
|
|
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(value))
|
|
return io.LimitReader(decoder, maxBytes+1), nil
|
|
}
|