mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
e6a6dbe3f6
* 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
137 lines
3.4 KiB
Go
137 lines
3.4 KiB
Go
package qq
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
identitypkg "github.com/memohai/memoh/internal/channel/identities"
|
|
)
|
|
|
|
var qqOpenIDPattern = regexp.MustCompile(`(?i)^[0-9a-f]{32}$`)
|
|
|
|
func (a *QQAdapter) resolveTarget(ctx context.Context, raw string) (string, error) {
|
|
target := normalizeTarget(raw)
|
|
if !strings.HasPrefix(target, "c2c:") {
|
|
return target, nil
|
|
}
|
|
id := strings.TrimSpace(strings.TrimPrefix(target, "c2c:"))
|
|
if !qqUUIDTargetPattern.MatchString(id) {
|
|
return target, nil
|
|
}
|
|
if mapped, found, err := a.resolveRouteTarget(ctx, id); err != nil {
|
|
return "", err
|
|
} else if found {
|
|
return normalizeTarget(mapped), nil
|
|
}
|
|
if mapped, found, err := a.resolveIdentityTarget(ctx, id); err != nil {
|
|
return "", err
|
|
} else if found {
|
|
return normalizeTarget(mapped), nil
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func (a *QQAdapter) resolveRouteTarget(ctx context.Context, routeID string) (string, bool, error) {
|
|
resolver := a.getRouteResolver()
|
|
if resolver == nil {
|
|
return "", false, nil
|
|
}
|
|
item, err := resolver.GetByID(ctx, routeID)
|
|
if err != nil {
|
|
if isQQLookupMiss(err) {
|
|
return "", false, nil
|
|
}
|
|
return "", false, err
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(item.Platform), string(Type)) {
|
|
return "", false, nil
|
|
}
|
|
target := strings.TrimSpace(item.ReplyTarget)
|
|
if target == "" {
|
|
return "", false, nil
|
|
}
|
|
return target, true, nil
|
|
}
|
|
|
|
func (a *QQAdapter) resolveIdentityTarget(ctx context.Context, id string) (string, bool, error) {
|
|
resolver := a.getIdentityResolver()
|
|
if resolver == nil {
|
|
return "", false, nil
|
|
}
|
|
if mapped, found, err := lookupQQIdentityTarget(ctx, resolver.ListCanonicalChannelIdentities, id); err != nil {
|
|
return "", false, err
|
|
} else if found {
|
|
return mapped, true, nil
|
|
}
|
|
if mapped, found, err := lookupQQIdentityTarget(ctx, resolver.ListUserChannelIdentities, id); err != nil {
|
|
return "", false, err
|
|
} else if found {
|
|
return mapped, true, nil
|
|
}
|
|
item, err := resolver.GetByID(ctx, id)
|
|
if err != nil {
|
|
if isQQLookupMiss(err) {
|
|
return "", false, nil
|
|
}
|
|
return "", false, err
|
|
}
|
|
if mapped := qqIdentityTarget(item); mapped != "" {
|
|
return mapped, true, nil
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
func lookupQQIdentityTarget(ctx context.Context, lookup func(context.Context, string) ([]identitypkg.ChannelIdentity, error), id string) (string, bool, error) {
|
|
items, err := lookup(ctx, id)
|
|
if err != nil {
|
|
if isQQLookupMiss(err) {
|
|
return "", false, nil
|
|
}
|
|
return "", false, err
|
|
}
|
|
if mapped := firstQQIdentityTarget(items); mapped != "" {
|
|
return mapped, true, nil
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
func firstQQIdentityTarget(items []identitypkg.ChannelIdentity) string {
|
|
for _, item := range items {
|
|
if target := qqIdentityTarget(item); target != "" {
|
|
return target
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func qqIdentityTarget(item identitypkg.ChannelIdentity) string {
|
|
if !strings.EqualFold(strings.TrimSpace(item.Channel), string(Type)) {
|
|
return ""
|
|
}
|
|
subjectID := strings.TrimSpace(item.ChannelSubjectID)
|
|
if !qqOpenIDPattern.MatchString(subjectID) {
|
|
return ""
|
|
}
|
|
return "c2c:" + subjectID
|
|
}
|
|
|
|
func isQQLookupMiss(err error) bool {
|
|
return errors.Is(err, pgx.ErrNoRows) || errors.Is(err, identitypkg.ErrChannelIdentityNotFound)
|
|
}
|
|
|
|
func (a *QQAdapter) getRouteResolver() routeResolver {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.routes
|
|
}
|
|
|
|
func (a *QQAdapter) getIdentityResolver() channelIdentityResolver {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.identity
|
|
}
|