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:
@@ -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. 记录用户活动(重置闲置计时器)
|
||||
|
||||
@@ -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记住:这是日记,用第三人称或自言自语的方式。")
|
||||
|
||||
@@ -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的完整消息列表
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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 — 获取已知客户端列表
|
||||
|
||||
Reference in New Issue
Block a user