Files
Memoh/internal/attachment/normalize.go
Ringo.Typowriter e6a6dbe3f6 feat(channel): add QQ channel support and image message pipeline (#199)
* feat(channel): add qq adapter and outbound delivery

* feat(channel): ingest inbound qq messages

* feat(web): expose qq channel in management ui

* feat(channel): support qq attachment ingestion

* fix(mcp): fail read raw immediately for missing files

* fix(agent): parse inline image data into native image parts

* test(agent): align read_media tool tests with SDK options

* fix(channel): harden qq image delivery and reconnect loop

Avoid data URLs for qq channel images, reset reconnect backoff after healthy sessions, and fall back gracefully for malformed public image URLs.

* fix(channel): restore qq media delivery and target resolution

* fix(qq,mcp,agent): fix message/qq regressions and pass go lint

* fix(qq,agent): validate inline base64 and sync heartbeat seq

* fix(qq): validate remote voice mime for upload checks

* fix(qq): fall back intents and restore adapter wiring

* fix(qq): prevent final text leakage and dedupe persisted inbound query
2026-03-07 17:12:06 +08:00

145 lines
3.6 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.
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 {
if strings.HasPrefix(source, "image/") {
return source
}
if strings.HasPrefix(sniffed, "image/") {
return sniffed
}
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
}