fix: platform_silent记忆提取 + 群聊上下文整合 + 多QQ实例支持

- platform_silent模式接入Orchestrator记忆提取:被动观察群聊时提取值得记住的信息到对应命名空间
- post_chat后台思考注入平台观察:对话后思考也能看到群聊摘要
- QQ适配器:OneBot v11 self_id动态捕获、CQ图片URL提取、视觉+OCR并行处理
- Router解耦:ConfigName/PlatformName分离,支持多QQ实例独立连接
- 黑白名单功能:后端API + Ethend代理 + UI面板
- \n\n双换行断句:AI回复按双换行分割为多条消息按间隔发送
- @提及修复:bot自感知UID进行@检测
- 群聊上下文共享:channel-based userID避免记忆碎片化
- 消息日志显示处理后内容而非原始SSE数据
- platform-bridge Dockerfile + docker-compose.yml更新

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 09:37:18 +08:00
parent 71f0a1abdb
commit 47dce276a4
22 changed files with 2375 additions and 313 deletions
@@ -0,0 +1,150 @@
package config
import (
"encoding/json"
"fmt"
"os"
"sync"
)
// BlocklistMode is either "blacklist" or "whitelist".
type BlocklistSettings struct {
Mode string `json:"mode"` // "blacklist" (default) or "whitelist"
GroupIDs []string `json:"group_ids"` // group IDs to block/allow
UserIDs []string `json:"user_ids"` // private chat user IDs to block/allow
}
// BlocklistStore manages persistence of blocklist settings.
type BlocklistStore struct {
mu sync.RWMutex
path string
settings BlocklistSettings
}
// NewBlocklistStore loads or creates blocklist settings file.
func NewBlocklistStore(path string) (*BlocklistStore, error) {
s := &BlocklistStore{
path: path,
settings: BlocklistSettings{
Mode: "blacklist",
GroupIDs: []string{},
UserIDs: []string{},
},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *BlocklistStore) load() error {
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return s.save() // write defaults
}
return fmt.Errorf("read blocklist file: %w", err)
}
if len(data) == 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if err := json.Unmarshal(data, &s.settings); err != nil {
return fmt.Errorf("parse blocklist file: %w", err)
}
if s.settings.Mode == "" {
s.settings.Mode = "blacklist"
}
if s.settings.GroupIDs == nil {
s.settings.GroupIDs = []string{}
}
if s.settings.UserIDs == nil {
s.settings.UserIDs = []string{}
}
return nil
}
func (s *BlocklistStore) save() error {
s.mu.RLock()
data, err := json.MarshalIndent(s.settings, "", " ")
s.mu.RUnlock()
if err != nil {
return fmt.Errorf("marshal blocklist: %w", err)
}
tmpPath := s.path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0640); err != nil {
return fmt.Errorf("write blocklist: %w", err)
}
return os.Rename(tmpPath, s.path)
}
// Get returns current blocklist settings.
func (s *BlocklistStore) Get() BlocklistSettings {
s.mu.RLock()
defer s.mu.RUnlock()
cp := BlocklistSettings{
Mode: s.settings.Mode,
GroupIDs: make([]string, len(s.settings.GroupIDs)),
UserIDs: make([]string, len(s.settings.UserIDs)),
}
copy(cp.GroupIDs, s.settings.GroupIDs)
copy(cp.UserIDs, s.settings.UserIDs)
return cp
}
// Set updates and persists blocklist settings.
func (s *BlocklistStore) Set(bs BlocklistSettings) error {
if bs.Mode != "blacklist" && bs.Mode != "whitelist" {
return fmt.Errorf("invalid mode: %s (must be blacklist or whitelist)", bs.Mode)
}
if bs.GroupIDs == nil {
bs.GroupIDs = []string{}
}
if bs.UserIDs == nil {
bs.UserIDs = []string{}
}
s.mu.Lock()
s.settings = bs
s.mu.Unlock()
return s.save()
}
// IsBlocked checks whether a message should be blocked based on channel type and ID.
// In blacklist mode: returns true if the id is IN the list.
// In whitelist mode: returns true if the id is NOT in the list.
// Admin users should call this with isAdmin=true to always bypass.
func (s *BlocklistStore) IsBlocked(channelType, channelID, senderID string, isAdmin bool) bool {
if isAdmin {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
switch s.settings.Mode {
case "whitelist":
// Block if NOT in the whitelist.
if channelType == "group" {
return !contains(s.settings.GroupIDs, channelID)
}
return !contains(s.settings.UserIDs, senderID)
case "blacklist":
fallthrough
default:
// Block if IN the blacklist.
if channelType == "group" {
return contains(s.settings.GroupIDs, channelID)
}
return contains(s.settings.UserIDs, senderID)
}
}
func contains(list []string, val string) bool {
for _, v := range list {
if v == val {
return true
}
}
return false
}
@@ -1,6 +1,9 @@
package config
import "os"
import (
"os"
"strings"
)
// Config holds Platform Bridge configuration.
type Config struct {
@@ -11,9 +14,17 @@ type Config struct {
InternalToken string
// Platform-specific.
QQBotPort string // port for QQ OBv11 reverse WebSocket
TelegramToken string // Telegram Bot API token
TelegramWebhookURL string // public webhook URL for Telegram
QQBotPort string // port for QQ OBv11 reverse WebSocket
TelegramToken string // Telegram Bot API token
TelegramWebhookURL string // public webhook URL for Telegram
// Silent observation mode.
PlatformSilentEnabled bool // PLATFORM_SILENT_ENABLED, default true
AdminNicknames []string // ADMIN_NICKNAMES, default ["开拓者"]
AdminMentionKeywords []string // ADMIN_MENTION_KEYWORDS, default ["昔涟","Cyrene","管理员"]
// Message sending.
MessageSendIntervalMs int // MSG_SEND_INTERVAL_MS, minimum interval between platform messages (default 2000)
}
func Load() *Config {
@@ -48,5 +59,54 @@ func Load() *Config {
if v := os.Getenv("TELEGRAM_WEBHOOK_URL"); v != "" {
cfg.TelegramWebhookURL = v
}
// Silent observation defaults.
cfg.PlatformSilentEnabled = getEnvBool("PLATFORM_SILENT_ENABLED", true)
cfg.AdminNicknames = getEnvList("ADMIN_NICKNAMES", []string{"开拓者"})
cfg.AdminMentionKeywords = getEnvList("ADMIN_MENTION_KEYWORDS", []string{"昔涟", "Cyrene", "管理员"})
cfg.MessageSendIntervalMs = getEnvInt("MSG_SEND_INTERVAL_MS", 2000)
return cfg
}
func getEnvBool(key string, defaultVal bool) bool {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
return v == "true" || v == "1" || v == "yes"
}
func getEnvInt(key string, defaultVal int) int {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
n := 0
for _, c := range v {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
} else {
return defaultVal
}
}
return n
}
func getEnvList(key string, defaultVal []string) []string {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
parts := strings.Split(v, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
if len(result) == 0 {
return defaultVal
}
return result
}
@@ -11,6 +11,7 @@ import (
// PlatformConfig holds persistent configuration for one platform adapter.
type PlatformConfig struct {
Name string `json:"name"`
Platform string `json:"platform"` // base platform type: "qq", "telegram", etc.
Enabled bool `json:"enabled"`
Label string `json:"label"`
Fields map[string]string `json:"fields"`
@@ -51,6 +52,12 @@ func (s *Store) load() error {
if err := json.Unmarshal(data, &s.configs); err != nil {
return fmt.Errorf("parse config file: %w", err)
}
// Backward compat: old configs without platform field default to Name.
for _, c := range s.configs {
if c.Platform == "" {
c.Platform = c.Name
}
}
return nil
}