Files
Memoh/internal/channel/adapters/qq/qq.go
T

266 lines
7.3 KiB
Go

package qq
import (
"context"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/memohai/memoh/internal/channel"
identitypkg "github.com/memohai/memoh/internal/channel/identities"
routepkg "github.com/memohai/memoh/internal/channel/route"
"github.com/memohai/memoh/internal/media"
)
const (
defaultAPIBaseURL = "https://api.sgroup.qq.com"
qqOAuthEndpoint = "https://bots.qq.com/app/getAppAccessToken"
defaultChunkLimit = 2000
defaultReadTimeout = 45 * time.Second
defaultWriteTimeout = 15 * time.Second
)
type assetOpener interface {
Open(ctx context.Context, botID, contentHash string) (io.ReadCloser, media.Asset, error)
}
type sessionState struct {
SessionID string
LastSeq int
IntentLevel int
}
type channelIdentityResolver interface {
GetByID(ctx context.Context, channelIdentityID string) (identitypkg.ChannelIdentity, error)
ListCanonicalChannelIdentities(ctx context.Context, channelIdentityID string) ([]identitypkg.ChannelIdentity, error)
ListUserChannelIdentities(ctx context.Context, userID string) ([]identitypkg.ChannelIdentity, error)
}
type routeResolver interface {
GetByID(ctx context.Context, routeID string) (routepkg.Route, error)
}
type QQAdapter struct {
logger *slog.Logger
httpClient *http.Client
dialer *websocket.Dialer
apiBaseURL string
tokenURL string
mu sync.Mutex
clients map[string]*qqClient
sessions map[string]sessionState
assets assetOpener
identity channelIdentityResolver
routes routeResolver
}
func NewQQAdapter(log *slog.Logger) *QQAdapter {
if log == nil {
log = slog.Default()
}
return &QQAdapter{
logger: log.With(slog.String("adapter", "qq")),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
dialer: &websocket.Dialer{
HandshakeTimeout: 15 * time.Second,
},
apiBaseURL: defaultAPIBaseURL,
tokenURL: qqOAuthEndpoint,
clients: make(map[string]*qqClient),
sessions: make(map[string]sessionState),
}
}
func (*QQAdapter) Type() channel.ChannelType {
return Type
}
func (*QQAdapter) Descriptor() channel.Descriptor {
return channel.Descriptor{
Type: Type,
DisplayName: "QQ",
Capabilities: channel.ChannelCapabilities{
Text: true,
Markdown: true,
Attachments: true,
Media: true,
Reply: true,
BlockStreaming: true,
ChatTypes: []string{channel.ConversationTypePrivate, channel.ConversationTypeGroup, channel.ConversationTypeThread},
},
OutboundPolicy: channel.OutboundPolicy{
TextChunkLimit: defaultChunkLimit,
ChunkerMode: channel.ChunkerModeMarkdown,
MediaOrder: channel.OutboundOrderTextFirst,
InlineTextWithMedia: true,
},
ConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"appId": {
Type: channel.FieldString,
Required: true,
Title: "App ID",
},
"clientSecret": {
Type: channel.FieldSecret,
Required: true,
Title: "Client Secret",
},
"markdownSupport": {
Type: channel.FieldBool,
Title: "Markdown Support",
Description: "Enable QQ markdown message mode for C2C and group replies when the bot has permission.",
},
"enableInputHint": {
Type: channel.FieldBool,
Title: "Input Hint",
Description: "Send QQ input-notify hints for direct messages while the bot is processing.",
},
},
},
UserConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"target_type": {
Type: channel.FieldEnum,
Required: true,
Title: "Target Type",
Enum: []string{"c2c", "group", "channel"},
},
"target_id": {
Type: channel.FieldString,
Required: true,
Title: "Target ID",
},
},
},
TargetSpec: channel.TargetSpec{
Format: "c2c:<openid> | group:<group_openid> | channel:<channel_id>",
Hints: []channel.TargetHint{
{Label: "Direct", Example: "c2c:00112233445566778899AABBCCDDEEFF"},
{Label: "Group", Example: "group:00112233445566778899AABBCCDDEEFF"},
{Label: "Channel", Example: "channel:1234567890"},
},
},
}
}
func (*QQAdapter) NormalizeConfig(raw map[string]any) (map[string]any, error) {
return normalizeConfig(raw)
}
func (*QQAdapter) NormalizeUserConfig(raw map[string]any) (map[string]any, error) {
return normalizeUserConfig(raw)
}
func (*QQAdapter) NormalizeTarget(raw string) string {
return normalizeTarget(raw)
}
func (*QQAdapter) ResolveTarget(userConfig map[string]any) (string, error) {
return resolveTarget(userConfig)
}
func (*QQAdapter) MatchBinding(config map[string]any, criteria channel.BindingCriteria) bool {
return matchBinding(config, criteria)
}
func (*QQAdapter) BuildUserConfig(identity channel.Identity) map[string]any {
return buildUserConfig(identity)
}
func (a *QQAdapter) SetAssetOpener(opener assetOpener) {
a.mu.Lock()
defer a.mu.Unlock()
a.assets = opener
}
func (a *QQAdapter) SetChannelIdentityResolver(resolver channelIdentityResolver) {
a.mu.Lock()
defer a.mu.Unlock()
a.identity = resolver
}
func (a *QQAdapter) SetRouteResolver(resolver routeResolver) {
a.mu.Lock()
defer a.mu.Unlock()
a.routes = resolver
}
func (a *QQAdapter) ProcessingStarted(ctx context.Context, cfg channel.ChannelConfig, _ channel.InboundMessage, info channel.ProcessingStatusInfo) (channel.ProcessingStatusHandle, error) {
parsed, err := parseConfig(cfg.Credentials)
if err != nil {
return channel.ProcessingStatusHandle{}, err
}
if !parsed.EnableInputHint || strings.TrimSpace(info.SourceMessageID) == "" {
return channel.ProcessingStatusHandle{}, nil
}
target, err := parseTarget(info.ReplyTarget)
if err != nil || target.Kind != qqTargetC2C {
return channel.ProcessingStatusHandle{}, nil
}
client := a.getOrCreateClient(cfg, parsed)
if err := client.sendInputHint(ctx, target.ID, info.SourceMessageID); err != nil {
return channel.ProcessingStatusHandle{}, err
}
return channel.ProcessingStatusHandle{}, nil
}
func (*QQAdapter) ProcessingCompleted(context.Context, channel.ChannelConfig, channel.InboundMessage, channel.ProcessingStatusInfo, channel.ProcessingStatusHandle) error {
return nil
}
func (*QQAdapter) ProcessingFailed(context.Context, channel.ChannelConfig, channel.InboundMessage, channel.ProcessingStatusInfo, channel.ProcessingStatusHandle, error) error {
return nil
}
func (a *QQAdapter) getOrCreateClient(cfg channel.ChannelConfig, parsed Config) *qqClient {
channel.SetIMErrorSecrets("qq:"+parsed.AppID, parsed.AppSecret)
a.mu.Lock()
defer a.mu.Unlock()
existing, ok := a.clients[cfg.ID]
if ok && existing.matches(parsed) {
return existing
}
client := &qqClient{
appID: parsed.AppID,
clientSecret: parsed.AppSecret,
httpClient: a.httpClient,
logger: a.logger,
apiBaseURL: a.apiBaseURL,
tokenURL: a.tokenURL,
msgSeq: make(map[string]int),
}
a.clients[cfg.ID] = client
return client
}
func (a *QQAdapter) loadSession(configID string) sessionState {
a.mu.Lock()
defer a.mu.Unlock()
return a.sessions[configID]
}
func (a *QQAdapter) saveSession(configID string, state sessionState) {
a.mu.Lock()
defer a.mu.Unlock()
a.sessions[configID] = state
}
func (a *QQAdapter) clearSession(configID string) {
a.mu.Lock()
defer a.mu.Unlock()
delete(a.sessions, configID)
}