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

172 lines
4.7 KiB
Go

package qq
import (
"errors"
"strings"
"github.com/memohai/memoh/internal/channel"
)
type Config struct {
AppID string
AppSecret string
MarkdownSupport bool
EnableInputHint bool
}
type UserConfig struct {
TargetType string
TargetID string
}
func normalizeConfig(raw map[string]any) (map[string]any, error) {
cfg, err := parseConfig(raw)
if err != nil {
return nil, err
}
return map[string]any{
"appId": cfg.AppID,
"clientSecret": cfg.AppSecret,
"markdownSupport": cfg.MarkdownSupport,
"enableInputHint": cfg.EnableInputHint,
}, nil
}
func normalizeUserConfig(raw map[string]any) (map[string]any, error) {
cfg, err := parseUserConfig(raw)
if err != nil {
return nil, err
}
return map[string]any{
"target_type": cfg.TargetType,
"target_id": cfg.TargetID,
}, nil
}
func resolveTarget(raw map[string]any) (string, error) {
cfg, err := parseUserConfig(raw)
if err != nil {
return "", err
}
return cfg.TargetType + ":" + cfg.TargetID, nil
}
func matchBinding(raw map[string]any, criteria channel.BindingCriteria) bool {
cfg, err := parseUserConfig(raw)
if err != nil {
return false
}
subjectID := strings.TrimSpace(criteria.SubjectID)
if cfg.TargetType == "c2c" && subjectID != "" && subjectID == cfg.TargetID {
return true
}
if cfg.TargetType == "c2c" && strings.TrimSpace(criteria.Attribute("user_openid")) == cfg.TargetID {
return true
}
if cfg.TargetType == "group" && strings.TrimSpace(criteria.Attribute("group_openid")) == cfg.TargetID {
return true
}
if cfg.TargetType == "channel" && strings.TrimSpace(criteria.Attribute("channel_id")) == cfg.TargetID {
return true
}
return false
}
func buildUserConfig(identity channel.Identity) map[string]any {
targetID := strings.TrimSpace(identity.Attribute("user_openid"))
if targetID == "" {
targetID = strings.TrimSpace(identity.SubjectID)
}
if targetID == "" {
return map[string]any{}
}
return map[string]any{
"target_type": "c2c",
"target_id": targetID,
}
}
func parseConfig(raw map[string]any) (Config, error) {
appID := strings.TrimSpace(channel.ReadString(raw, "appId", "app_id"))
clientSecret := strings.TrimSpace(channel.ReadString(raw, "clientSecret", "client_secret"))
if appID == "" {
return Config{}, errors.New("qq appId is required")
}
if clientSecret == "" {
return Config{}, errors.New("qq clientSecret is required")
}
return Config{
AppID: appID,
AppSecret: clientSecret,
MarkdownSupport: readBool(raw, true, "markdownSupport", "markdown_support"),
EnableInputHint: readBool(raw, true, "enableInputHint", "enable_input_hint"),
}, nil
}
func parseUserConfig(raw map[string]any) (UserConfig, error) {
targetType := strings.ToLower(strings.TrimSpace(channel.ReadString(raw, "targetType", "target_type")))
targetID := strings.TrimSpace(channel.ReadString(raw, "targetId", "target_id"))
if targetType == "" || targetID == "" {
switch {
case strings.TrimSpace(channel.ReadString(raw, "userOpenid", "user_openid")) != "":
targetType = "c2c"
targetID = strings.TrimSpace(channel.ReadString(raw, "userOpenid", "user_openid"))
case strings.TrimSpace(channel.ReadString(raw, "groupOpenid", "group_openid")) != "":
targetType = "group"
targetID = strings.TrimSpace(channel.ReadString(raw, "groupOpenid", "group_openid"))
case strings.TrimSpace(channel.ReadString(raw, "channelId", "channel_id")) != "":
targetType = "channel"
targetID = strings.TrimSpace(channel.ReadString(raw, "channelId", "channel_id"))
}
}
if targetType == "" || targetID == "" {
return UserConfig{}, errors.New("qq user config requires target_type and target_id")
}
switch targetType {
case "c2c", "group", "channel":
default:
return UserConfig{}, errors.New("qq target_type must be c2c, group, or channel")
}
return UserConfig{TargetType: targetType, TargetID: targetID}, nil
}
func normalizeTarget(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
for _, prefix := range []string{"qq:", "qqbot:"} {
if strings.HasPrefix(strings.ToLower(value), prefix) {
value = strings.TrimSpace(value[len(prefix):])
break
}
}
for _, targetType := range []string{"c2c:", "group:", "channel:"} {
if strings.HasPrefix(strings.ToLower(value), targetType) {
return strings.ToLower(targetType[:len(targetType)-1]) + ":" + strings.TrimSpace(value[len(targetType):])
}
}
return "c2c:" + value
}
func readBool(raw map[string]any, fallback bool, keys ...string) bool {
for _, key := range keys {
value, ok := raw[key]
if !ok {
continue
}
switch v := value.(type) {
case bool:
return v
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
}
}
}
return fallback
}