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
+16
View File
@@ -734,6 +734,22 @@ func handleChat(
req.Mode = "text"
}
// 平台静默观察模式:只记录消息、提取记忆、触发后台思考,不生成回复。
if req.Mode == "platform_silent" {
if thinker != nil {
thinker.RecordUserMessage(req.SessionID)
}
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, req.Message)
// 从观察到的群聊消息中提取记忆。
orch.ExtractMemoriesOnly(r.Context(), req.UserID, req.SessionID, req.Message)
if thinker != nil {
thinker.TriggerPostChatThink()
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"silent_processed"}`))
return
}
ctx := r.Context()
// 0. 记录用户活动(重置闲置计时器)
+190 -9
View File
@@ -29,6 +29,38 @@ type PendingThought struct {
Consumed bool `json:"consumed"`
}
// PlatformChannel represents a platform channel to observe for background thinking.
type PlatformChannel struct {
Platform string // qq, telegram, etc.
ChannelType string // group, private
ChannelID string // group ID or user QQ number
}
// ParsePlatformChannels parses PLATFORM_CHANNELS env var.
// Format: "qq:group:123456,telegram:group:789012"
func ParsePlatformChannels(raw string) []PlatformChannel {
if raw == "" {
return nil
}
var channels []PlatformChannel
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
fields := strings.SplitN(part, ":", 3)
if len(fields) != 3 {
continue
}
channels = append(channels, PlatformChannel{
Platform: strings.TrimSpace(fields[0]),
ChannelType: strings.TrimSpace(fields[1]),
ChannelID: strings.TrimSpace(fields[2]),
})
}
return channels
}
// Thinker 后台思考器(事件驱动 + 定时周期双模式)
//
// 触发机制:
@@ -128,6 +160,10 @@ type Thinker struct {
// 时区设置 (默认 Asia/Shanghai,可通过 TZ 环境变量覆盖)
timeLocation *time.Location
// 平台静默观察
platformChannels []PlatformChannel
platformThinkInterval time.Duration
}
// AutonomousToolPolicy 自主思考工具调用安全策略
@@ -213,6 +249,10 @@ type ThinkerConfig struct {
PostChatDelay time.Duration // 对话后多久触发思考
MinThinkGap time.Duration // 两次思考最小间隔 (在线)
OfflineThinkGap time.Duration // 两次思考最小间隔 (离线,默认 10 分钟)
// 平台静默观察
PlatformSilentThinkInterval time.Duration // 平台记忆观察间隔 (默认 600s,0 = 禁用)
PlatformChannels []PlatformChannel
}
// DefaultThinkerConfig 默认配置
@@ -232,6 +272,8 @@ func DefaultThinkerConfig() ThinkerConfig {
PostChatDelay: getEnvDuration("THINK_POST_CHAT_DELAY_SEC", 5),
MinThinkGap: getEnvDuration("THINK_MIN_GAP_SEC", 30),
OfflineThinkGap: getEnvDuration("THINK_OFFLINE_GAP_SEC", 600),
PlatformSilentThinkInterval: getEnvDuration("PLATFORM_THINK_INTERVAL_SEC", 600),
PlatformChannels: ParsePlatformChannels(os.Getenv("PLATFORM_CHANNELS")),
}
}
@@ -282,12 +324,14 @@ func NewThinker(
adminUserID: adminUserID,
adminSessionID: adminSessionID,
memClient: memClient,
pendingThoughts: make([]*PendingThought, 0),
lastUserMessage: time.Now(),
stopCh: make(chan struct{}),
chain: NewThinkChain(10),
autoToolPolicy: DefaultAutonomousToolPolicy(),
proactiveGuard: DefaultProactiveGuard(),
pendingThoughts: make([]*PendingThought, 0),
lastUserMessage: time.Now(),
stopCh: make(chan struct{}),
chain: NewThinkChain(10),
autoToolPolicy: DefaultAutonomousToolPolicy(),
proactiveGuard: DefaultProactiveGuard(),
platformChannels: cfg.PlatformChannels,
platformThinkInterval: cfg.PlatformSilentThinkInterval,
}
}
@@ -314,8 +358,14 @@ func (t *Thinker) Start() {
go t.periodicThinkLoop()
}
log.Printf("[后台思考] 已就绪 — 周期=%v + 事件驱动模式 (静默超时=%v, 对话后延迟=%v, 在线最小间隔=%v, 离线最小间隔=%v, 管理员=%s)",
t.thinkInterval, t.silenceTimeout, t.postChatDelay, t.minThinkGap, t.offlineThinkGap, t.adminUserID)
// 启动平台静默观察循环
if len(t.platformChannels) > 0 && t.platformThinkInterval > 0 {
t.wg.Add(1)
go t.platformObservationLoop()
}
log.Printf("[后台思考] 已就绪 — 周期=%v + 事件驱动模式 (静默超时=%v, 对话后延迟=%v, 在线最小间隔=%v, 离线最小间隔=%v, 管理员=%s, 平台频道=%d)",
t.thinkInterval, t.silenceTimeout, t.postChatDelay, t.minThinkGap, t.offlineThinkGap, t.adminUserID, len(t.platformChannels))
// 启动后首次思考:延迟 5s,让服务完全初始化后再触发
go func() {
@@ -460,6 +510,116 @@ func (t *Thinker) resetSilenceTimer() {
}()
}
// platformObservationLoop periodically queries platform channel memories and generates observations.
func (t *Thinker) platformObservationLoop() {
defer t.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 平台观察循环 panic 恢复: %v", r)
}
}()
interval := t.platformThinkInterval
log.Printf("[后台思考] 平台观察循环已启动 (间隔=%v, 频道数=%d)", interval, len(t.platformChannels))
for {
select {
case <-t.stopCh:
log.Println("[后台思考] 平台观察循环已停止")
return
case <-time.After(interval):
t.performPlatformObservation()
}
}
}
// performPlatformObservation queries memories from all platform channels,
// runs an intermediate LLM session to summarize, and stores the result as a pending thought.
func (t *Thinker) performPlatformObservation() {
if t.memClient == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var channelSummaries []string
for _, ch := range t.platformChannels {
namespace := fmt.Sprintf("platform_%s_%s_%s", ch.Platform, ch.ChannelType, ch.ChannelID)
memories, err := t.memClient.Query(ctx, model.MemoryQuery{
UserID: namespace,
Limit: 20,
})
if err != nil {
log.Printf("[后台思考] 查询平台频道 %s 记忆失败: %v", namespace, err)
continue
}
if len(memories) == 0 {
continue
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("【%s %s %s】\n", ch.Platform, ch.ChannelType, ch.ChannelID))
for i, m := range memories {
if i >= 10 {
sb.WriteString(fmt.Sprintf("... 还有 %d 条记忆\n", len(memories)-10))
break
}
sb.WriteString(fmt.Sprintf("- %s\n", m.Content))
}
channelSummaries = append(channelSummaries, sb.String())
}
if len(channelSummaries) == 0 {
return
}
log.Printf("[后台思考] 平台观察:%d 个频道有记忆数据,调用中间会话生成摘要...", len(channelSummaries))
systemPrompt := "你是昔涟的后台观察助手。以下是各聊天平台频道最近的观察摘要。\n请生成简洁报告:\n1. 各频道近期讨论主题(每频道1-2句)\n2. 是否有需要开拓者关注的重要/紧急事项\n3. 整体氛围评估\n不要直接对开拓者说话,这是给昔涟参考的幕后报告。\n输出为JSON格式:{\"summary\": \"报告内容\", \"needs_attention\": true/false}"
userPrompt := strings.Join(channelSummaries, "\n\n")
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
{Role: model.RoleUser, Content: userPrompt},
}
resp, err := t.toolAdapter.Chat(ctx, messages)
if err != nil {
log.Printf("[后台思考] 中间会话 LLM 调用失败: %v", err)
return
}
var result struct {
Summary string `json:"summary"`
NeedsAttention bool `json:"needs_attention"`
}
content := strings.TrimSpace(resp.Content)
if idx := strings.Index(content, "{"); idx >= 0 {
if end := strings.LastIndex(content, "}"); end > idx {
content = content[idx : end+1]
}
}
if err := json.Unmarshal([]byte(content), &result); err != nil {
result.Summary = resp.Content
}
observationContent := fmt.Sprintf("[平台观察 %s]\n%s", time.Now().In(t.timeLocation).Format("15:04"), result.Summary)
t.mu.Lock()
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
Content: observationContent,
CreatedAt: time.Now(),
Consumed: false,
})
if len(t.pendingThoughts) > 10 {
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
}
t.mu.Unlock()
log.Printf("[后台思考] 平台观察摘要已生成 (长度=%d, 需要关注=%v)", len(result.Summary), result.NeedsAttention)
}
// periodicThinkLoop 周期性自主思考循环
//
// 使用动态间隔:若配置了 ScheduleLoader,每次循环根据当前时段计算间隔;
@@ -639,9 +799,22 @@ func (t *Thinker) performThink(triggerReason string) {
}
}
// 4.5 获取最近平台观察(定期触发和对话后触发时注入)
var platformObservation string
if triggerReason == "periodic" || triggerReason == "post_chat" {
t.mu.Lock()
for i := len(t.pendingThoughts) - 1; i >= 0; i-- {
if strings.HasPrefix(t.pendingThoughts[i].Content, "[平台观察") {
platformObservation = t.pendingThoughts[i].Content
break
}
}
t.mu.Unlock()
}
// 5. 构建思考提示词(根据触发原因调整)
systemPrompt := t.buildThinkingSystemPrompt(personaConfig, triggerReason)
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary, triggerReason)
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary, triggerReason, platformObservation)
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
@@ -895,6 +1068,7 @@ func (t *Thinker) buildThinkingUserPrompt(
convHistory []model.LLMMessage,
deviceSummary string,
triggerReason string,
platformObservation string,
) string {
var sb strings.Builder
@@ -1009,6 +1183,13 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("\n" + deviceSummary)
}
// 平台观察摘要 (中间会话产生的报告)
if platformObservation != "" {
sb.WriteString("\n\n【平台频道观察报告(中间会话生成,供参考)】\n")
sb.WriteString(platformObservation)
sb.WriteString("\n")
}
// 结尾引导
sb.WriteString("\n---\n现在请写下你的私人反思。")
sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。")
+3 -2
View File
@@ -163,8 +163,9 @@ type BuildParams struct {
Memories []memory.MemoryEntry
HistoryLimit int
DeviceContext string // 注入的设备状态文本
PendingThoughts []string // 待注入的后台思考
Nickname string // 用户昵称 (昔涟对用户的称呼)
PendingThoughts []string // 待注入的后台思考
PlatformObservationSummary string // 平台观察摘要(中间会话生成)
Nickname string // 用户昵称 (昔涟对用户的称呼)
}
// Build 构建发送给LLM的完整消息列表
+76 -5
View File
@@ -34,16 +34,28 @@ func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, user
logger.Printf("[memory] 记忆提取失败: %v", err)
return
}
e.storeMemories(ctx, userID, sessionID, memories)
}
// ExtractObservations 从观察到的单条消息中提取记忆(无语境回复)。
// 用于 platform_silent 模式:昔涟被动观察群聊,提取值得记住的信息。
func (e *Extractor) ExtractObservations(ctx context.Context, userID, sessionID, message string) {
memories, err := e.extractObservations(ctx, message)
if err != nil {
logger.Printf("[memory] 观察记忆提取失败: %v", err)
return
}
e.storeMemories(ctx, userID, sessionID, memories)
}
func (e *Extractor) storeMemories(ctx context.Context, userID, sessionID string, memories []model.MemoryEntry) {
for _, mem := range memories {
mem.UserID = userID
mem.SessionID = sessionID
mem.Source = "conversation"
// 去重检查:查询用户已有的相关记忆
existing, err := e.findSimilar(ctx, userID, &mem)
if err == nil && existing != nil {
// 相似度 > 80%,更新现有记忆
e.mergeMemory(ctx, existing, &mem)
continue
}
@@ -56,6 +68,58 @@ func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, user
}
}
// extractObservations 从观察到的消息中提取记忆(无助手回复)
func (e *Extractor) extractObservations(ctx context.Context, message string) ([]model.MemoryEntry, error) {
if e.llmChat != nil {
return e.extractObservationsWithLLM(ctx, message)
}
return e.extractWithRules(message, ""), nil
}
// extractObservationsWithLLM 使用LLM从观察到的消息中提取值得记住的信息
func (e *Extractor) extractObservationsWithLLM(ctx context.Context, message string) ([]model.MemoryEntry, error) {
prompt := fmt.Sprintf(`分析以下在聊天平台观察到的消息,提取值得记住的信息作为记忆。
观察到的消息: %s
请以JSON格式返回提取的记忆。这条消息来自群聊/频道,昔涟只是旁观者。
提取角度:这条消息中包含了什么关于聊天参与者、讨论主题、事件或氛围的信息?
每条记忆需要包含以下字段:
- content: 完整的记忆内容(一句话描述,客观准确)
- summary: 简短摘要(10字以内)
- category: 记忆分类,必须是以下之一:
* conversation: 对话主题/讨论摘要
* event: 事件记录(发生了什么)
* personal_info: 参与者的个人信息
* knowledge: 知识性信息
* user_preference: 某人的偏好
* task: 提及的计划/任务
- priority: 优先级 (0=临时, 1=普通, 2=重要, 3=核心)
- importance: 重要程度 1-10
* 1-3: 日常闲聊,不太重要
* 4-6: 一般有用的信息
* 7-8: 重要信息,值得长期记住
* 9-10: 核心信息
- keywords: 关键词标签数组(3-5个词)
只提取有意义的信息。如果消息只是日常寒暄或无实质内容,返回空数组。
输出格式:
{"memories": [{"content": "...", "summary": "...", "category": "...", "priority": 1, "importance": 6, "keywords": ["词1", "词2"]}]}
`, message)
resp, err := e.llmChat(ctx, []model.LLMMessage{
{Role: "system", Content: "你是一个聊天观察记录助手。你只输出JSON格式的结果。你的任务是从观察到的聊天消息中提取值得记住的信息。"},
{Role: "user", Content: prompt},
})
if err != nil {
return nil, fmt.Errorf("LLM提取观察记忆失败: %w", err)
}
return e.parseExtractionResult(resp.Content)
}
// extract 从对话中提取记忆
func (e *Extractor) extract(ctx context.Context, userMessage, assistantResponse string) ([]model.MemoryEntry, error) {
// 如果有LLM,使用LLM提取
@@ -128,11 +192,18 @@ func (e *Extractor) extractWithLLM(ctx context.Context, userMessage, assistantRe
return nil, fmt.Errorf("LLM提取记忆失败: %w", err)
}
// 解析JSON
entries, err := e.parseExtractionResult(resp.Content)
if err != nil {
return nil, err
}
return entries, nil
}
// parseExtractionResult 解析LLM返回的记忆提取JSON结果
func (e *Extractor) parseExtractionResult(text string) ([]model.MemoryEntry, error) {
result := MemoryExtractionResult{}
content := extractJSON(resp.Content)
content := extractJSON(text)
if err := json.Unmarshal([]byte(content), &result); err != nil {
// 尝试作为数组解析(兼容旧格式)
var arrResult []ExtractedMemory
if err2 := json.Unmarshal([]byte(content), &arrResult); err2 != nil {
return nil, fmt.Errorf("解析记忆JSON失败: %w (原始: %s)", err, content[:minint(len(content), 100)])
@@ -500,6 +500,15 @@ func (o *Orchestrator) ProcessInput(
return eventCh, nil
}
// ExtractMemoriesOnly 仅提取记忆,不生成回复。
// 用于 platform_silent 模式:观察群聊消息并提取值得记住的信息到对应命名空间。
func (o *Orchestrator) ExtractMemoriesOnly(ctx context.Context, userID, sessionID, message string) {
if o.memoryExtractor == nil {
return
}
o.memoryExtractor.ExtractObservations(ctx, userID, sessionID, message)
}
// scheduleWithDelays 通过 MessageScheduler 为审查消息分配发送延迟
func (o *Orchestrator) scheduleWithDelays(messages []model.ReviewMessage) []model.ReviewMessage {
if o.msgScheduler == nil || len(messages) <= 1 {
+35
View File
@@ -0,0 +1,35 @@
# ========== 构建阶段 ==========
FROM golang:1.26-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY backend/platform-bridge/ ./backend/platform-bridge/
WORKDIR /app/backend/platform-bridge
ENV GOPROXY=https://goproxy.cn,direct
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /platform-bridge ./cmd/main.go
# ========== 运行阶段 ==========
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
WORKDIR /app
COPY --from=builder /platform-bridge .
RUN mkdir -p logs && adduser -D -H cyrene && chown -R cyrene:cyrene /app
USER cyrene
EXPOSE 8095
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8095/health || exit 1
ENTRYPOINT ["./platform-bridge"]
+625 -128
View File
@@ -5,9 +5,12 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
@@ -34,6 +37,13 @@ func main() {
os.Exit(1)
}
// Blocklist settings.
blocklistStore, err := config.NewBlocklistStore("platform_blocklist.json")
if err != nil {
fmt.Printf("FATAL: blocklist store: %v\n", err)
os.Exit(1)
}
// Message logger.
msgLogger, err := logging.NewLogger("logs")
if err != nil {
@@ -48,7 +58,7 @@ func main() {
router := bridge.NewPlatformRouter(mapper, checker)
// Seed default identities from environment.
seedIdentities(mapper)
seedIdentities(mapper, configStore)
// Register platform adapters based on stored configs or defaults.
adapters := createAdapters(cfg, configStore)
@@ -64,7 +74,7 @@ func main() {
Direction: "incoming",
Platform: msg.Platform,
ChannelID: msg.ChannelID,
SenderID: msg.SenderID,
SenderID: msg.OriginalSenderUID,
SenderName: msg.SenderName,
Content: msg.Content,
ContentType: msg.ContentType,
@@ -72,33 +82,122 @@ func main() {
Success: true,
})
response, err := forwardToAICore(cfg, msg)
if err != nil {
// Routing decisions.
isAdmin := mapper.IsAdmin(msg.Platform, msg.OriginalSenderUID)
isMentioned, mentionReason := detectAdminMention(msg, mapper, cfg)
isBotMentioned := msg.BotUID != "" && containsString(msg.Mentions, msg.BotUID)
isSilent := cfg.PlatformSilentEnabled && !isAdmin && !isBotMentioned
// Blocklist/whitelist check (admin always bypasses).
if blocked := blocklistStore.IsBlocked(msg.ChannelType, msg.ChannelID, msg.OriginalSenderUID, isAdmin); blocked {
msgLogger.Log(logging.LogEntry{
Timestamp: time.Now(),
Direction: "outgoing",
Platform: msg.Platform,
ChannelID: msg.ChannelID,
SenderID: msg.SenderID,
Success: false,
Error: err.Error(),
SenderID: msg.OriginalSenderUID,
SenderName: "Cyrene",
Content: "[blocked]",
Success: true,
})
return nil, err
return &bridge.UnifiedResponse{
Messages: []bridge.ResponseMessage{
{DisplayType: "silent", Content: "", FormatMode: "plain"},
},
Platform: msg.Platform,
}, nil
}
// Log outgoing.
for _, rm := range response.Messages {
var response *bridge.UnifiedResponse
var routeErr error
// Extract image URLs for vision/OCR processing (admin + bot-mentioned + admin-mentioned only).
imageURLs := getImageURLs(msg)
includeImages := isAdmin || isBotMentioned || isMentioned
// For group chats, use a channel-based user ID to share context between admin and regular users.
chatUserID := msg.SenderID
if msg.ChannelType == "group" {
chatUserID = fmt.Sprintf("platform_%s_group_%s", msg.Platform, msg.ChannelID)
}
groupSessionID := fmt.Sprintf("platform_%s_%s", msg.Platform, msg.ChannelID)
switch {
case isAdmin:
msg.RouteType = "normal"
response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, imageURLs)
case isBotMentioned:
msg.RouteType = "normal"
response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, imageURLs)
case isMentioned:
msg.RouteType = "admin_mention"
enhancedContent := fmt.Sprintf(
"[来自%s平台 %s 频道 %s 的用户 %s 说]\n%s\n\n[系统提示:此消息提及了管理员(原因:%s)。请参考以上消息内容判断是否需要关注。]",
msg.Platform, msg.ChannelType, msg.ChannelID, msg.SenderName, msg.Content, mentionReason,
)
originalContent := msg.Content
msg.Content = enhancedContent
if includeImages {
response, routeErr = forwardToAICore(cfg, msg, "text", "admin", "admin-session-main", imageURLs)
} else {
response, routeErr = forwardToAICore(cfg, msg, "text", "admin", "admin-session-main", nil)
}
msg.Content = originalContent
case isSilent:
msg.RouteType = "silent"
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
silentResponse, silentErr := forwardToAICore(cfg, msg, "platform_silent", namespace, namespace, nil)
if silentErr != nil {
msgLogger.Log(logging.LogEntry{
Timestamp: time.Now(),
Direction: "outgoing",
Platform: msg.Platform,
ChannelID: msg.ChannelID,
SenderID: msg.OriginalSenderUID,
Success: false,
Error: silentErr.Error(),
})
return nil, silentErr
}
response = silentResponse
routeErr = nil
default:
msg.RouteType = "normal"
response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, nil)
}
if routeErr != nil {
msgLogger.Log(logging.LogEntry{
Timestamp: time.Now(),
Direction: "outgoing",
Platform: msg.Platform,
ChannelID: msg.ChannelID,
SenderID: msg.SenderID,
SenderName: "Cyrene",
Content: rm.Content,
ContentType: "text",
Success: true,
Timestamp: time.Now(),
Direction: "outgoing",
Platform: msg.Platform,
ChannelID: msg.ChannelID,
SenderID: msg.OriginalSenderUID,
Success: false,
Error: routeErr.Error(),
})
return nil, routeErr
}
// Log outgoing messages (skip for silent route).
if msg.RouteType != "silent" {
for _, rm := range response.Messages {
msgLogger.Log(logging.LogEntry{
Timestamp: time.Now(),
Direction: "outgoing",
Platform: msg.Platform,
ChannelID: msg.ChannelID,
SenderID: msg.OriginalSenderUID,
SenderName: "Cyrene",
Content: rm.Content,
ContentType: "text",
Success: true,
})
}
}
return response, nil
@@ -121,31 +220,50 @@ func main() {
// Config and log handlers.
ch := handler.NewConfigHandler(configStore, router)
// Hot-reload: on config save/delete, dynamically replace adapters.
ch.SetOnConfigChanged(func(name, platform string, enabled bool, fields map[string]string) {
if enabled {
a := createSingleAdapter(cfg, platform, name, fields)
if a == nil {
fmt.Printf("WARN: cannot create adapter for %s (platform=%s)\n", name, platform)
return
}
if err := router.ReplaceAdapter(a); err != nil {
fmt.Printf("WARN: hot-reload connect %s failed: %v\n", name, err)
} else {
fmt.Printf("Platform adapter hot-reloaded: %s\n", name)
}
// Sync admin identities from config fields.
syncAdminUIDs(mapper, platform, fields)
// Restart QQ reader when QQ config changes.
if platform == "qq" {
startQQReaders(router)
}
} else {
router.RemoveAdapter(name)
fmt.Printf("Platform adapter removed: %s\n", name)
// Cancel reader goroutines for removed adapter.
if platform == "qq" {
startQQReaders(router)
}
}
})
ch.RegisterRoutes(mux)
lh := handler.NewLogHandler(msgLogger)
lh := handler.NewLogHandler(msgLogger, configStore)
lh.RegisterRoutes(mux)
// Log WebSocket for real-time log streaming to ethend.
logWS := handler.NewLogWSHub(msgLogger)
mux.HandleFunc("/ws/logs", logWS.ServeWS)
// Blocklist settings.
blh := handler.NewBlocklistHandler(blocklistStore)
blh.RegisterRoutes(mux)
// Start QQ message reader loop.
qq, _ := router.GetAdapter("qq")
if qqa, ok := qq.(*qqadapter.Adapter); ok {
qqMsgCh := make(chan *qqadapter.OBv11Message, 100)
go qqa.ReadMessages(ctx, qqMsgCh)
go func() {
for msg := range qqMsgCh {
response, err := router.RouteMessage("qq", msg)
if err != nil {
fmt.Printf("[qq] route error: %v\n", err)
continue
}
msgs, err := router.SendResponse(response)
if err != nil {
fmt.Printf("[qq] send error: %v\n", err)
continue
}
_ = msgs
}
}()
}
startQQReaders(router)
addr := ":" + cfg.Port
srv := &http.Server{Addr: addr, Handler: mux}
@@ -171,48 +289,121 @@ func main() {
fmt.Println("Platform Bridge stopped")
}
// createAdapters builds platform adapters, preferring stored configs over defaults.
func createAdapters(cfg *config.Config, store *config.Store) []bridge.PlatformAdapter {
allNames := []string{"qq", "telegram", "webhook", "wechat", "feishu", "discord"}
var adapters []bridge.PlatformAdapter
// qqReaderCancels maps adapter config name to its cancel function.
var qqReaderCancels = make(map[string]context.CancelFunc)
var qqReaderCancelsMu sync.Mutex
for _, name := range allNames {
stored, _ := store.Get(name)
if stored != nil && !stored.Enabled {
fmt.Printf("Platform %s is disabled in config, skipping\n", name)
// startQQReaders cancels any existing QQ readers and starts one per registered QQ adapter.
func startQQReaders(router *bridge.PlatformRouter) {
// Cancel all existing readers.
qqReaderCancelsMu.Lock()
for _, cancel := range qqReaderCancels {
cancel()
}
qqReaderCancels = make(map[string]context.CancelFunc)
qqReaderCancelsMu.Unlock()
for _, qqa := range router.GetAdaptersByPlatform("qq") {
qqAdapter, ok := qqa.(*qqadapter.Adapter)
if !ok {
continue
}
adapterKey := qqAdapter.ConfigName()
ctx, cancel := context.WithCancel(context.Background())
qqReaderCancelsMu.Lock()
qqReaderCancels[adapterKey] = cancel
qqReaderCancelsMu.Unlock()
var a bridge.PlatformAdapter
fields := mergeFields(cfg, name, stored)
adapter := qqAdapter // capture for goroutine
qqMsgCh := make(chan *qqadapter.OBv11Message, 100)
go adapter.ReadMessages(ctx, qqMsgCh)
go func() {
for msg := range qqMsgCh {
response, err := router.RouteMessage(adapterKey, msg)
if err != nil {
fmt.Printf("[qq:%s] route error: %v\n", adapterKey, err)
continue
}
if response != nil && len(response.Messages) > 0 && !hasOnlySilentMessages(response.Messages) {
messageType := msg.MessageType
userID := msg.UserID
groupID := msg.GroupID
// Filter non-empty messages.
var toSend []bridge.ResponseMessage
for _, rm := range response.Messages {
if rm.Content != "" {
toSend = append(toSend, rm)
}
}
interval := time.Duration(adapter.SendIntervalMs()) * time.Millisecond
if interval <= 0 {
interval = 2 * time.Second
}
for i, rm := range toSend {
if i > 0 && interval > 0 {
select {
case <-ctx.Done():
return
case <-time.After(interval):
}
}
// Re-acquire current adapter for hot-reload safety.
cur, err := router.GetAdapter(adapterKey)
if err != nil {
continue
}
curQQ, ok := cur.(*qqadapter.Adapter)
if !ok {
continue
}
var sendErr error
switch messageType {
case "private":
sendErr = curQQ.SendMessage("private", userID, 0, rm.Content)
case "group":
sendErr = curQQ.SendMessage("group", 0, groupID, rm.Content)
}
if sendErr != nil {
fmt.Printf("[qq:%s] send msg error: %v\n", adapterKey, sendErr)
}
}
}
}
}()
}
}
switch name {
case "qq":
port := cfg.QQBotPort
if p, ok := fields["bot_port"]; ok && p != "" {
port = p
}
a = qqadapter.NewAdapter(port)
case "telegram":
token := cfg.TelegramToken
if t, ok := fields["bot_token"]; ok && t != "" {
token = t
}
webhookURL := cfg.TelegramWebhookURL
if w, ok := fields["webhook_url"]; ok && w != "" {
webhookURL = w
}
a = telegramadapter.NewAdapter(token, webhookURL)
case "webhook":
a = webhookadapter.NewAdapter("webhook")
case "wechat":
a = wechatstub.NewAdapter()
case "feishu":
a = feishustub.NewAdapter()
case "discord":
a = discordstub.NewAdapter()
// createAdapters builds platform adapters from stored configs.
func createAdapters(cfg *config.Config, store *config.Store) []bridge.PlatformAdapter {
var adapters []bridge.PlatformAdapter
// Build adapters from stored configs. Each config is a separate adapter instance.
seen := make(map[string]bool)
for _, stored := range store.List() {
if !stored.Enabled {
fmt.Printf("Platform %s is disabled in config, skipping\n", stored.Name)
continue
}
platform := stored.Platform
if platform == "" {
platform = stored.Name
}
fields := mergeFields(cfg, platform, &stored)
a := createSingleAdapter(cfg, platform, stored.Name, fields)
if a != nil {
adapters = append(adapters, a)
seen[stored.Name] = true
}
}
// Seed default adapters for platforms that have no stored config.
defaultPlatforms := []string{"qq", "telegram", "webhook", "wechat", "feishu", "discord"}
for _, name := range defaultPlatforms {
if seen[name] {
continue
}
fields := mergeFields(cfg, name, nil)
a := createSingleAdapter(cfg, name, name, fields)
if a != nil {
adapters = append(adapters, a)
}
@@ -220,46 +411,132 @@ func createAdapters(cfg *config.Config, store *config.Store) []bridge.PlatformAd
return adapters
}
// createSingleAdapter creates one platform adapter from config fields.
// platform is the base platform type ("qq", "telegram", etc.), configName is the instance key.
func createSingleAdapter(cfg *config.Config, platform, configName string, fields map[string]string) bridge.PlatformAdapter {
switch platform {
case "qq":
port := cfg.QQBotPort
if p, ok := fields["bot_port"]; ok && p != "" {
port = p
}
token := ""
if t, ok := fields["access_token"]; ok {
token = t
}
remoteURL := ""
if r, ok := fields["remote_url"]; ok {
remoteURL = r
}
mode := "server"
if m, ok := fields["mode"]; ok && m != "" {
mode = m
} else if fields["remote_url"] != "" {
mode = "client" // backward compat: old configs with remote_url
}
sendIntervalMs := cfg.MessageSendIntervalMs
if s, ok := fields["send_interval_ms"]; ok && s != "" {
if n := parseIntOr(s, cfg.MessageSendIntervalMs); n > 0 {
sendIntervalMs = n
}
}
return qqadapter.NewAdapter(configName, mode, port, token, remoteURL, sendIntervalMs)
case "telegram":
token := cfg.TelegramToken
if t, ok := fields["bot_token"]; ok && t != "" {
token = t
}
webhookURL := cfg.TelegramWebhookURL
if w, ok := fields["webhook_url"]; ok && w != "" {
webhookURL = w
}
return telegramadapter.NewAdapter(token, webhookURL)
case "webhook":
return webhookadapter.NewAdapter(configName)
case "wechat":
return wechatstub.NewAdapter()
case "feishu":
return feishustub.NewAdapter()
case "discord":
return discordstub.NewAdapter()
}
return nil
}
// mergeFields returns fields from stored config, falling back to env defaults.
func mergeFields(cfg *config.Config, name string, stored *config.PlatformConfig) map[string]string {
func mergeFields(cfg *config.Config, platform string, stored *config.PlatformConfig) map[string]string {
fields := make(map[string]string)
if stored != nil {
for k, v := range stored.Fields {
fields[k] = v
}
}
// Apply env var defaults if fields are missing.
if fields["bot_token"] == "" && cfg.TelegramToken != "" && name == "telegram" {
if fields["bot_token"] == "" && cfg.TelegramToken != "" && platform == "telegram" {
fields["bot_token"] = cfg.TelegramToken
}
if fields["webhook_url"] == "" && cfg.TelegramWebhookURL != "" && name == "telegram" {
if fields["webhook_url"] == "" && cfg.TelegramWebhookURL != "" && platform == "telegram" {
fields["webhook_url"] = cfg.TelegramWebhookURL
}
if fields["bot_port"] == "" && cfg.QQBotPort != "" && name == "qq" {
if fields["bot_port"] == "" && cfg.QQBotPort != "" && platform == "qq" {
fields["bot_port"] = cfg.QQBotPort
}
return fields
}
// containsString checks whether a string slice contains a specific value.
func containsString(list []string, val string) bool {
for _, v := range list {
if v == val {
return true
}
}
return false
}
// getImageURLs extracts image attachment URLs from a UnifiedMessage.
func getImageURLs(msg *bridge.UnifiedMessage) []string {
if len(msg.Attachments) == 0 {
return nil
}
var urls []string
for _, a := range msg.Attachments {
if a.Type == "image" && a.URL != "" {
urls = append(urls, a.URL)
}
}
return urls
}
// forwardToAICore sends a unified message to AI-Core's chat endpoint and returns the response.
func forwardToAICore(cfg *config.Config, msg *bridge.UnifiedMessage) (*bridge.UnifiedResponse, error) {
reqBody, _ := json.Marshal(map[string]interface{}{
"user_id": msg.SenderID,
"session_id": fmt.Sprintf("platform_%s_%s", msg.Platform, msg.ChannelID),
// If images is non-empty, they are passed as URL strings for AI-Core to download and process.
func forwardToAICore(cfg *config.Config, msg *bridge.UnifiedMessage, mode, userID, sessionID string, images []string) (*bridge.UnifiedResponse, error) {
bodyMap := map[string]interface{}{
"user_id": userID,
"session_id": sessionID,
"message": msg.Content,
"mode": "text",
"mode": mode,
"routing": msg.RouteType,
"source": map[string]string{
"platform": msg.Platform,
"channel_id": msg.ChannelID,
"channel_type": msg.ChannelType,
"sender_name": msg.SenderName,
"platform": msg.Platform,
"channel_id": msg.ChannelID,
"channel_type": msg.ChannelType,
"sender_name": msg.SenderName,
"original_uid": msg.OriginalSenderUID,
},
})
}
if len(images) > 0 {
bodyMap["images"] = images
}
reqBody, _ := json.Marshal(bodyMap)
url := cfg.AICoreURL + "/api/v1/chat"
req, _ := http.NewRequest("POST", url, bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
if mode == "platform_silent" {
req.Header.Set("Accept", "application/json")
} else {
req.Header.Set("Accept", "text/event-stream")
}
if cfg.InternalToken != "" {
req.Header.Set("X-Internal-Token", cfg.InternalToken)
}
@@ -271,56 +548,276 @@ func forwardToAICore(cfg *config.Config, msg *bridge.UnifiedMessage) (*bridge.Un
}
defer resp.Body.Close()
// Silent mode: only check status, no reply expected.
if mode == "platform_silent" {
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("silent forward returned status %d", resp.StatusCode)
}
return &bridge.UnifiedResponse{
Messages: []bridge.ResponseMessage{
{DisplayType: "silent", Content: "", FormatMode: "plain"},
},
Platform: msg.Platform,
}, nil
}
// Try JSON first (non-streaming or already-complete response).
var result struct {
Content string `json:"content"`
Error string `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
return &bridge.UnifiedResponse{
Messages: []bridge.ResponseMessage{
{DisplayType: "chat", Content: buf.String(), FormatMode: "plain"},
},
Platform: msg.Platform,
}, nil
bodyBytes, readErr := ioReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("read ai-core response: %w", readErr)
}
if result.Error != "" {
return &bridge.UnifiedResponse{
Messages: []bridge.ResponseMessage{
{DisplayType: "system_info", Content: result.Error, FormatMode: "plain"},
},
Platform: msg.Platform,
}, nil
if json.Unmarshal(bodyBytes, &result) == nil {
if result.Error != "" {
return &bridge.UnifiedResponse{
Messages: []bridge.ResponseMessage{
{DisplayType: "system_info", Content: result.Error, FormatMode: "plain"},
},
Platform: msg.Platform,
}, nil
}
if result.Content != "" {
return &bridge.UnifiedResponse{
Messages: splitContent(filterActions(result.Content)),
Platform: msg.Platform,
}, nil
}
}
// Not JSON — parse as SSE (text/event-stream).
content := parseSSEAndAccumulate(string(bodyBytes))
if content == "" {
return nil, fmt.Errorf("ai-core returned empty response")
}
return &bridge.UnifiedResponse{
Messages: []bridge.ResponseMessage{
{DisplayType: "chat", Content: result.Content, FormatMode: "plain"},
},
Messages: splitContent(filterActions(content)),
Platform: msg.Platform,
}, nil
}
// seedIdentities loads default identity mappings.
func seedIdentities(m *bridge.IdentityMapper) {
if qqAdmin := os.Getenv("QQ_ADMIN_UID"); qqAdmin != "" {
m.Register(permissions.PlatformIdentity{
Platform: "qq",
PlatformUID: qqAdmin,
CyreneUser: "admin",
Nickname: "开拓者",
PermissionLevel: "admin",
})
// ioReadAll reads all bytes from a reader (replaces io.ReadAll for older Go compat).
func ioReadAll(r io.Reader) ([]byte, error) {
buf := new(bytes.Buffer)
_, err := buf.ReadFrom(r)
return buf.Bytes(), err
}
// parseSSEAndAccumulate parses a Server-Sent Events stream and accumulates the full text.
// It handles both "delta" chunks and the final "segments" structure.
func parseSSEAndAccumulate(body string) string {
lines := strings.Split(body, "\n")
var deltas []string
var finalText string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || line == "[DONE]" {
continue
}
if !strings.HasPrefix(line, "data: ") {
continue
}
payload := strings.TrimPrefix(line, "data: ")
var chunk map[string]interface{}
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
// Final segment: use the full text if available.
if done, _ := chunk["done"].(bool); done {
if segs, ok := chunk["segments"].([]interface{}); ok && len(segs) > 0 {
if seg, ok := segs[0].(map[string]interface{}); ok {
if t, ok := seg["text"].(string); ok && t != "" {
finalText = t
}
}
}
continue
}
// Accumulate deltas.
if delta, ok := chunk["delta"].(string); ok && delta != "" {
deltas = append(deltas, delta)
}
// Also accumulate content if present (some APIs send full content).
if content, ok := chunk["content"].(string); ok && content != "" {
deltas = append(deltas, content)
}
}
if tgAdmin := os.Getenv("TELEGRAM_ADMIN_UID"); tgAdmin != "" {
m.Register(permissions.PlatformIdentity{
Platform: "telegram",
PlatformUID: tgAdmin,
CyreneUser: "admin",
Nickname: "开拓者",
PermissionLevel: "admin",
})
if finalText != "" {
return finalText
}
return strings.Join(deltas, "")
}
// splitContent splits text by \n\n into multiple ResponseMessage segments.
// Non-empty segments are each wrapped as a chat message; empty input returns a single empty message.
func splitContent(text string) []bridge.ResponseMessage {
parts := strings.Split(text, "\n\n")
var msgs []bridge.ResponseMessage
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
msgs = append(msgs, bridge.ResponseMessage{
DisplayType: "chat",
Content: part,
FormatMode: "plain",
})
}
}
if len(msgs) == 0 {
return []bridge.ResponseMessage{
{DisplayType: "chat", Content: text, FormatMode: "plain"},
}
}
return msgs
}
// filterActions removes <action>...</action> tags and their content from text.
// Also handles unescaped <action> tags from SSE (e.g. <action>).
func filterActions(text string) string {
// Remove standard <action>...</action> tags.
for {
start := strings.Index(text, "<action>")
if start == -1 {
break
}
end := strings.Index(text[start:], "</action>")
if end == -1 {
// Unclosed action tag — just remove the opening tag.
text = text[:start] + text[start+len("<action>"):]
continue
}
text = text[:start] + text[start+end+len("</action>"):]
}
// Remove SSE-escaped action tags (<action>...</action>).
for {
start := strings.Index(text, `<action>`)
if start == -1 {
break
}
end := strings.Index(text[start:], `</action>`)
if end == -1 {
text = text[:start] + text[start+len(`<action>`):]
continue
}
text = text[:start] + text[start+end+len(`</action>`):]
}
return strings.TrimSpace(text)
}
// buildMemoryNamespace creates a memory-isolated user_id for a platform channel.
func buildMemoryNamespace(platform, channelType, channelID string) string {
return fmt.Sprintf("platform_%s_%s_%s", platform, channelType, channelID)
}
// detectAdminMention checks whether a message mentions the admin.
func detectAdminMention(msg *bridge.UnifiedMessage, mapper *bridge.IdentityMapper, cfg *config.Config) (bool, string) {
for _, mentionUID := range msg.Mentions {
if mapper.IsAdmin(msg.Platform, mentionUID) {
return true, fmt.Sprintf("@mention of admin UID %s", mentionUID)
}
}
lowerContent := strings.ToLower(msg.Content)
for _, nickname := range cfg.AdminNicknames {
if strings.Contains(msg.Content, nickname) {
return true, fmt.Sprintf("admin nickname mention: %s", nickname)
}
}
for _, kw := range cfg.AdminMentionKeywords {
if strings.Contains(lowerContent, strings.ToLower(kw)) {
return true, fmt.Sprintf("admin keyword mention: %s", kw)
}
}
return false, ""
}
// hasOnlySilentMessages checks if all response messages are silent (no reply needed).
func hasOnlySilentMessages(messages []bridge.ResponseMessage) bool {
for _, m := range messages {
if m.DisplayType != "silent" && m.Content != "" {
return false
}
}
return true
}
func parseIntOr(s string, defaultVal int) int {
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
} else {
return defaultVal
}
}
if n == 0 && s != "0" {
return defaultVal
}
return n
}
// seedIdentities loads default identity mappings from env vars and stored platform configs.
func seedIdentities(m *bridge.IdentityMapper, store *config.Store) {
// From environment variables.
for _, entry := range []struct{ envKey, platform string }{
{"QQ_ADMIN_UID", "qq"},
{"TELEGRAM_ADMIN_UID", "telegram"},
} {
if raw := os.Getenv(entry.envKey); raw != "" {
for _, uid := range strings.Split(raw, ",") {
uid = strings.TrimSpace(uid)
if uid == "" {
continue
}
m.Register(permissions.PlatformIdentity{
Platform: entry.platform,
PlatformUID: uid,
CyreneUser: "admin",
Nickname: "开拓者",
PermissionLevel: "admin",
})
}
}
}
// From stored platform configs (admin_uids field).
for _, name := range []string{"qq", "telegram", "webhook", "wechat", "feishu", "discord"} {
stored, _ := store.Get(name)
if stored == nil {
continue
}
syncAdminUIDs(m, name, stored.Fields)
}
}
// syncAdminUIDs registers admin identities from a platform config's admin_uids field.
// Comma-separated list of platform UIDs.
func syncAdminUIDs(m *bridge.IdentityMapper, platform string, fields map[string]string) {
raw, ok := fields["admin_uids"]
if !ok || raw == "" {
return
}
for _, uid := range strings.Split(raw, ",") {
uid = strings.TrimSpace(uid)
if uid == "" {
continue
}
m.Register(permissions.PlatformIdentity{
Platform: platform,
PlatformUID: uid,
CyreneUser: "admin",
Nickname: "开拓者",
PermissionLevel: "admin",
})
}
fmt.Printf("Synced admin identities for %s from config: %s\n", platform, raw)
}
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
@@ -17,31 +19,51 @@ var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// Adapter implements PlatformAdapter for QQ via OBv11 WebSocket.
// Adapter implements PlatformAdapter for QQ via OneBot v11 WebSocket.
// Supports two modes:
// - "server" (正向 WS): adapter starts a WS server, NapCat connects as client.
// - "client" (反向 WS): adapter connects to NapCat's WS server as a client.
type Adapter struct {
port string
conn *websocket.Conn
connMu sync.Mutex
connected bool
configName string // instance name, e.g. "qq-home"
mode string // "client" or "server"
port string
accessToken string
remoteURL string // NapCat OneBot WS server URL, used in client mode
sendIntervalMs int // minimum interval between consecutive messages
selfID string // bot's own QQ number, populated from incoming messages
conn *websocket.Conn
connMu sync.Mutex
connected bool
srv *http.Server // HTTP server for WS upgrades (server mode only)
// Pending API call responses.
pendingResponses map[string]chan *OBv11APIResponse
respMu sync.Mutex
}
func NewAdapter(port string) *Adapter {
func NewAdapter(configName, mode, port, accessToken, remoteURL string, sendIntervalMs int) *Adapter {
if mode == "" {
mode = "server"
}
return &Adapter{
configName: configName,
mode: mode,
port: port,
accessToken: accessToken,
remoteURL: remoteURL,
sendIntervalMs: sendIntervalMs,
pendingResponses: make(map[string]chan *OBv11APIResponse),
}
}
func (a *Adapter) PlatformName() string { return "qq" }
func (a *Adapter) PlatformName() string { return "qq" }
func (a *Adapter) ConfigName() string { return a.configName }
func (a *Adapter) SendIntervalMs() int { return a.sendIntervalMs }
func (a *Adapter) SelfID() string { return a.selfID }
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
return bridge.PlatformCapabilities{
MaxMessageLength: 200,
SupportsMarkdown: true, // QQ supports basic markdown
SupportsMarkdown: true,
SupportsImage: true,
SupportsVoice: false,
SupportsEmoji: true,
@@ -51,38 +73,101 @@ func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
}
}
// checkAuth 验证 WebSocket 升级请求的 access_token。
// NapCat 通过两种方式传递: query 参数 ?access_token=xxx 或 Authorization: Bearer xxx 头.
func (a *Adapter) checkAuth(r *http.Request) bool {
if a.accessToken == "" {
return true // 未配置 token,允许所有连接
}
// 1) query 参数
if r.URL.Query().Get("access_token") == a.accessToken {
return true
}
// 2) Authorization: Bearer <token>
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == a.accessToken {
return true
}
return false
}
// wsHandler 统一的 WebSocket 连接处理 — 单连接承载 API 调用 + 事件推送 (OneBot 正向 WS).
func (a *Adapter) wsHandler(w http.ResponseWriter, r *http.Request) {
if !a.checkAuth(r) {
http.Error(w, "Forbidden: invalid access_token", http.StatusForbidden)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
fmt.Printf("[qq] upgrade error: %v\n", err)
return
}
a.connMu.Lock()
// 关闭旧连接 (NapCat 重连)
if a.conn != nil {
a.conn.Close()
}
a.conn = conn
a.connected = true
a.connMu.Unlock()
fmt.Println("[qq] NapCat/OneBot connected (正向WS)")
}
// legacyHandler 兼容旧的路径 /ws/qq 和 /ws/qq/event.
func (a *Adapter) legacyHandler(w http.ResponseWriter, r *http.Request) {
fmt.Printf("[qq] legacy WS path %s connected (consider changing NapCat URL to root /)\n", r.URL.Path)
a.wsHandler(w, r)
}
func (a *Adapter) Connect(ctx context.Context) error {
if a.mode == "client" {
return a.connectClient(ctx)
}
return a.connectServer()
}
func (a *Adapter) connectClient(ctx context.Context) error {
url := a.remoteURL
if a.accessToken != "" {
sep := "?"
if strings.Contains(url, "?") {
sep = "&"
}
url += sep + "access_token=" + a.accessToken
}
dialer := websocket.DefaultDialer
conn, _, err := dialer.DialContext(ctx, url, nil)
if err != nil {
return fmt.Errorf("dial NapCat WS %s: %w", url, err)
}
a.connMu.Lock()
if a.conn != nil {
a.conn.Close()
}
a.conn = conn
a.connected = true
a.connMu.Unlock()
fmt.Printf("[qq] connected to NapCat OneBot WS (client mode): %s\n", a.remoteURL)
return nil
}
func (a *Adapter) connectServer() error {
mux := http.NewServeMux()
mux.HandleFunc("/ws/qq", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
fmt.Printf("[qq] upgrade error: %v\n", err)
return
}
a.connMu.Lock()
a.conn = conn
a.connected = true
a.connMu.Unlock()
fmt.Println("[qq] bot connected")
})
mux.HandleFunc("/ws/qq/event", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
fmt.Printf("[qq] event upgrade error: %v\n", err)
return
}
a.connMu.Lock()
a.conn = conn
a.connected = true
a.connMu.Unlock()
fmt.Println("[qq] event WebSocket connected")
})
mux.HandleFunc("/", a.wsHandler) // NapCat 正向 WS 标准路径
mux.HandleFunc("/ws/qq", a.legacyHandler) // 向下兼容旧配置
mux.HandleFunc("/ws/qq/event", a.legacyHandler) // 向下兼容旧配置
addr := ":" + a.port
srv := &http.Server{Addr: addr, Handler: mux}
a.srv = &http.Server{Addr: addr, Handler: mux}
go func() {
fmt.Printf("[qq] listening on %s (waiting for bot WebSocket connection)\n", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("[qq] WebSocket server on %s (waiting for NapCat forward WS connection)\n", addr)
if a.accessToken != "" {
fmt.Println("[qq] access_token 已配置,将验证连接请求")
}
if err := a.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("[qq] server error: %v\n", err)
}
}()
@@ -91,12 +176,22 @@ func (a *Adapter) Connect(ctx context.Context) error {
func (a *Adapter) Disconnect(ctx context.Context) error {
a.connMu.Lock()
defer a.connMu.Unlock()
if a.conn != nil {
a.conn.Close()
a.conn = nil
}
a.connected = false
srv := a.srv
a.srv = nil
a.connMu.Unlock()
if srv != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("qq server shutdown: %w", err)
}
}
return nil
}
@@ -120,10 +215,8 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
return nil, fmt.Errorf("expected *OBv11Message, got %T", rawMessage)
}
// Extract text content.
content := extractText(msg)
// Determine sender.
senderID := ""
senderName := "unknown"
channelType := "direct"
@@ -150,7 +243,6 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
}
}
// Extract mentions.
var mentions []string
if segments, ok := msg.Message.([]interface{}); ok {
for _, s := range segments {
@@ -166,6 +258,8 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
}
}
attachments := extractAttachments(msg)
return &bridge.UnifiedMessage{
SenderID: senderID,
SenderName: senderName,
@@ -176,6 +270,7 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
ContentType: "text",
MessageID: fmt.Sprintf("%d", msg.MessageID),
Mentions: mentions,
Attachments: attachments,
RawData: rawMessage,
Timestamp: time.Unix(msg.Time, 0),
}, nil
@@ -189,7 +284,6 @@ func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.Platfo
if rm.FormatMode == "markdown" {
content = convertMarkdownToQQ(rm.Content)
}
// QQ prefers shorter messages — split if needed.
runes := []rune(content)
if len(runes) > 200 {
content = string(runes[:200])
@@ -240,6 +334,14 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message)
conn := a.conn
a.connMu.Unlock()
if conn == nil {
// Client mode: auto-reconnect when connection is lost.
if a.mode == "client" {
if err := a.connectClient(ctx); err != nil {
fmt.Printf("[qq] reconnect failed: %v, retrying in 3s...\n", err)
time.Sleep(3 * time.Second)
}
continue
}
time.Sleep(time.Second)
continue
}
@@ -248,6 +350,7 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message)
if err != nil {
fmt.Printf("[qq] read error: %v\n", err)
a.connMu.Lock()
a.conn.Close()
a.conn = nil
a.connected = false
a.connMu.Unlock()
@@ -258,7 +361,6 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message)
// Try to parse as OBv11 message (event from QQ).
var msg OBv11Message
if err := json.Unmarshal(raw, &msg); err != nil {
// Might be an API response.
var resp OBv11APIResponse
if err := json.Unmarshal(raw, &resp); err != nil {
fmt.Printf("[qq] unknown message: %s\n", string(raw))
@@ -275,7 +377,12 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message)
continue
}
// Only handle message events.
// Capture bot's own QQ number from incoming messages.
if msg.SelfID != 0 && a.selfID == "" {
a.selfID = fmt.Sprintf("%d", msg.SelfID)
fmt.Printf("[qq:%s] self ID captured: %s\n", a.configName, a.selfID)
}
if msg.PostType == "message" {
select {
case msgCh <- &msg:
@@ -312,13 +419,64 @@ func extractText(msg *OBv11Message) string {
return ""
}
var cqImageRegex = regexp.MustCompile(`\[CQ:image,[^\]]*\]`)
var cqURLRegex = regexp.MustCompile(`\burl=([^,\]]+)`)
// extractAttachments extracts image URLs from OBv11Message.
// Handles both string format (CQ codes in raw_message) and array format (parsed segments).
func extractAttachments(msg *OBv11Message) []bridge.Attachment {
var attachments []bridge.Attachment
// Array format: iterate segments looking for type="image".
if segments, ok := msg.Message.([]interface{}); ok {
for _, s := range segments {
if seg, ok := s.(map[string]interface{}); ok {
if seg["type"] != "image" {
continue
}
data, _ := seg["data"].(map[string]interface{})
if data == nil {
continue
}
url, _ := data["url"].(string)
file, _ := data["file"].(string)
if url == "" {
continue
}
attachments = append(attachments, bridge.Attachment{
Type: "image",
URL: url,
FileName: file,
})
}
}
return attachments
}
// String format: parse CQ codes from RawMessage or string Message.
raw := msg.RawMessage
if raw == "" {
if s, ok := msg.Message.(string); ok {
raw = s
}
}
matches := cqImageRegex.FindAllString(raw, -1)
for _, m := range matches {
urlMatch := cqURLRegex.FindStringSubmatch(m)
if len(urlMatch) >= 2 {
attachments = append(attachments, bridge.Attachment{
Type: "image",
URL: urlMatch[1],
})
}
}
return attachments
}
// convertMarkdownToQQ converts common markdown to QQ-supported format.
func convertMarkdownToQQ(md string) string {
// QQ supports basic markdown: **bold**, *italic*, ~~strikethrough~~
// Remove unsupported elements (headings, code blocks, links).
md = removeHeadings(md)
md = removeCodeBlocks(md)
// Preserve bold, italic, strikethrough which QQ supports.
return md
}
@@ -332,7 +490,6 @@ func removeHeadings(s string) string {
}
func removeCodeBlocks(s string) string {
// Simple: remove ``` markers.
result := ""
inCode := false
for _, line := range splitLines(s) {
@@ -376,7 +533,6 @@ func stripPrefix(s, prefix string) string {
}
func replaceLine(s, old, new string) string {
// Simple: find old line and replace with new.
idx := indexOf(s, old)
if idx < 0 {
return s
@@ -44,6 +44,23 @@ func (m *IdentityMapper) Resolve(platform, platformUID string) (*permissions.Pla
return id, nil
}
// ResolveOrNil finds the Cyrene user for a platform identity, returning nil for unknown users.
func (m *IdentityMapper) ResolveOrNil(platform, platformUID string) *permissions.PlatformIdentity {
m.mu.RLock()
defer m.mu.RUnlock()
plat, ok := m.byPlatform[platform]
if !ok {
return nil
}
return plat[platformUID]
}
// IsAdmin returns true if the given platform user is a registered admin.
func (m *IdentityMapper) IsAdmin(platform, platformUID string) bool {
id := m.ResolveOrNil(platform, platformUID)
return id != nil && id.PermissionLevel == "admin"
}
// List returns all identities for a platform.
func (m *IdentityMapper) List(platform string) []permissions.PlatformIdentity {
m.mu.RLock()
@@ -1,12 +1,22 @@
package bridge
import (
"context"
"fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/permissions"
)
// adapterKey returns the unique key for an adapter in the router map.
// Uses ConfigName() if the adapter implements it, otherwise PlatformName().
func adapterKey(a PlatformAdapter) string {
if named, ok := a.(interface{ ConfigName() string }); ok {
return named.ConfigName()
}
return a.PlatformName()
}
// PlatformRouter manages all platform adapters and routes messages.
type PlatformRouter struct {
mu sync.RWMutex
@@ -37,11 +47,37 @@ func NewPlatformRouter(mapper *IdentityMapper, checker *permissions.Checker) *Pl
}
}
// RegisterAdapter adds a platform adapter.
// RegisterAdapter adds a platform adapter, keyed by its config name.
func (r *PlatformRouter) RegisterAdapter(a PlatformAdapter) {
r.mu.Lock()
defer r.mu.Unlock()
r.adapters[a.PlatformName()] = a
r.adapters[adapterKey(a)] = a
}
// RemoveAdapter disconnects and removes a platform adapter.
func (r *PlatformRouter) RemoveAdapter(platform string) {
r.mu.Lock()
a, ok := r.adapters[platform]
if ok {
delete(r.adapters, platform)
}
r.mu.Unlock()
if ok {
a.Disconnect(context.Background())
}
}
// ReplaceAdapter disconnects the old adapter (if present), registers the new one,
// and connects it. Returns an error if the new adapter fails to connect.
func (r *PlatformRouter) ReplaceAdapter(a PlatformAdapter) error {
key := adapterKey(a)
r.mu.Lock()
if old, ok := r.adapters[key]; ok {
old.Disconnect(context.Background())
}
r.adapters[key] = a
r.mu.Unlock()
return a.Connect(context.Background())
}
// GetAdapter returns the adapter for a platform.
@@ -55,7 +91,7 @@ func (r *PlatformRouter) GetAdapter(platform string) (PlatformAdapter, error) {
return a, nil
}
// ListAdapters returns all registered adapter names.
// ListAdapters returns all registered adapter names (config names).
func (r *PlatformRouter) ListAdapters() []string {
r.mu.RLock()
defer r.mu.RUnlock()
@@ -66,14 +102,28 @@ func (r *PlatformRouter) ListAdapters() []string {
return names
}
// GetAdaptersByPlatform returns all registered adapters for a given platform type.
func (r *PlatformRouter) GetAdaptersByPlatform(platform string) []PlatformAdapter {
r.mu.RLock()
defer r.mu.RUnlock()
var result []PlatformAdapter
for _, a := range r.adapters {
if a.PlatformName() == platform {
result = append(result, a)
}
}
return result
}
// SetMessageHandler sets the callback for processing unified messages.
func (r *PlatformRouter) SetMessageHandler(h MessageHandler) {
r.handler = h
}
// RouteMessage converts a platform message to unified, checks permissions, and dispatches.
func (r *PlatformRouter) RouteMessage(platform string, rawMsg interface{}) (*UnifiedResponse, error) {
a, err := r.GetAdapter(platform)
// adapterKey is the config name (e.g., "qq", "qq-home") used to look up the adapter instance.
func (r *PlatformRouter) RouteMessage(adapterKey string, rawMsg interface{}) (*UnifiedResponse, error) {
a, err := r.GetAdapter(adapterKey)
if err != nil {
return nil, err
}
@@ -83,18 +133,22 @@ func (r *PlatformRouter) RouteMessage(platform string, rawMsg interface{}) (*Uni
return nil, fmt.Errorf("convert to unified: %w", err)
}
// Resolve identity.
identity, err := r.mapper.Resolve(platform, unified.SenderID)
if err != nil {
return nil, fmt.Errorf("identity not found: %w", err)
// Preserve original platform UID before identity mapping.
unified.OriginalSenderUID = unified.SenderID
unified.OriginalRawMessage = rawMsg
// Capture bot's own UID for @mention detection.
if selfAware, ok := a.(interface{ SelfID() string }); ok {
unified.BotUID = selfAware.SelfID()
}
// Merge identity info into the unified message.
unified.SenderID = identity.CyreneUser
unified.SenderName = identity.Nickname
// Apply permission-based filtering.
_ = identity // used by permission checks on tools
// Resolve identity (nil for unknown users; caller decides routing).
// Use platform type (e.g. "qq") for identity resolution, not adapter key.
identity := r.mapper.ResolveOrNil(a.PlatformName(), unified.SenderID)
if identity != nil {
unified.SenderID = identity.CyreneUser
unified.SenderName = identity.Nickname
}
// Update channel context.
r.updateContext(unified)
@@ -108,8 +162,9 @@ func (r *PlatformRouter) RouteMessage(platform string, rawMsg interface{}) (*Uni
return nil, err
}
response.Platform = platform
response.PlatformHints = r.platformHints(platform)
// Use adapter key for response routing so SendResponse finds the correct instance.
response.Platform = adapterKey
response.PlatformHints = r.platformHints(adapterKey)
return response, nil
}
@@ -21,6 +21,12 @@ type UnifiedMessage struct {
RawData interface{} `json:"raw_data,omitempty"`
Timestamp time.Time `json:"timestamp"`
// Routing metadata.
RouteType string `json:"route_type,omitempty"` // "normal", "silent", "admin_mention"
OriginalSenderUID string `json:"original_sender_uid,omitempty"` // preserved before identity mapping
OriginalRawMessage interface{} `json:"-"` // preserved for SendMessage wiring
BotUID string `json:"-"` // bot's own platform UID, set by router
}
// Attachment represents a file/image/voice attachment.
@@ -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
}
@@ -0,0 +1,44 @@
package handler
import (
"encoding/json"
"net/http"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/config"
)
// BlocklistHandler exposes CRUD for blocklist settings.
type BlocklistHandler struct {
store *config.BlocklistStore
}
func NewBlocklistHandler(store *config.BlocklistStore) *BlocklistHandler {
return &BlocklistHandler{store: store}
}
func (h *BlocklistHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/settings/blocklist", h.handleBlocklist)
}
func (h *BlocklistHandler) handleBlocklist(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
writeJSON(w, http.StatusOK, h.store.Get())
case "POST", "PUT":
var bs config.BlocklistSettings
if err := json.NewDecoder(r.Body).Decode(&bs); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return
}
if err := h.store.Set(bs); err != nil {
writeJSON(w, http.StatusBadRequest, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "saved",
"settings": h.store.Get(),
})
default:
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
}
}
@@ -8,21 +8,27 @@ import (
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/config"
)
var knownPlatforms = map[string]bool{
var validPlatformTypes = map[string]bool{
"qq": true, "telegram": true, "webhook": true,
"wechat": true, "feishu": true, "discord": true,
}
// ConfigHandler exposes CRUD endpoints for platform configs.
type ConfigHandler struct {
store *config.Store
router *bridge.PlatformRouter
store *config.Store
router *bridge.PlatformRouter
onChanged func(name, platform string, enabled bool, fields map[string]string)
}
func NewConfigHandler(store *config.Store, router *bridge.PlatformRouter) *ConfigHandler {
return &ConfigHandler{store: store, router: router}
}
// SetOnConfigChanged sets a callback invoked after config is saved or deleted.
func (h *ConfigHandler) SetOnConfigChanged(fn func(name, platform string, enabled bool, fields map[string]string)) {
h.onChanged = fn
}
func (h *ConfigHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/configs", h.listConfigs)
mux.HandleFunc("/api/v1/configs/", h.handleConfig)
@@ -33,6 +39,7 @@ func (h *ConfigHandler) listConfigs(w http.ResponseWriter, r *http.Request) {
type configSummary struct {
Name string `json:"name"`
Platform string `json:"platform"`
Enabled bool `json:"enabled"`
Label string `json:"label,omitempty"`
Fields map[string]string `json:"fields"`
@@ -46,8 +53,13 @@ func (h *ConfigHandler) listConfigs(w http.ResponseWriter, r *http.Request) {
if a, err := h.router.GetAdapter(c.Name); err == nil {
connected = a.IsConnected()
}
platform := c.Platform
if platform == "" {
platform = c.Name
}
result = append(result, configSummary{
Name: c.Name,
Platform: platform,
Enabled: c.Enabled,
Label: c.Label,
Fields: c.Fields,
@@ -71,6 +83,7 @@ func (h *ConfigHandler) listConfigs(w http.ResponseWriter, r *http.Request) {
}
result = append(result, configSummary{
Name: name,
Platform: name,
Enabled: false,
Fields: map[string]string{},
Connected: connected,
@@ -92,10 +105,6 @@ func (h *ConfigHandler) handleConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, errResp("missing config name"))
return
}
if !knownPlatforms[name] {
writeJSON(w, http.StatusBadRequest, errResp("unknown platform: "+name))
return
}
switch r.Method {
case "GET":
@@ -120,26 +129,37 @@ func (h *ConfigHandler) getConfig(w http.ResponseWriter, r *http.Request, name s
connected = a.IsConnected()
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"name": cfg.Name,
"enabled": cfg.Enabled,
"label": cfg.Label,
"fields": cfg.Fields,
"updated_at": cfg.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
"connected": connected,
"name": cfg.Name,
"platform": cfg.Platform,
"enabled": cfg.Enabled,
"label": cfg.Label,
"fields": cfg.Fields,
"updated_at": cfg.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
"connected": connected,
})
}
func (h *ConfigHandler) saveConfig(w http.ResponseWriter, r *http.Request, name string) {
var body struct {
Enabled *bool `json:"enabled"`
Label string `json:"label"`
Fields map[string]string `json:"fields"`
Platform *string `json:"platform"`
Enabled *bool `json:"enabled"`
Label string `json:"label"`
Fields map[string]string `json:"fields"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return
}
platform := name
if body.Platform != nil && *body.Platform != "" {
platform = *body.Platform
}
if !validPlatformTypes[platform] {
writeJSON(w, http.StatusBadRequest, errResp("unknown or missing platform type: "+platform))
return
}
enabled := true
if body.Enabled != nil {
enabled = *body.Enabled
@@ -151,29 +171,48 @@ func (h *ConfigHandler) saveConfig(w http.ResponseWriter, r *http.Request, name
}
cfg := config.PlatformConfig{
Name: name,
Enabled: enabled,
Label: body.Label,
Fields: fields,
Name: name,
Platform: platform,
Enabled: enabled,
Label: body.Label,
Fields: fields,
}
if err := h.store.Set(cfg); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
// Trigger hot-reload.
if h.onChanged != nil {
h.onChanged(name, platform, enabled, fields)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"name": name,
"enabled": enabled,
"label": body.Label,
"fields": fields,
"status": "saved",
"name": name,
"platform": platform,
"enabled": enabled,
"label": body.Label,
"fields": fields,
"status": "saved",
})
}
func (h *ConfigHandler) deleteConfig(w http.ResponseWriter, r *http.Request, name string) {
// Get platform type before deleting (needed for onChanged callback).
platform := name
if cfg, err := h.store.Get(name); err == nil && cfg.Platform != "" {
platform = cfg.Platform
}
if err := h.store.Delete(name); err != nil {
writeJSON(w, http.StatusNotFound, errResp(err.Error()))
return
}
// Trigger hot-reload: disable and clear fields.
if h.onChanged != nil {
h.onChanged(name, platform, false, nil)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
}
@@ -4,16 +4,18 @@ import (
"net/http"
"strconv"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/config"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/logging"
)
// LogHandler exposes message log retrieval endpoints.
type LogHandler struct {
logger *logging.Logger
store *config.Store
}
func NewLogHandler(logger *logging.Logger) *LogHandler {
return &LogHandler{logger: logger}
func NewLogHandler(logger *logging.Logger, store *config.Store) *LogHandler {
return &LogHandler{logger: logger, store: store}
}
func (h *LogHandler) RegisterRoutes(mux *http.ServeMux) {
@@ -27,6 +29,14 @@ func (h *LogHandler) handleLogs(w http.ResponseWriter, r *http.Request) {
return
}
// Resolve platform type from config name (e.g. "qq-home" → "qq").
platform := name
if h.store != nil {
if cfg, err := h.store.Get(name); err == nil && cfg.Platform != "" {
platform = cfg.Platform
}
}
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 1000 {
@@ -34,7 +44,7 @@ func (h *LogHandler) handleLogs(w http.ResponseWriter, r *http.Request) {
}
}
entries, err := h.logger.ReadLogs(name, limit)
entries, err := h.logger.ReadLogs(platform, limit)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
@@ -43,7 +53,7 @@ func (h *LogHandler) handleLogs(w http.ResponseWriter, r *http.Request) {
entries = []logging.LogEntry{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"platform": name,
"platform": platform,
"total": len(entries),
"logs": entries,
})
@@ -0,0 +1,86 @@
package handler
import (
"encoding/json"
"net/http"
"sync"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/logging"
)
var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// LogWSHub broadcasts log entries to connected WebSocket clients.
type LogWSHub struct {
mu sync.Mutex
clients map[*websocket.Conn]chan logging.LogEntry
}
// NewLogWSHub creates a LogWSHub and subscribes to the logger.
func NewLogWSHub(logger *logging.Logger) *LogWSHub {
h := &LogWSHub{
clients: make(map[*websocket.Conn]chan logging.LogEntry),
}
logger.OnLog(func(entry logging.LogEntry) {
h.broadcast(entry)
})
return h
}
func (h *LogWSHub) broadcast(entry logging.LogEntry) {
h.mu.Lock()
defer h.mu.Unlock()
for _, ch := range h.clients {
select {
case ch <- entry:
default:
}
}
}
// ServeWS handles WebSocket upgrade and streams log entries to the client.
func (h *LogWSHub) ServeWS(w http.ResponseWriter, r *http.Request) {
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
ch := make(chan logging.LogEntry, 64)
h.mu.Lock()
h.clients[conn] = ch
h.mu.Unlock()
// Write goroutine: drains ch until it is closed.
done := make(chan struct{})
go func() {
defer close(done)
for entry := range ch {
data, _ := json.Marshal(entry)
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return
}
}
}()
// Read goroutine: detect client disconnect.
// (websocket requires a reader to detect close frames.)
go func() {
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
}
// Client disconnected — stop broadcasting, close channel.
h.mu.Lock()
delete(h.clients, conn)
h.mu.Unlock()
close(ch)
}()
<-done
conn.Close()
}
@@ -25,11 +25,23 @@ type LogEntry struct {
Error string `json:"error,omitempty"`
}
// LogListener receives log entries as they are written.
type LogListener func(LogEntry)
// Logger writes message logs to per-platform JSONL files.
type Logger struct {
mu sync.Mutex
dir string
files map[string]*os.File
mu sync.Mutex
dir string
files map[string]*os.File
listeners []LogListener
}
// OnLog registers a listener that is called for every log entry written.
// The listener is called synchronously; avoid heavy work in the callback.
func (l *Logger) OnLog(fn LogListener) {
l.mu.Lock()
defer l.mu.Unlock()
l.listeners = append(l.listeners, fn)
}
// NewLogger creates a Logger, ensuring the log directory exists.
@@ -60,12 +72,23 @@ func (l *Logger) Log(entry LogEntry) error {
}
l.mu.Lock()
defer l.mu.Unlock()
if _, err := f.Write(append(data, '\n')); err != nil {
l.mu.Unlock()
return fmt.Errorf("write log: %w", err)
}
return f.Sync()
if err := f.Sync(); err != nil {
l.mu.Unlock()
return err
}
listeners := make([]LogListener, len(l.listeners))
copy(listeners, l.listeners)
l.mu.Unlock()
// Notify listeners outside the lock.
for _, fn := range listeners {
fn(entry)
}
return nil
}
// ReadLogs reads the last N log entries for a platform, newest first.
+21
View File
@@ -133,6 +133,26 @@ services:
WHISPER_LANGUAGE: "zh"
restart: unless-stopped
platform-bridge:
container_name: cyrene_platform_bridge
build:
context: .
dockerfile: ./backend/platform-bridge/Dockerfile
ports:
- "${QQ_BOT_PORT:-8096}:8096"
environment:
PORT: "8095"
ENV: production
GATEWAY_URL: http://gateway:8080
AI_CORE_URL: http://ai-core:8081
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}
QQ_BOT_PORT: ${QQ_BOT_PORT:-8096}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_WEBHOOK_URL: ${TELEGRAM_WEBHOOK_URL:-}
depends_on:
- gateway
restart: unless-stopped
iot-debug-service:
container_name: cyrene_iot_debug_service
build:
@@ -155,6 +175,7 @@ services:
MEMORY_SERVICE_URL: http://memory-service:8091
VOICE_SERVICE_URL: http://voice-service:8093
IOT_DEBUG_SERVICE_URL: http://iot-debug-service:8083
PLATFORM_BRIDGE_URL: http://platform-bridge:8095
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
RUNNING_IN_DOCKER: "true"
+541 -65
View File
@@ -282,6 +282,14 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
}
.empty-state .icon { font-size: 36px; margin-bottom: 8px; }
/* 概览统计条 */
.overview-stat {
display: flex; flex-direction: column; align-items: center; gap: 2px;
min-width: 80px;
}
.overview-stat-label { font-size: 10px; color: var(--text3); text-transform: uppercase; letter-spacing: .5px; }
.overview-stat-value { font-size: 15px; font-weight: 700; }
/* 会话详情展开 */
.session-detail {
background: var(--bg); border: 1px solid var(--border2); border-radius: var(--radius-sm);
@@ -997,6 +1005,7 @@ function connectWS() {
if (msg.type === 'log') handleWSLog(msg.data);
if (msg.type === 'stt-log') handleSTTLog(msg);
if (msg.type === 'voice_transcript') handleVoiceTranscript(msg);
if (msg.type === 'chat-log') handleChatLog(msg.data);
if (msg.type === 'status') {
STATE.serviceStatus = msg.data;
if (STATE.activePanel === 'services') renderServiceCards();
@@ -3551,6 +3560,70 @@ async function processVoiceRecording() {
reader.readAsDataURL(blob);
}
function handleChatLog(entry) {
if (!entry || !entry.platform) return;
// Add to local log cache.
STATE.chatLogs = STATE.chatLogs || {};
STATE.chatLogs[entry.platform] = STATE.chatLogs[entry.platform] || [];
var logs = STATE.chatLogs[entry.platform];
// Dedup by timestamp+content.
var dup = logs.find(function(l) {
return l.timestamp === entry.timestamp && l.content === entry.content && l.direction === entry.direction;
});
if (dup) return;
logs.unshift(entry);
if (logs.length > 500) logs.length = 500;
// If viewing this platform's detail, prepend to the log container.
if (STATE.activePanel === 'chatPlatforms' && STATE.chatActivePlatform) {
// Check if the entry's platform matches the active config's platform type.
var activeCfg = (STATE.chatConfigs || []).find(function(c) { return c.name === STATE.chatActivePlatform; }) || null;
var activePtype = (activeCfg && activeCfg.platform) || STATE.chatActivePlatform;
if (entry.platform === activePtype) {
prependChatLogEntry(entry);
}
}
}
function prependChatLogEntry(l) {
var container = document.getElementById('chat-log-container');
if (!container) return;
// Check if the container is showing an empty state; if so, clear it first.
var emptyState = container.querySelector('.empty-state');
if (emptyState) container.innerHTML = '';
var filter = STATE.chatLogFilter || 'all';
if (filter === 'incoming' && l.direction !== 'incoming') return;
if (filter === 'outgoing' && l.direction !== 'outgoing') return;
if (filter === 'error' && !l.error && l.success !== false) return;
var arrow = l.direction === 'incoming' ? '← 收到' : '→ 发送';
var color = l.direction === 'incoming' ? 'var(--blue)' : 'var(--green)';
var time = new Date(l.timestamp).toLocaleString('zh-CN', { hour12: false });
var content = (l.content || '').length > 300 ? (l.content || '').substring(0, 297) + '...' : (l.content || '');
var errorTag = (l.error || l.success === false)
? ' <span style="color:var(--red);cursor:help" title="' + escHtml(l.error || '发送失败') + '"></span>' : '';
var sender = escHtml(l.sender_name || l.sender_id || '-');
if (l.sender_name && l.sender_id && l.sender_name !== l.sender_id) {
sender = escHtml(l.sender_name) + ' <span style="color:var(--text3);font-size:10px">(' + escHtml(l.sender_id) + ')</span>';
}
var ctxTag = '';
if (l.channel_id && l.channel_id.indexOf('private_') !== 0 && l.direction === 'incoming') {
ctxTag = ' <span style="color:var(--text3);font-size:10px">[群:' + escHtml(l.channel_id) + ']</span> ';
}
var entryHTML = '<div style="padding:6px 10px;border-bottom:1px solid var(--border);font-size:12px">' +
'<span style="color:' + color + ';font-weight:600">' + arrow + '</span> ' +
'<span style="color:var(--text3)">' + time + '</span> ' +
ctxTag +
'<span style="color:var(--text2)">' + sender + '</span> ' +
'<span>' + escHtml(content) + '</span>' + errorTag +
'</div>';
container.insertAdjacentHTML('afterbegin', entryHTML);
// Keep max 200 entries in DOM.
while (container.children.length > 200) {
container.removeChild(container.lastChild);
}
}
function handleVoiceTranscript(msg) {
var resultEl = document.getElementById('voice-result');
if (!resultEl) return;
@@ -4155,42 +4228,72 @@ function toggleTimelineAutoRefresh(on) {
// ========== 面板: 第三方聊天配置 ==========
var PLATFORM_FIELDS = {
qq: [{ key: 'bot_port', label: 'Bot WebSocket 端口', placeholder: '8096' }],
qq: [
{ key: 'mode', label: '连接模式', type: 'select', options: [
{ value: 'client', label: '客户端模式 (主动连接 NapCat)' },
{ value: 'server', label: '服务端模式 (等待 NapCat 连接)' }
]},
{ key: 'remote_url', label: 'NapCat OneBot WS 地址', placeholder: '如: ws://127.0.0.1:10311', showIf: { key: 'mode', value: 'client' } },
{ key: 'access_token', label: 'Access Token (可选)', placeholder: '留空则不验证' },
{ key: 'bot_port', label: '本地监听端口', placeholder: '8096', showIf: { key: 'mode', value: 'server' } },
{ key: 'send_interval_ms', label: '消息发送间隔 (毫秒, 防封控)', placeholder: '2000' },
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: 123456789,987654321' }
],
telegram: [
{ key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF...' },
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://your-domain.com' }
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://your-domain.com' },
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: 123456789,987654321' }
],
webhook: [
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://hook.example.com/chat' },
{ key: 'secret', label: 'Secret Token', placeholder: '(可选)' }
{ key: 'secret', label: 'Secret Token', placeholder: '(可选)' },
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: user_abc,user_xyz' }
],
wechat: [
{ key: 'corp_id', label: '企业ID (Corp ID)', placeholder: 'ww...' },
{ key: 'corp_secret', label: '应用Secret', placeholder: '' },
{ key: 'agent_id', label: 'Agent ID', placeholder: '1000001' },
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://...' }
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://...' },
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: user_abc,user_xyz' }
],
feishu: [
{ key: 'app_id', label: 'App ID', placeholder: 'cli_...' },
{ key: 'app_secret', label: 'App Secret', placeholder: '' },
{ key: 'verification_token', label: 'Verification Token', placeholder: '' },
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://...' }
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://...' },
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: user_abc,user_xyz' }
],
discord: [
{ key: 'bot_token', label: 'Bot Token', placeholder: 'MT...' },
{ key: 'application_id', label: 'Application ID', placeholder: '123456789...' }
{ key: 'application_id', label: 'Application ID', placeholder: '123456789...' },
{ key: 'admin_uids', label: '管理员账号ID (多个用逗号分隔)', placeholder: '如: 123456789,987654321' }
]
};
var PLATFORM_ICONS = { qq: '🐧', telegram: '✈️', webhook: '🪝', wechat: '💚', feishu: '🕊️', discord: '🎮' };
var PLATFORM_LABELS = { qq: 'QQ', telegram: 'Telegram', webhook: 'Webhook', wechat: 'WeChat', feishu: 'Feishu', discord: 'Discord' };
// 已完整实现的平台 (非桩代码)
var PLATFORM_REAL = { qq: true, telegram: true, webhook: true };
// 静态能力声明 (来自各适配器 Capabilities() 返回值,桥接离线时展示)
var PLATFORM_STATIC_CAPS = {
qq: { max_message_length: 4500, supports_markdown: false, supports_image: true, supports_voice: false, supports_emoji: true, supports_reaction: false, supports_typing_hint: false, recommend_burst_max: 3 },
telegram: { max_message_length: 4096, supports_markdown: true, supports_image: true, supports_voice: true, supports_emoji: true, supports_reaction: false, supports_typing_hint: true, recommend_burst_max: 5 },
webhook: { max_message_length: 4000, supports_markdown: true, supports_image: true, supports_voice: true, supports_emoji: true, supports_reaction: false, supports_typing_hint: false, recommend_burst_max: 3 },
wechat: { max_message_length: 2048, supports_markdown: false, supports_image: true, supports_voice: true, supports_emoji: false, supports_reaction: false, supports_typing_hint: false, recommend_burst_max: 3 },
feishu: { max_message_length: 30000, supports_markdown: true, supports_image: true, supports_voice: false, supports_emoji: true, supports_reaction: true, supports_typing_hint: false, recommend_burst_max: 5 },
discord: { max_message_length: 2000, supports_markdown: true, supports_image: true, supports_voice: false, supports_emoji: true, supports_reaction: true, supports_typing_hint: true, recommend_burst_max: 3 }
};
function startChatAutoRefresh() {
stopChatAutoRefresh();
// Periodic refresh for overview + connection status (logs are now real-time via WebSocket).
STATE.chatConfigsAutoRefresh = setInterval(function() {
if (STATE.activePanel === 'chatPlatforms') {
loadChatConfigs();
if (STATE.chatActivePlatform) refreshChatLogs(STATE.chatActivePlatform);
loadChatOverview();
if (STATE.chatActivePlatform) {
loadChatPlatformInfo(STATE.chatActivePlatform);
}
}
}, 10000);
}
@@ -4199,59 +4302,131 @@ function stopChatAutoRefresh() {
if (STATE.chatConfigsAutoRefresh) { clearInterval(STATE.chatConfigsAutoRefresh); STATE.chatConfigsAutoRefresh = null; }
}
function renderChatPlatformsPanel() {
if (STATE.chatActivePlatform) { renderChatPlatformDetail(STATE.chatActivePlatform); return; }
var panel = document.getElementById('panel-chatPlatforms');
panel.innerHTML = '<div class="card"><div class="card-header"><span class="card-title">🔗 平台配置列表</span>' +
'<button class="btn btn-sm btn-accent" onclick="showChatConfigForm()"> 添加配置</button></div>' +
'<div class="table-wrap"><table id="chat-configs-table"><thead><tr>' +
'<th>平台</th><th>启用</th><th>连接</th><th>关键配置</th><th>更新时间</th><th>操作</th>' +
'</tr></thead><tbody id="chat-configs-tbody">' +
'<tr><td colspan="6"><div class="empty-state"><div class="icon">💬</div>加载中...</div></td></tr></tbody></table></div></div>';
document.getElementById('panel-actions').innerHTML = '<button class="btn btn-sm" onclick="refreshChatConfigs()">🔄 刷新</button>';
loadChatConfigs();
// ---- 概览栏 ----
async function loadChatOverview() {
var bar = document.getElementById('chat-overview-bar');
if (!bar) return;
// 并行获取平台状态 + 配置列表
var [platResp, cfgResp] = await Promise.all([
api('/api/chat-platforms/platforms').catch(function() { return { error: true }; }),
api('/api/chat-platforms/configs').catch(function() { return { error: true }; })
]);
var platforms = platResp.platforms || [];
var configs = cfgResp.configs || [];
var bridgeDown = !!platResp.error;
STATE.chatPlatforms = platforms;
STATE.chatConfigs = configs;
var connected = 0, realCount = 0;
platforms.forEach(function(p) {
if (p.connected) connected++;
if (PLATFORM_REAL[p.name]) realCount++;
});
bar.innerHTML =
'<div class="overview-stat"><span class="overview-stat-label">桥接服务</span>' +
'<span class="overview-stat-value">' + (bridgeDown ? '<span style="color:var(--red)">离线</span>' : '<span style="color:var(--green)">运行中</span>') + '</span></div>' +
'<div class="overview-stat"><span class="overview-stat-label">已连接</span>' +
'<span class="overview-stat-value" style="color:' + (connected > 0 ? 'var(--green)' : 'var(--text2)') + '">' + connected + '/' + platforms.length + '</span></div>' +
'<div class="overview-stat"><span class="overview-stat-label">已实现</span>' +
'<span class="overview-stat-value">' + realCount + '/6 (3桩)</span></div>' +
'<div class="overview-stat"><span class="overview-stat-label">身份映射</span>' +
'<span class="overview-stat-value" id="ov-ident-count"></span></div>';
// 异步加载身份数量
api('/api/chat-platforms/identities').then(function(d) {
var el = document.getElementById('ov-ident-count');
if (!el) return;
var total = 0;
if (d && !d.error) { for (var k in d) { if (d.hasOwnProperty(k)) total += d[k].length; } }
el.textContent = total;
}).catch(function() {});
}
async function loadChatConfigs() {
var data = await api('/api/chat-platforms/configs');
var tbody = document.getElementById('chat-configs-tbody');
if (!tbody) return;
if (data.error) { tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div></td></tr>'; return; }
STATE.chatConfigs = data.configs || [];
// ---- 列表视图 ----
function renderChatPlatformsPanel() {
if (STATE.chatActivePlatform) { renderChatPlatformDetail(STATE.chatActivePlatform); return; }
STATE.chatLogFilter = 'all';
var panel = document.getElementById('panel-chatPlatforms');
panel.innerHTML =
'<div class="card" style="margin-bottom:14px"><div class="card-body" id="chat-overview-bar" style="display:flex;gap:24px;flex-wrap:wrap;padding:12px 16px">' +
'<div class="empty-state">加载中...</div></div></div>' +
'<div class="card"><div class="card-header"><span class="card-title">🔗 平台配置列表</span>' +
'<button class="btn btn-sm btn-accent" onclick="showChatConfigForm()"> 添加配置</button></div>' +
'<div class="table-wrap"><table id="chat-configs-table"><thead><tr>' +
'<th>平台</th><th>状态</th><th>能力</th><th>关键配置</th><th>更新时间</th><th>操作</th>' +
'</tr></thead><tbody id="chat-configs-tbody">' +
'<tr><td colspan="6"><div class="empty-state"><div class="icon">💬</div>加载中...</div></td></tr></tbody></table></div></div>';
document.getElementById('panel-actions').innerHTML =
'<button class="btn btn-sm" onclick="refreshChatAll()">🔄 刷新全部</button>' +
'<button class="btn btn-sm" onclick="loadChatIdentities()">👤 身份映射</button>' +
'<button class="btn btn-sm" onclick="showBlocklistSettings()">🚫 黑白名单</button>';
loadChatOverview();
renderChatConfigsTable();
}
function refreshChatAll() {
loadChatOverview();
renderChatConfigsTable();
}
function renderChatConfigsTable() {
var tbody = document.getElementById('chat-configs-tbody');
if (!tbody) return;
// 优先使用刚从 loadChatOverview 拉到的数据
var configs = STATE.chatConfigs;
if (configs.length === 0) {
if (!configs || configs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="icon">💬</div>暂无配置,点击「添加配置」创建</div></td></tr>';
return;
}
tbody.innerHTML = configs.map(function(c) {
var icon = PLATFORM_ICONS[c.name] || '🔗';
var label = c.label || PLATFORM_LABELS[c.name] || c.name;
var connBadge = c.connected ? '<span class="badge badge-running">已连接</span>' : '<span class="badge badge-stopped">未连接</span>';
var enabledBadge = c.enabled !== false ? '<span class="badge badge-running">启用</span>' : '<span class="badge badge-stopped">禁用</span>';
var ptype = c.platform || c.name;
var icon = PLATFORM_ICONS[ptype] || '🔗';
var pfx = (c.platform && c.platform !== c.name) ? ('<span style="color:var(--text3);font-size:10px">' + escHtml(c.platform) + '/</span>') : '';
var label = c.label || (PLATFORM_LABELS[ptype] ? (PLATFORM_LABELS[ptype] + ' (' + escHtml(c.name) + ')') : c.name);
var isReal = PLATFORM_REAL[ptype];
var implBadge = isReal ? '<span class="badge badge-running" title="完整实现"></span>'
: '<span class="badge badge-stopped" title="桩代码 (待开发)" style="opacity:.7">🔧 桩</span>';
var connBadge = c.connected
? '<span class="badge badge-running">● 已连接</span>'
: '<span class="badge badge-stopped">○ 未连接</span>';
var enabledBadge = c.enabled !== false
? '<span class="badge badge-running">启用</span>'
: '<span class="badge badge-stopped">禁用</span>';
var keys = (c.fields && Object.keys(c.fields).length > 0)
? Object.keys(c.fields).map(function(k) { return k + '=' + (c.fields[k] ? '***' : '(空)'); }).join(', ')
: '—';
var capsHTML = buildCapsHTML(ptype);
if (!capsHTML) capsHTML = '<span style="color:var(--text3)"></span>';
var updated = c.updated_at ? timeAgo(c.updated_at) : '—';
return '<tr>' +
'<td><strong>' + icon + ' ' + escHtml(label) + '</strong></td>' +
'<td>' + enabledBadge + '</td>' +
'<td>' + connBadge + '</td>' +
'<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(keys) + '</td>' +
return '<tr style="cursor:pointer" onclick="editChatConfig(\'' + escHtml(c.name) + '\')">' +
'<td><strong>' + pfx + icon + ' ' + escHtml(label) + '</strong> ' + implBadge + '</td>' +
'<td>' + enabledBadge + ' ' + connBadge + '</td>' +
'<td style="font-size:11px">' + capsHTML + '</td>' +
'<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(keys) + '</td>' +
'<td>' + updated + '</td>' +
'<td><div class="btn-group">' +
'<td><div class="btn-group" onclick="event.stopPropagation()">' +
'<button class="btn btn-xs" onclick="editChatConfig(\'' + escHtml(c.name) + '\')">✏️ 编辑</button>' +
'<button class="btn btn-xs btn-red" onclick="deleteChatConfig(\'' + escHtml(c.name) + '\')">🗑</button>' +
'</div></td></tr>';
}).join('');
}
function refreshChatConfigs() { loadChatConfigs(); }
function buildCapsHTML(name) {
var caps = (STATE.chatCaps && STATE.chatCaps[name]) || PLATFORM_STATIC_CAPS[name];
if (!caps) return '';
var items = [];
if (caps.supports_markdown) items.push('<span title="支持 Markdown">📝</span>');
if (caps.supports_image) items.push('<span title="支持图片">🖼️</span>');
if (caps.supports_voice) items.push('<span title="支持语音">🎤</span>');
if (caps.supports_emoji) items.push('<span title="支持表情">😊</span>');
if (caps.supports_reaction) items.push('<span title="支持回应">👍</span>');
if (caps.supports_typing_hint) items.push('<span title="支持输入状态">⌨️</span>');
items.push('<span style="color:var(--text3)">' + caps.max_message_length + '字</span>');
return items.join(' ');
}
function refreshChatConfigs() { loadChatOverview(); renderChatConfigsTable(); }
function showChatConfigForm() {
var panel = document.getElementById('panel-chatPlatforms');
@@ -4260,58 +4435,165 @@ function showChatConfigForm() {
'<button class="btn btn-sm" onclick="STATE.chatActivePlatform=null;renderChatPlatformsPanel();">← 取消</button></div>' +
'<div class="cards-grid cards-3">' +
options.map(function(p) {
var isReal = PLATFORM_REAL[p];
return '<div class="card" style="cursor:pointer;text-align:center;padding:20px" onclick="startNewConfig(\'' + p + '\')">' +
'<div style="font-size:32px;margin-bottom:8px">' + (PLATFORM_ICONS[p] || '🔗') + '</div>' +
'<div style="font-weight:600">' + (PLATFORM_LABELS[p] || p) + '</div></div>';
'<div style="font-weight:600">' + (PLATFORM_LABELS[p] || p) + '</div>' +
'<div style="font-size:10px;color:var(--text3);margin-top:4px">' + (isReal ? '完整实现' : '桩代码') + '</div></div>';
}).join('') + '</div></div>';
document.getElementById('panel-actions').innerHTML = '';
}
function startNewConfig(name) { STATE.chatActivePlatform = name; renderChatPlatformsPanel(); }
function startNewConfig(name) {
if (name === 'qq') { showNewQQConfigDialog(); return; }
STATE.chatActivePlatform = name;
renderChatPlatformsPanel();
}
function showNewQQConfigDialog() {
var defaultName = 'qq-' + Date.now().toString(36);
var panel = document.getElementById('panel-chatPlatforms');
panel.innerHTML =
'<div class="card"><div class="card-header"><span class="card-title">🐧 新建 QQ 配置</span>' +
'<button class="btn btn-sm" onclick="STATE.chatActivePlatform=null;renderChatPlatformsPanel();">← 取消</button></div>' +
'<div class="card-body">' +
'<div class="form-group"><label>配置名称</label>' +
'<input type="text" id="new-qq-name" value="' + defaultName + '" placeholder="如: qq-home, qq-work"></div>' +
'<div class="form-group"><label>连接模式</label>' +
'<select id="new-qq-mode" style="width:100%;padding:8px 12px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' +
'<option value="client">客户端模式 (主动连接 NapCat)</option>' +
'<option value="server">服务端模式 (等待 NapCat 连接)</option></select></div>' +
'<div class="btn-group" style="margin-top:12px">' +
'<button class="btn btn-accent" onclick="createQQConfig()">创建配置</button></div></div></div>';
document.getElementById('panel-actions').innerHTML = '';
}
function createQQConfig() {
var nameEl = document.getElementById('new-qq-name');
var modeEl = document.getElementById('new-qq-mode');
var name = (nameEl && nameEl.value.trim()) || ('qq-' + Date.now().toString(36));
var mode = modeEl ? modeEl.value : 'client';
var newCfg = { name: name, platform: 'qq', enabled: true, label: '', fields: { mode: mode }, connected: false };
var configs = STATE.chatConfigs || [];
configs.push(newCfg);
STATE.chatConfigs = configs;
STATE.chatActivePlatform = name;
renderChatPlatformsPanel();
}
function editChatConfig(name) {
if (!STATE.chatConfigs.some(function(c) { return c.name === name; })) {
STATE.chatActivePlatform = name;
renderChatPlatformsPanel();
} else {
STATE.chatActivePlatform = name;
renderChatPlatformsPanel();
STATE.chatActivePlatform = name;
STATE.chatLogFilter = 'all';
renderChatPlatformsPanel();
}
// ---- 平台详情页 ----
async function loadChatPlatformInfo(name) {
var data = await api('/api/chat-platforms/platforms/' + encodeURIComponent(name)).catch(function() { return { error: true }; });
STATE.chatBridgeDown = !!data.error;
// 桥接服务离线提示
var banner = document.getElementById('chat-bridge-banner');
if (banner) {
banner.innerHTML = data.error
? '<div class="card" style="border-color:var(--yellow);background:var(--yellow-bg);padding:12px 16px">' +
'<strong>⚠️ 平台桥接服务未运行</strong> — 以下配置和日志依赖 platform-bridge 服务。' +
'请在 <a href="javascript:switchPanel(\'services\')" style="color:var(--accent);text-decoration:underline">服务管理</a> 中启动 platform-bridge 或将其加入 docker-compose.yml。</div>'
: '';
}
// 更新能力缓存
STATE.chatCaps = STATE.chatCaps || {};
if (!data.error) {
STATE.chatCaps[name] = data.capabilities;
STATE.chatPlatformInfo = STATE.chatPlatformInfo || {};
STATE.chatPlatformInfo[name] = data;
}
// 更新页面中的状态指示
var statusEl = document.getElementById('platform-status-indicator');
if (statusEl && !data.error) {
var conn = data.connected;
statusEl.innerHTML = conn
? '<span class="badge badge-running">● 已连接</span>'
: '<span class="badge badge-stopped">○ 未连接</span>';
}
var cfgForCaps = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
var ptypeForCaps = (cfgForCaps && cfgForCaps.platform) || name;
var capsHTML = buildCapsHTML(ptypeForCaps);
var capsEl = document.getElementById('platform-caps');
if (capsEl) capsEl.innerHTML = capsHTML || '<span style="color:var(--text3)"></span>';
}
function renderChatPlatformDetail(name) {
var cfg = null;
for (var i = 0; i < STATE.chatConfigs.length; i++) {
if (STATE.chatConfigs[i].name === name) { cfg = STATE.chatConfigs[i]; break; }
}
var icon = PLATFORM_ICONS[name] || '🔗';
var cfg = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
STATE.chatLogFilter = STATE.chatLogFilter || 'all';
var ptype = (cfg && cfg.platform) || name;
var icon = PLATFORM_ICONS[ptype] || '🔗';
var isReal = PLATFORM_REAL[ptype];
var panel = document.getElementById('panel-chatPlatforms');
var logLimit = STATE.chatLogLimit || 100;
var filterOpts = '<option value="all"' + (STATE.chatLogFilter === 'all' ? ' selected' : '') + '>全部</option>' +
'<option value="incoming"' + (STATE.chatLogFilter === 'incoming' ? ' selected' : '') + '>← 收到</option>' +
'<option value="outgoing"' + (STATE.chatLogFilter === 'outgoing' ? ' selected' : '') + '>→ 发送</option>' +
'<option value="error"' + (STATE.chatLogFilter === 'error' ? ' selected' : '') + '>⚠ 错误</option>';
panel.innerHTML =
'<div style="margin-bottom:14px"><button class="btn btn-sm" onclick="STATE.chatActivePlatform=null;renderChatPlatformsPanel();">← 返回列表</button></div>' +
'<div class="card"><div class="card-header"><span class="card-title">' + icon + ' ' + escHtml(name) + ' 配置</span><span id="cfg-save-status"></span></div>' +
'<div id="chat-bridge-banner" style="margin-bottom:14px"></div>' +
// 状态卡片
'<div class="card" style="margin-bottom:14px"><div class="card-header"><span class="card-title">' + icon + ' ' + (ptype !== name ? escHtml(ptype) + '/' : '') + escHtml(name) + ' 状态</span></div>' +
'<div class="card-body" style="display:flex;gap:24px;flex-wrap:wrap;align-items:center">' +
'<div><span style="color:var(--text3)">连接: </span><span id="platform-status-indicator">' +
(cfg && cfg.connected ? '<span class="badge badge-running">● 已连接</span>' : '<span class="badge badge-stopped">○ 未连接</span>') + '</span></div>' +
'<div><span style="color:var(--text3)">实现: </span>' + (isReal ? '<span class="badge badge-running">完整实现</span>' : '<span class="badge badge-stopped" style="opacity:.7">🔧 桩代码 (待开发)</span>') + '</div>' +
'<div><span style="color:var(--text3)">能力: </span><span id="platform-caps">' + (buildCapsHTML(ptype) || '<span style="color:var(--text3)"></span>') + '</span></div>' +
'<div style="margin-left:auto"><button class="btn btn-xs" onclick="loadChatPlatformInfo(\'' + escHtml(name) + '\')">🔄 刷新状态</button></div>' +
'</div></div>' +
// 配置 + 身份映射 并排
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px">' +
'<div class="card"><div class="card-header"><span class="card-title">⚙️ 配置</span><span id="cfg-save-status"></span></div>' +
'<div class="card-body" id="chat-config-form"></div></div>' +
'<div class="card" style="margin-top:14px"><div class="card-header"><span class="card-title">📋 消息日志 (最近 ' + STATE.chatLogLimit + ' 条)</span>' +
'<div class="card" id="chat-identity-card"><div class="card-header"><span class="card-title">👤 身份映射</span></div>' +
'<div class="card-body" id="chat-identity-body"><div class="empty-state">加载中...</div></div></div>' +
'</div>' +
// 消息日志
'<div class="card"><div class="card-header"><span class="card-title">📋 消息日志 (最近 ' + logLimit + ' 条)</span>' +
'<div class="btn-group">' +
'<button class="btn btn-xs" onclick="refreshChatLogs(\'' + escHtml(name) + '\')">🔄 刷新</button>' +
'<select id="chat-log-filter" onchange="STATE.chatLogFilter=this.value;refreshChatLogs(\'' + escHtml(name) + '\')" ' +
'style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' + filterOpts + '</select>' +
'<select id="chat-log-limit" onchange="STATE.chatLogLimit=parseInt(this.value);refreshChatLogs(\'' + escHtml(name) + '\')" ' +
'style="width:auto;padding:4px 8px;font-size:11px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' +
'<option value="50">50条</option><option value="100" selected>100条</option><option value="200">200条</option><option value="500">500条</option></select></div></div>' +
'<option value="50"' + (logLimit === 50 ? ' selected' : '') + '>50条</option><option value="100"' + (logLimit === 100 ? ' selected' : '') + '>100条</option><option value="200"' + (logLimit === 200 ? ' selected' : '') + '>200条</option><option value="500"' + (logLimit === 500 ? ' selected' : '') + '>500条</option></select>' +
'<button class="btn btn-xs" onclick="refreshChatLogs(\'' + escHtml(name) + '\')">🔄 刷新</button></div></div>' +
'<div id="chat-log-container" style="max-height:400px;overflow-y:auto;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px">' +
'<div class="empty-state"><div class="icon">📝</div>加载中...</div></div></div>';
document.getElementById('panel-actions').innerHTML = '';
renderChatConfigForm(name, cfg);
loadChatPlatformInfo(name);
loadChatIdentitiesForPlatform(name);
refreshChatLogs(name);
}
function renderChatConfigForm(name, cfg) {
var fields = PLATFORM_FIELDS[name] || [];
var platformType = (cfg && cfg.platform) || name;
var fields = PLATFORM_FIELDS[platformType] || [];
var container = document.getElementById('chat-config-form');
if (!container) return;
var currentFields = (cfg && cfg.fields) || {};
var enabled = cfg ? (cfg.enabled !== false) : true;
var fieldsHTML = fields.map(function(f) {
var val = currentFields[f.key] || '';
return '<div class="form-group"><label>' + escHtml(f.label) + '</label>' +
var display = '';
if (f.showIf) {
var condVal = currentFields[f.showIf.key] || '';
if (condVal !== f.showIf.value) display = 'display:none';
}
if (f.type === 'select') {
return '<div class="form-group" style="' + display + '" data-cond="' + escHtml(f.showIf ? f.showIf.key + ':' + f.showIf.value : '') + '"><label>' + escHtml(f.label) + '</label>' +
'<select id="cfg-field-' + escHtml(f.key) + '" onchange="onCfgFieldChange(\'' + escHtml(name) + '\')" style="width:100%;padding:8px 12px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' +
(f.options || []).map(function(o) {
return '<option value="' + escHtml(o.value) + '"' + (val === o.value ? ' selected' : '') + '>' + escHtml(o.label) + '</option>';
}).join('') + '</select></div>';
}
return '<div class="form-group" style="' + display + '" data-cond="' + escHtml(f.showIf ? f.showIf.key + ':' + f.showIf.value : '') + '"><label>' + escHtml(f.label) + '</label>' +
'<input type="text" id="cfg-field-' + escHtml(f.key) + '" value="' + escHtml(val) + '" placeholder="' + escHtml(f.placeholder || '') + '"></div>';
}).join('');
container.innerHTML =
@@ -4323,9 +4605,26 @@ function renderChatConfigForm(name, cfg) {
'<div class="btn-group" style="margin-top:12px"><button class="btn btn-sm btn-accent" onclick="saveChatConfig(\'' + escHtml(name) + '\')">💾 保存配置</button></div>';
}
function onCfgFieldChange(name) {
var cfg = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
var platformType = (cfg && cfg.platform) || name;
var fieldDefs = PLATFORM_FIELDS[platformType] || [];
var tempFields = {};
fieldDefs.forEach(function(f) {
var el = document.getElementById('cfg-field-' + f.key);
if (el) tempFields[f.key] = el.value;
});
var enabledEl = document.getElementById('cfg-field-enabled');
var labelEl = document.getElementById('cfg-field-label');
var tempCfg = { name: name, platform: (cfg && cfg.platform) || name, fields: tempFields, enabled: enabledEl ? enabledEl.checked : true, label: labelEl ? labelEl.value : '' };
renderChatConfigForm(name, tempCfg);
}
async function saveChatConfig(name) {
var cfg = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
var platformType = (cfg && cfg.platform) || name;
var fields = {};
var fieldDefs = PLATFORM_FIELDS[name] || [];
var fieldDefs = PLATFORM_FIELDS[platformType] || [];
fieldDefs.forEach(function(f) {
var el = document.getElementById('cfg-field-' + f.key);
if (el) fields[f.key] = el.value;
@@ -4336,11 +4635,17 @@ async function saveChatConfig(name) {
var label = labelEl ? labelEl.value : '';
var data = await api('/api/chat-platforms/configs/' + encodeURIComponent(name), {
method: 'POST',
body: JSON.stringify({ name: name, enabled: enabled, label: label, fields: fields })
body: JSON.stringify({ name: name, platform: platformType, enabled: enabled, label: label, fields: fields })
});
if (data.error) { showToast('保存失败: ' + data.error, 'error'); return; }
showToast('配置已保存 (需重启平台桥接服务生效)', 'success');
await loadChatConfigs();
showToast('配置已保存 (实时生效)', 'success');
// Update STATE with saved config so re-render preserves form inputs.
var oldCfg = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
var savedCfg = { name: name, platform: platformType, enabled: enabled, label: label, fields: fields, connected: (oldCfg && oldCfg.connected) || false };
var configs = STATE.chatConfigs || [];
var idx = configs.findIndex(function(c) { return c.name === name; });
if (idx >= 0) { configs[idx] = savedCfg; } else { configs.push(savedCfg); }
STATE.chatConfigs = configs;
renderChatPlatformDetail(name);
}
@@ -4350,31 +4655,202 @@ async function deleteChatConfig(name) {
if (data.error) { showToast('删除失败: ' + data.error, 'error'); return; }
showToast('配置已删除', 'success');
STATE.chatActivePlatform = null;
await loadChatConfigs();
loadChatOverview();
renderChatPlatformsPanel();
}
// ---- 身份映射 ----
async function loadChatIdentities() {
var data = await api('/api/chat-platforms/identities').catch(function() { return { error: true }; });
STATE.chatIdentities = data;
// 以弹窗形式展示
var html = '<div class="card"><div class="card-header"><span class="card-title">👤 身份映射列表</span>' +
'<button class="btn btn-sm" onclick="this.closest(\'.card\').remove()">✕ 关闭</button></div>' +
'<div class="card-body">';
if (data.error) {
html += '<div class="empty-state"><div class="icon">⚠️</div>桥接服务不可达</div>';
} else {
var total = 0;
for (var plat in data) { if (data.hasOwnProperty(plat)) total += data[plat].length; }
if (total === 0) {
html += '<div class="empty-state"><div class="icon">👤</div>暂无身份映射 (在 platform-bridge 启动时通过环境变量 QQ_ADMIN_UID / TELEGRAM_ADMIN_UID 预设)</div>';
} else {
html += '<div class="table-wrap"><table><thead><tr><th>平台</th><th>平台UID</th><th>Cyrene用户</th><th>昵称</th><th>权限级别</th></tr></thead><tbody>';
for (var plat in data) {
if (!data.hasOwnProperty(plat)) continue;
(data[plat] || []).forEach(function(id) {
var permBadge = id.permission_level === 'admin'
? '<span class="badge badge-running">管理员</span>'
: '<span class="badge">' + escHtml(id.permission_level || '—') + '</span>';
html += '<tr><td>' + (PLATFORM_ICONS[plat] || '') + ' ' + escHtml(plat) + '</td>' +
'<td><code>' + escHtml(id.platform_uid) + '</code></td>' +
'<td>' + escHtml(id.cyrene_user_id) + '</td>' +
'<td>' + escHtml(id.nickname || '—') + '</td>' +
'<td>' + permBadge + '</td></tr>';
});
}
html += '</tbody></table></div>';
}
}
html += '</div></div>';
// 插入到列表上方
var panel = document.getElementById('panel-chatPlatforms');
var existing = panel.querySelector('.card:first-child');
var div = document.createElement('div');
div.style.cssText = 'margin-bottom:14px';
div.innerHTML = html;
panel.insertBefore(div, existing);
}
async function loadChatIdentitiesForPlatform(name) {
var bodyEl = document.getElementById('chat-identity-body');
if (!bodyEl) return;
var cfg = (STATE.chatConfigs || []).find(function(c) { return c.name === name; }) || null;
var ptype = (cfg && cfg.platform) || name;
var data = await api('/api/chat-platforms/identities').catch(function() { return { error: true }; });
if (data.error) {
bodyEl.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>桥接服务不可达</div>';
return;
}
var identities = data[ptype] || [];
STATE.chatIdentities = data;
if (identities.length === 0) {
bodyEl.innerHTML = '<div class="empty-state" style="padding:20px"><div class="icon">👤</div>暂无此平台的身份映射' +
'<div style="font-size:11px;color:var(--text3);margin-top:4px">通过环境变量 ' + ptype.toUpperCase() + '_ADMIN_UID 预设管理员身份</div></div>';
return;
}
bodyEl.innerHTML = '<div style="max-height:200px;overflow-y:auto">' +
identities.map(function(id) {
var permBadge = id.permission_level === 'admin'
? '<span class="badge badge-running">管理员</span>'
: '<span class="badge">' + escHtml(id.permission_level || '—') + '</span>';
return '<div style="padding:8px;border-bottom:1px solid var(--border);font-size:12px;display:flex;justify-content:space-between;align-items:center">' +
'<div><code>' + escHtml(id.platform_uid) + '</code><strong>' + escHtml(id.cyrene_user_id) + '</strong>' +
(id.nickname ? ' (' + escHtml(id.nickname) + ')' : '') + '</div>' +
'<div>' + permBadge + '</div></div>';
}).join('') + '</div>';
}
// ---- 黑名单/白名单设置 ----
async function showBlocklistSettings() {
var panel = document.getElementById('panel-chatPlatforms');
var data = await api('/api/chat-platforms/settings/blocklist').catch(function() { return null; });
var settings = (data && !data.error) ? data : { mode: 'blacklist', group_ids: [], user_ids: [] };
STATE._blocklistSettings = settings;
var groupIDs = (settings.group_ids || []).join('\n');
var userIDs = (settings.user_ids || []).join('\n');
var div = document.createElement('div');
div.id = 'blocklist-settings-card';
div.style.cssText = 'margin-bottom:14px';
div.innerHTML =
'<div class="card"><div class="card-header"><span class="card-title">🚫 黑名单/白名单设置</span>' +
'<button class="btn btn-sm" onclick="hideBlocklistSettings()">✕ 关闭</button></div>' +
'<div class="card-body">' +
'<div class="form-group"><label>模式</label>' +
'<select id="blocklist-mode" style="width:100%;padding:8px 12px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px">' +
'<option value="blacklist"' + (settings.mode === 'blacklist' ? ' selected' : '') + '>黑名单模式 (屏蔽列表中的群号/用户)</option>' +
'<option value="whitelist"' + (settings.mode === 'whitelist' ? ' selected' : '') + '>白名单模式 (仅回复列表中的群号/用户)</option>' +
'</select></div>' +
'<div style="color:var(--text3);font-size:11px;margin-bottom:12px">' +
(settings.mode === 'blacklist'
? '黑名单模式: 不对名单内的群号或私聊用户进行回复,但消息仍会显示在日志中'
: '白名单模式: 只对白名单内的群号和私聊用户进行回复,消息仍会显示在日志中') +
'</div>' +
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">' +
'<div class="form-group"><label>群号列表 (每行一个)</label>' +
'<textarea id="blocklist-group-ids" rows="6" style="width:100%;padding:8px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px">' + escHtml(groupIDs) + '</textarea></div>' +
'<div class="form-group"><label>私聊用户ID列表 (每行一个)</label>' +
'<textarea id="blocklist-user-ids" rows="6" style="width:100%;padding:8px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px">' + escHtml(userIDs) + '</textarea></div>' +
'</div>' +
'<div class="btn-group" style="margin-top:12px">' +
'<button class="btn btn-sm btn-accent" onclick="saveBlocklistSettings()">💾 保存设置</button>' +
'<span id="blocklist-save-status" style="margin-left:8px"></span></div>' +
'</div></div>';
var existing = panel.querySelector('#blocklist-settings-card');
if (existing) existing.remove();
var firstChild = panel.firstChild;
if (firstChild) { panel.insertBefore(div, firstChild); } else { panel.appendChild(div); }
// 监听模式切换更新提示文字
var modeEl = document.getElementById('blocklist-mode');
if (modeEl) {
modeEl.addEventListener('change', function() {
var hint = this.value === 'blacklist'
? '黑名单模式: 不对名单内的群号或私聊用户进行回复,但消息仍会显示在日志中'
: '白名单模式: 只对白名单内的群号和私聊用户进行回复,消息仍会显示在日志中';
var next = this.parentElement.parentElement.querySelector('div[style]');
if (next) next.textContent = hint;
});
}
}
function hideBlocklistSettings() {
var card = document.getElementById('blocklist-settings-card');
if (card) card.remove();
}
async function saveBlocklistSettings() {
var modeEl = document.getElementById('blocklist-mode');
var groupEl = document.getElementById('blocklist-group-ids');
var userEl = document.getElementById('blocklist-user-ids');
var statusEl = document.getElementById('blocklist-save-status');
var mode = modeEl ? modeEl.value : 'blacklist';
var groupIDs = (groupEl ? groupEl.value : '').split('\n').map(function(s) { return s.trim(); }).filter(function(s) { return s !== ''; });
var userIDs = (userEl ? userEl.value : '').split('\n').map(function(s) { return s.trim(); }).filter(function(s) { return s !== ''; });
var data = await api('/api/chat-platforms/settings/blocklist', {
method: 'POST',
body: JSON.stringify({ mode: mode, group_ids: groupIDs, user_ids: userIDs })
});
if (data.error) {
if (statusEl) { statusEl.innerHTML = '<span style="color:var(--red)">保存失败: ' + escHtml(data.error) + '</span>'; }
return;
}
if (statusEl) { statusEl.innerHTML = '<span style="color:var(--green)">已保存</span>'; }
STATE._blocklistSettings = data.settings || { mode: mode, group_ids: groupIDs, user_ids: userIDs };
showToast('黑名单/白名单设置已保存', 'success');
}
// ---- 消息日志 ----
async function refreshChatLogs(name) {
var limit = STATE.chatLogLimit || 100;
var filter = STATE.chatLogFilter || 'all';
var data = await api('/api/chat-platforms/logs/' + encodeURIComponent(name) + '?limit=' + limit);
var container = document.getElementById('chat-log-container');
if (!container) return;
if (data.error) { container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>'; return; }
var logs = data.logs || [];
// 应用过滤
if (filter === 'incoming') logs = logs.filter(function(l) { return l.direction === 'incoming'; });
else if (filter === 'outgoing') logs = logs.filter(function(l) { return l.direction === 'outgoing'; });
else if (filter === 'error') logs = logs.filter(function(l) { return l.error || l.success === false; });
STATE.chatLogs = STATE.chatLogs || {};
STATE.chatLogs[name] = logs;
if (logs.length === 0) { container.innerHTML = '<div class="empty-state"><div class="icon">📝</div>暂无消息日志</div>'; return; }
if (logs.length === 0) { container.innerHTML = '<div class="empty-state"><div class="icon">📝</div>暂无匹配的消息日志</div>'; return; }
container.innerHTML = logs.map(function(l) {
var arrow = l.direction === 'incoming' ? '← 收到' : '→ 发送';
var color = l.direction === 'incoming' ? 'var(--blue)' : 'var(--green)';
var time = new Date(l.timestamp).toLocaleString('zh-CN', { hour12: false });
var content = (l.content || '').length > 300 ? (l.content || '').substring(0, 297) + '...' : (l.content || '');
var errorTag = (l.error || l.success === false)
? ' <span style="color:var(--red);cursor:help" title="' + escHtml(l.error || '发送失败') + '"></span>' : '';
// Build sender info: name (id).
var sender = escHtml(l.sender_name || l.sender_id || '-');
if (l.sender_name && l.sender_id && l.sender_name !== l.sender_id) {
sender = escHtml(l.sender_name) + ' <span style="color:var(--text3);font-size:10px">(' + escHtml(l.sender_id) + ')</span>';
}
// Build channel context tag for group messages.
var ctxTag = '';
if (l.channel_id && l.channel_id.indexOf('private_') !== 0 && l.direction === 'incoming') {
ctxTag = ' <span style="color:var(--text3);font-size:10px">[群:' + escHtml(l.channel_id) + ']</span> ';
}
return '<div style="padding:6px 10px;border-bottom:1px solid var(--border);font-size:12px">' +
'<span style="color:' + color + ';font-weight:600">' + arrow + '</span> ' +
'<span style="color:var(--text3)">' + time + '</span> ' +
'<span style="color:var(--text2)">[' + escHtml(l.sender_name || l.sender_id || '-') + ']</span> ' +
'<span>' + escHtml(content) + '</span>' +
(l.error ? ' <span style="color:var(--red)">' + escHtml(l.error) + '</span>' : '') +
ctxTag +
'<span style="color:var(--text2)">' + sender + '</span> ' +
'<span>' + escHtml(content) + '</span>' + errorTag +
'</div>';
}).join('');
}
+102
View File
@@ -79,6 +79,67 @@ processManager.on('log', (serviceId, stream, text) => {
}
});
// ========== 平台桥接实时日志流 ==========
let logStreamWs = null;
let logStreamReconnectTimer = null;
function connectPlatformBridgeLogStream() {
if (logStreamReconnectTimer) { clearTimeout(logStreamReconnectTimer); logStreamReconnectTimer = null; }
if (logStreamWs && (logStreamWs.readyState === WebSocket.OPEN || logStreamWs.readyState === WebSocket.CONNECTING)) return;
const wsUrl = PLATFORM_BRIDGE_URL.replace(/^http/, 'ws') + '/ws/logs';
console.log(`[LogStream] 连接 ${wsUrl} ...`);
try {
logStreamWs = new WebSocket(wsUrl);
} catch (err) {
console.error(`[LogStream] 创建连接失败: ${err.message}`);
scheduleLogStreamReconnect();
return;
}
logStreamWs.on('open', () => {
console.log('[LogStream] 已连接,实时日志推送中');
});
logStreamWs.on('message', (raw) => {
try {
const entry = JSON.parse(raw.toString());
broadcast('chat-log', entry);
} catch {}
});
logStreamWs.on('close', () => {
console.log('[LogStream] 连接断开');
logStreamWs = null;
scheduleLogStreamReconnect();
});
logStreamWs.on('error', (err) => {
console.error(`[LogStream] 错误: ${err.message}`);
logStreamWs = null;
scheduleLogStreamReconnect();
});
}
function scheduleLogStreamReconnect() {
if (logStreamReconnectTimer) return;
logStreamReconnectTimer = setTimeout(() => {
logStreamReconnectTimer = null;
connectPlatformBridgeLogStream();
}, 5000);
}
// 启动时连接,后续 platform-bridge 重启时通过状态变化自动重连。
connectPlatformBridgeLogStream();
// 监听服务状态:platform-bridge 上线后重连。
setInterval(() => {
if (!logStreamWs || logStreamWs.readyState === WebSocket.CLOSED || logStreamWs.readyState === WebSocket.CLOSING) {
connectPlatformBridgeLogStream();
}
}, 15000);
// ========== Gateway 代理辅助函数 ==========
/** 缓存的 JWT token 和过期时间 */
@@ -740,6 +801,47 @@ app.get('/api/chat-platforms/logs/:name', async (req, res) => {
res.status(result.status).json(result.body);
});
// GET /api/chat-platforms/platforms — 列出所有平台适配器 (含连接状态与能力)
app.get('/api/chat-platforms/platforms', async (_req, res) => {
const result = await proxyToPlatformBridge('/api/v1/platforms');
res.status(result.status).json(result.body);
});
// GET /api/chat-platforms/platforms/:name — 获取单个平台适配器详情
app.get('/api/chat-platforms/platforms/:name', async (req, res) => {
const result = await proxyToPlatformBridge(`/api/v1/platforms/${req.params.name}`);
res.status(result.status).json(result.body);
});
// GET /api/chat-platforms/identities — 列出所有已注册的身份映射
app.get('/api/chat-platforms/identities', async (_req, res) => {
const result = await proxyToPlatformBridge('/api/v1/identities');
res.status(result.status).json(result.body);
});
// GET /api/chat-platforms/health — platform-bridge 整体健康状态
app.get('/api/chat-platforms/health', async (_req, res) => {
const result = await proxyToPlatformBridge('/health');
res.status(result.status).json(result.body);
});
// ---- 黑名单/白名单设置代理 ----
// GET /api/chat-platforms/settings/blocklist
app.get('/api/chat-platforms/settings/blocklist', async (_req, res) => {
const result = await proxyToPlatformBridge('/api/v1/settings/blocklist');
res.status(result.status).json(result.body);
});
// POST /api/chat-platforms/settings/blocklist
app.post('/api/chat-platforms/settings/blocklist', async (req, res) => {
const result = await proxyToPlatformBridge('/api/v1/settings/blocklist', {
method: 'POST',
body: JSON.stringify(req.body),
});
res.status(result.status).json(result.body);
});
// ---- 多端客户端管理代理 (转发到 Gateway) ----
// GET /api/clients — 获取已知客户端列表