Files
Memoh/internal/channel/adapters/qq/send.go
T
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

359 lines
10 KiB
Go

package qq
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/memohai/memoh/internal/channel"
"github.com/memohai/memoh/internal/media"
)
const (
qqMediaTypeImage = 1
qqMediaTypeVideo = 2
qqMediaTypeVoice = 3
qqMediaTypeFile = 4
)
type qqTargetKind string
const (
qqTargetC2C qqTargetKind = "c2c"
qqTargetGroup qqTargetKind = "group"
qqTargetChannel qqTargetKind = "channel"
)
type qqTarget struct {
Kind qqTargetKind
ID string
}
var qqUUIDTargetPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
type attachmentUpload struct {
Base64 string
FileName string
Mime string
}
func (a *QQAdapter) Send(ctx context.Context, cfg channel.ChannelConfig, msg channel.OutboundMessage) error {
parsed, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
resolvedTarget, err := a.resolveTarget(ctx, msg.Target)
if err != nil {
return err
}
target, err := parseTarget(resolvedTarget)
if err != nil {
return err
}
client := a.getOrCreateClient(cfg, parsed)
replyTo := ""
if msg.Message.Reply != nil {
replyTo = strings.TrimSpace(msg.Message.Reply.MessageID)
}
text := strings.TrimSpace(msg.Message.PlainText())
if text != "" {
useMarkdown := parsed.MarkdownSupport && msg.Message.Format == channel.MessageFormatMarkdown && target.Kind != qqTargetChannel
if err := client.sendText(ctx, target, text, replyTo, useMarkdown); err != nil {
return err
}
}
for _, att := range msg.Message.Attachments {
if err := a.sendAttachment(ctx, cfg, client, target, replyTo, att); err != nil {
return err
}
}
return nil
}
func parseTarget(raw string) (qqTarget, error) {
normalized := normalizeTarget(raw)
switch {
case strings.HasPrefix(normalized, "c2c:"):
id := strings.TrimSpace(strings.TrimPrefix(normalized, "c2c:"))
if id == "" {
return qqTarget{}, errors.New("qq target c2c id is required")
}
if err := validateQQC2CTarget(id); err != nil {
return qqTarget{}, err
}
return qqTarget{Kind: qqTargetC2C, ID: id}, nil
case strings.HasPrefix(normalized, "group:"):
id := strings.TrimSpace(strings.TrimPrefix(normalized, "group:"))
if id == "" {
return qqTarget{}, errors.New("qq target group id is required")
}
return qqTarget{Kind: qqTargetGroup, ID: id}, nil
case strings.HasPrefix(normalized, "channel:"):
id := strings.TrimSpace(strings.TrimPrefix(normalized, "channel:"))
if id == "" {
return qqTarget{}, errors.New("qq target channel id is required")
}
return qqTarget{Kind: qqTargetChannel, ID: id}, nil
default:
return qqTarget{}, errors.New("unsupported qq target")
}
}
func validateQQC2CTarget(id string) error {
if qqUUIDTargetPattern.MatchString(strings.TrimSpace(id)) {
return errors.New("qq c2c target must be user_openid, not an internal UUID; use c2c:<user_openid>")
}
return nil
}
func (a *QQAdapter) sendAttachment(ctx context.Context, cfg channel.ChannelConfig, client *qqClient, target qqTarget, replyTo string, att channel.Attachment) error {
if target.Kind == qqTargetChannel {
switch att.Type {
case channel.AttachmentImage, channel.AttachmentGIF:
return errors.New("qq channel does not support image attachments")
case channel.AttachmentVideo:
return errors.New("qq channel does not support video attachments")
case channel.AttachmentVoice, channel.AttachmentAudio:
return errors.New("qq channel does not support voice attachments")
case channel.AttachmentFile, "":
return errors.New("qq channel does not support file attachments")
default:
return fmt.Errorf("unsupported qq attachment type: %s", att.Type)
}
}
upload, err := a.prepareAttachmentUpload(ctx, cfg.BotID, att)
if err != nil {
return err
}
switch att.Type {
case channel.AttachmentImage, channel.AttachmentGIF:
fileInfo, err := client.uploadMedia(ctx, target, qqMediaTypeImage, upload.Base64, "")
if err != nil {
return err
}
return client.sendMedia(ctx, target, fileInfo, replyTo, att.Caption)
case channel.AttachmentVideo:
fileInfo, err := client.uploadMedia(ctx, target, qqMediaTypeVideo, upload.Base64, "")
if err != nil {
return err
}
return client.sendMedia(ctx, target, fileInfo, replyTo, att.Caption)
case channel.AttachmentVoice, channel.AttachmentAudio:
if !supportsQQVoiceUpload(att, upload.FileName, upload.Mime) {
return errors.New("qq voice attachments require SILK/WAV/MP3/AMR input")
}
fileInfo, err := client.uploadMedia(ctx, target, qqMediaTypeVoice, upload.Base64, "")
if err != nil {
return err
}
return client.sendMedia(ctx, target, fileInfo, replyTo, att.Caption)
case channel.AttachmentFile, "":
fileInfo, err := client.uploadMedia(ctx, target, qqMediaTypeFile, upload.Base64, upload.FileName)
if err != nil {
return err
}
return client.sendMedia(ctx, target, fileInfo, replyTo, att.Caption)
default:
return fmt.Errorf("unsupported qq attachment type: %s", att.Type)
}
}
func (a *QQAdapter) prepareAttachmentUpload(ctx context.Context, fallbackBotID string, att channel.Attachment) (attachmentUpload, error) {
if remoteURL := strings.TrimSpace(att.URL); strings.HasPrefix(strings.ToLower(remoteURL), "http://") || strings.HasPrefix(strings.ToLower(remoteURL), "https://") {
return a.prepareRemoteAttachmentUpload(ctx, att, remoteURL)
}
if rawBase64 := extractRawBase64(att); rawBase64 != "" {
return attachmentUpload{
Base64: rawBase64,
FileName: deriveAttachmentName(att),
Mime: strings.TrimSpace(att.Mime),
}, nil
}
contentHash := strings.TrimSpace(att.ContentHash)
if contentHash == "" || a.assets == nil {
return attachmentUpload{}, errors.New("qq attachment requires http(s) URL, base64, or content_hash")
}
botID := strings.TrimSpace(fallbackBotID)
if att.Metadata != nil {
if override, ok := att.Metadata["bot_id"].(string); ok && strings.TrimSpace(override) != "" {
botID = strings.TrimSpace(override)
}
}
if botID == "" {
return attachmentUpload{}, errors.New("qq attachment content_hash requires bot_id context")
}
reader, asset, err := a.assets.Open(ctx, botID, contentHash)
if err != nil {
return attachmentUpload{}, err
}
defer func() { _ = reader.Close() }()
data, err := media.ReadAllWithLimit(reader, media.MaxAssetBytes)
if err != nil {
return attachmentUpload{}, err
}
fileName := deriveAttachmentName(att)
if fileName == "" {
fileName = deriveFileNameFromMime(asset.Mime, att.Type)
}
return attachmentUpload{
Base64: base64.StdEncoding.EncodeToString(data),
FileName: fileName,
Mime: strings.TrimSpace(asset.Mime),
}, nil
}
func (a *QQAdapter) prepareRemoteAttachmentUpload(ctx context.Context, att channel.Attachment, remoteURL string) (attachmentUpload, error) {
u, err := url.Parse(remoteURL)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
return attachmentUpload{}, fmt.Errorf("invalid attachment url: %s", remoteURL)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteURL, nil)
if err != nil {
return attachmentUpload{}, err
}
resp, err := a.httpClient.Do(req) //nolint:gosec // remote URL is validated to http(s) with non-empty host above
if err != nil {
return attachmentUpload{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return attachmentUpload{}, fmt.Errorf("qq attachment fetch failed: status=%d", resp.StatusCode)
}
data, err := media.ReadAllWithLimit(resp.Body, media.MaxAssetBytes)
if err != nil {
return attachmentUpload{}, err
}
mimeType := strings.TrimSpace(att.Mime)
if mimeType == "" {
mimeType = strings.TrimSpace(resp.Header.Get("Content-Type"))
if idx := strings.Index(mimeType, ";"); idx >= 0 {
mimeType = strings.TrimSpace(mimeType[:idx])
}
}
fileName := deriveAttachmentName(att)
if fileName == "" {
fileName = deriveFileNameFromMime(mimeType, att.Type)
}
return attachmentUpload{
Base64: base64.StdEncoding.EncodeToString(data),
FileName: fileName,
Mime: mimeType,
}, nil
}
func extractRawBase64(att channel.Attachment) string {
if candidate := strings.TrimSpace(att.Base64); candidate != "" {
if strings.HasPrefix(strings.ToLower(candidate), "data:") {
if idx := strings.Index(candidate, ","); idx >= 0 && idx < len(candidate)-1 {
return candidate[idx+1:]
}
return ""
}
return candidate
}
candidate := strings.TrimSpace(att.URL)
if strings.HasPrefix(strings.ToLower(candidate), "data:") {
if idx := strings.Index(candidate, ","); idx >= 0 && idx < len(candidate)-1 {
return candidate[idx+1:]
}
}
return ""
}
func deriveAttachmentName(att channel.Attachment) string {
if name := strings.TrimSpace(att.Name); name != "" {
return name
}
if rawURL := strings.TrimSpace(att.URL); rawURL != "" && !strings.HasPrefix(strings.ToLower(rawURL), "data:") {
if base := filepath.Base(rawURL); base != "." && base != "/" && base != "" {
return base
}
}
return deriveFileNameFromMime(att.Mime, att.Type)
}
func deriveFileNameFromMime(mimeType string, attType channel.AttachmentType) string {
ext := mimeExtension(mimeType)
base := "attachment"
switch attType {
case channel.AttachmentImage, channel.AttachmentGIF:
base = "image"
case channel.AttachmentVideo:
base = "video"
case channel.AttachmentVoice, channel.AttachmentAudio:
base = "audio"
case channel.AttachmentFile:
base = "file"
}
return base + ext
}
func mimeExtension(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "image/png":
return ".png"
case "image/jpeg", "image/jpg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "video/mp4":
return ".mp4"
case "audio/mpeg", "audio/mp3":
return ".mp3"
case "audio/wav", "audio/x-wav":
return ".wav"
case "audio/amr":
return ".amr"
case "application/pdf":
return ".pdf"
default:
return ""
}
}
func supportsQQVoiceUpload(att channel.Attachment, fileName string, resolvedMime string) bool {
check := strings.ToLower(strings.TrimSpace(fileName))
if check == "" {
check = strings.ToLower(strings.TrimSpace(att.Name))
}
for _, ext := range []string{".silk", ".slk", ".amr", ".wav", ".mp3"} {
if strings.HasSuffix(check, ext) {
return true
}
}
mimeType := strings.ToLower(strings.TrimSpace(resolvedMime))
if mimeType == "" {
mimeType = strings.ToLower(strings.TrimSpace(att.Mime))
}
switch mimeType {
case "audio/silk", "audio/amr", "audio/wav", "audio/x-wav", "audio/mpeg", "audio/mp3":
return true
default:
return false
}
}