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
202 lines
5.5 KiB
Go
202 lines
5.5 KiB
Go
package qq
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
identitypkg "github.com/memohai/memoh/internal/channel/identities"
|
|
routepkg "github.com/memohai/memoh/internal/channel/route"
|
|
)
|
|
|
|
const testQQOpenID = "00112233445566778899AABBCCDDEEFF"
|
|
|
|
func TestQQResolveTargetMapsRouteID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := NewQQAdapter(nil)
|
|
adapter.SetRouteResolver(&fakeQQRouteResolver{
|
|
byID: map[string]routepkg.Route{
|
|
"3fe2bad9-3eae-4f23-872c-b7a63662aa00": {
|
|
ID: "3fe2bad9-3eae-4f23-872c-b7a63662aa00",
|
|
Platform: "qq",
|
|
ReplyTarget: "c2c:" + testQQOpenID,
|
|
},
|
|
},
|
|
})
|
|
|
|
got, err := adapter.resolveTarget(context.Background(), "3fe2bad9-3eae-4f23-872c-b7a63662aa00")
|
|
if err != nil {
|
|
t.Fatalf("resolveTarget returned error: %v", err)
|
|
}
|
|
if got != "c2c:"+testQQOpenID {
|
|
t.Fatalf("unexpected mapped target: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestQQResolveTargetMapsIdentityID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := NewQQAdapter(nil)
|
|
adapter.SetChannelIdentityResolver(&fakeQQIdentityResolver{
|
|
canonical: map[string][]identitypkg.ChannelIdentity{
|
|
"3fe2bad9-3eae-4f23-872c-b7a63662aa00": {
|
|
{ID: "qq-identity-1", Channel: "qq", ChannelSubjectID: testQQOpenID},
|
|
},
|
|
},
|
|
})
|
|
|
|
got, err := adapter.resolveTarget(context.Background(), "3fe2bad9-3eae-4f23-872c-b7a63662aa00")
|
|
if err != nil {
|
|
t.Fatalf("resolveTarget returned error: %v", err)
|
|
}
|
|
if got != "c2c:"+testQQOpenID {
|
|
t.Fatalf("unexpected mapped target: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestQQResolveTargetMapsUserID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := NewQQAdapter(nil)
|
|
adapter.SetChannelIdentityResolver(&fakeQQIdentityResolver{
|
|
userScoped: map[string][]identitypkg.ChannelIdentity{
|
|
"3fe2bad9-3eae-4f23-872c-b7a63662aa00": {
|
|
{ID: "qq-identity-1", Channel: "qq", ChannelSubjectID: testQQOpenID},
|
|
},
|
|
},
|
|
})
|
|
|
|
got, err := adapter.resolveTarget(context.Background(), "3fe2bad9-3eae-4f23-872c-b7a63662aa00")
|
|
if err != nil {
|
|
t.Fatalf("resolveTarget returned error: %v", err)
|
|
}
|
|
if got != "c2c:"+testQQOpenID {
|
|
t.Fatalf("unexpected mapped target: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestQQResolveTargetSkipsNonOpenIDQQIdentity(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := NewQQAdapter(nil)
|
|
adapter.SetChannelIdentityResolver(&fakeQQIdentityResolver{
|
|
canonical: map[string][]identitypkg.ChannelIdentity{
|
|
"3fe2bad9-3eae-4f23-872c-b7a63662aa00": {
|
|
{ID: "qq-guild-identity-1", Channel: "qq", ChannelSubjectID: "guild-user-id"},
|
|
},
|
|
},
|
|
})
|
|
|
|
got, err := adapter.resolveTarget(context.Background(), "3fe2bad9-3eae-4f23-872c-b7a63662aa00")
|
|
if err != nil {
|
|
t.Fatalf("resolveTarget returned error: %v", err)
|
|
}
|
|
if got != "c2c:3fe2bad9-3eae-4f23-872c-b7a63662aa00" {
|
|
t.Fatalf("unexpected mapped target: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestQQResolveTargetReturnsRouteResolverErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := NewQQAdapter(nil)
|
|
adapter.SetRouteResolver(&fakeQQRouteResolver{err: errors.New("route store unavailable")})
|
|
|
|
_, err := adapter.resolveTarget(context.Background(), "3fe2bad9-3eae-4f23-872c-b7a63662aa00")
|
|
if err == nil {
|
|
t.Fatal("expected route resolver error")
|
|
}
|
|
if !strings.Contains(err.Error(), "route store unavailable") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQQResolveTargetReturnsIdentityResolverErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := NewQQAdapter(nil)
|
|
adapter.SetChannelIdentityResolver(&fakeQQIdentityResolver{canonicalErr: errors.New("identity store unavailable")})
|
|
|
|
_, err := adapter.resolveTarget(context.Background(), "3fe2bad9-3eae-4f23-872c-b7a63662aa00")
|
|
if err == nil {
|
|
t.Fatal("expected identity resolver error")
|
|
}
|
|
if !strings.Contains(err.Error(), "identity store unavailable") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseTargetRejectsUUIDForC2C(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := parseTarget("3fe2bad9-3eae-4f23-872c-b7a63662aa00")
|
|
if err == nil {
|
|
t.Fatal("expected c2c uuid target error")
|
|
}
|
|
if !strings.Contains(err.Error(), "user_openid") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
type fakeQQIdentityResolver struct {
|
|
byID map[string]identitypkg.ChannelIdentity
|
|
canonical map[string][]identitypkg.ChannelIdentity
|
|
userScoped map[string][]identitypkg.ChannelIdentity
|
|
byIDErr error
|
|
canonicalErr error
|
|
userErr error
|
|
}
|
|
|
|
func (f *fakeQQIdentityResolver) GetByID(_ context.Context, channelIdentityID string) (identitypkg.ChannelIdentity, error) {
|
|
if f.byIDErr != nil {
|
|
return identitypkg.ChannelIdentity{}, f.byIDErr
|
|
}
|
|
item, ok := f.byID[channelIdentityID]
|
|
if !ok {
|
|
return identitypkg.ChannelIdentity{}, identitypkg.ErrChannelIdentityNotFound
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (f *fakeQQIdentityResolver) ListCanonicalChannelIdentities(_ context.Context, channelIdentityID string) ([]identitypkg.ChannelIdentity, error) {
|
|
if f.canonicalErr != nil {
|
|
return nil, f.canonicalErr
|
|
}
|
|
items, ok := f.canonical[channelIdentityID]
|
|
if !ok {
|
|
return nil, identitypkg.ErrChannelIdentityNotFound
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (f *fakeQQIdentityResolver) ListUserChannelIdentities(_ context.Context, userID string) ([]identitypkg.ChannelIdentity, error) {
|
|
if f.userErr != nil {
|
|
return nil, f.userErr
|
|
}
|
|
items, ok := f.userScoped[userID]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
type fakeQQRouteResolver struct {
|
|
byID map[string]routepkg.Route
|
|
err error
|
|
}
|
|
|
|
func (f *fakeQQRouteResolver) GetByID(_ context.Context, routeID string) (routepkg.Route, error) {
|
|
if f.err != nil {
|
|
return routepkg.Route{}, f.err
|
|
}
|
|
item, ok := f.byID[routeID]
|
|
if !ok {
|
|
return routepkg.Route{}, pgx.ErrNoRows
|
|
}
|
|
return item, nil
|
|
}
|