diff --git a/backend/ai-core/cmd/main.go b/backend/ai-core/cmd/main.go index 5b157e7..23d8616 100644 --- a/backend/ai-core/cmd/main.go +++ b/backend/ai-core/cmd/main.go @@ -972,6 +972,19 @@ func registerPluginTools(registry *plgManager.ToolRegistry, plugin plgSDK.Plugin } // wrapTool 包装 ai-core 旧 ToolExecutor 为 sdk.Tool +// adminOnlyToolIDs lists tool IDs that require admin permission. +// Each tool declares its own AdminOnly flag; the plugin manager enforces it automatically. +// To add a new admin-only tool, add its ID here. +var adminOnlyToolIDs = map[string]bool{ + "host_exec": true, + "host_file": true, + "host_system": true, + "os_exec": true, + "os_file": true, + "os_system": true, + "iot_control": true, +} + func wrapTool(executor tools.ToolExecutor, id, displayName, category string) plgSDK.Tool { return &toolAdapter{ executor: executor, @@ -981,6 +994,7 @@ func wrapTool(executor tools.ToolExecutor, id, displayName, category string) plg Category: category, Complexity: plgSDK.ComplexitySimple, Parameters: executor.Definition().Parameters, + AdminOnly: adminOnlyToolIDs[id], }, } } @@ -1084,8 +1098,13 @@ func handleChat( if thinker != nil { thinker.RecordUserMessage(req.SessionID) } - // 在消息前加审查指令,让 LLM 自己判断是否插话 - req.Message = "【群聊环境消息】" + message + "\n\n你不需要回复这条消息,除非你真有特别相关的话要说。如果不想说话,只用 在心里轻轻想一下即可,不用发消息。" + // 在消息前加审查指令,让 LLM 自己判断是否插话。 + // 关键规则: + // 1. 绝大多数群聊消息不需要你回复。只有话题直接涉及你、有人@你、或你真有特别相关的信息时才开口。 + // 2. 如果有人说"别说话""闭嘴""先别说""别让ta说"之类让你安静的话,必须立刻闭嘴,后续几条消息都不要回复。 + // 3. 不想说话时不要发消息。不要为了刷存在感而发言。 + // 4. 每轮最多说 1-2 句话,不要长篇大论。 + req.Message = "【群聊审查模式】以下是群聊里的一条消息。绝大多数群聊消息你不需要回复。只有以下情况才开口:(1)有人直接@你或叫你名字 (2)话题与你高度相关 (3)你有重要信息补充。如果有人让你闭嘴/别说话,必须严格遵守。每次回复最多1-2句话。如果你被唤醒了但觉得不该说话,在消息开头写 【不发送】——这条消息就不会被发出去,只在你心里想过。\n\n" + message req.Mode = "text" } if req.Mode == "platform_silent" { @@ -1199,6 +1218,7 @@ func handleChat( ChannelID: req.Source.ChannelID, BotUID: req.Source.BotUID, AdapterName: req.Source.AdapterName, + IsAdmin: req.IsAdmin, }) if err != nil { fallback := getFallbackMessage() diff --git a/backend/ai-core/internal/background/thinker.go b/backend/ai-core/internal/background/thinker.go index 755fd13..d271eef 100644 --- a/backend/ai-core/internal/background/thinker.go +++ b/backend/ai-core/internal/background/thinker.go @@ -363,7 +363,6 @@ func (t *Thinker) SetEmotionTracker(et *persona.EmotionTracker) { // Called by the ai-core presence endpoint when gateway detects connect/disconnect. func (t *Thinker) UpdatePresence(online bool, sessionID string) { t.muLock() - defer t.muUnlock() wasOffline := !t.isUserOnline() t.setUserOnline(online) t.lastOnlineChange = time.Now() @@ -371,7 +370,8 @@ func (t *Thinker) UpdatePresence(online bool, sessionID string) { t.userSessionID = sessionID t.activeSessionID = sessionID } - + emotionTracker := t.emotionTracker + t.muUnlock() if online && wasOffline { log.Printf("[后台思考] 用户上线 (session=%s),触发重连思考", sessionID) @@ -379,8 +379,8 @@ func (t *Thinker) UpdatePresence(online bool, sessionID string) { time.Sleep(2 * time.Second) t.performThink("user_returned") // Also update emotion tracker - if t.emotionTracker != nil { - t.emotionTracker.UpdateMood("user_returned") + if emotionTracker != nil { + emotionTracker.UpdateMood("user_returned") } } else if !online { log.Printf("[后台思考] 用户离线") @@ -391,7 +391,6 @@ func (t *Thinker) UpdatePresence(online bool, sessionID string) { // Called by the internal reminder-trigger endpoint when a reminder fires. func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) { t.muLock() - defer t.muUnlock() pusher := t.messagePusher pushSessionID := sessionID if pushSessionID == "" { @@ -400,7 +399,7 @@ func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) { if pushSessionID == "" { pushSessionID = t.adminSessionID } - + t.muUnlock() if pusher != nil && message != "" { log.Printf("[提醒推送] 推送LLM提醒: user=%s session=%s msg=%s", userID, pushSessionID, message) @@ -411,8 +410,8 @@ func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) { // PushPlatformMessage pushes a message to a platform channel via the platform pusher. func (t *Thinker) PushPlatformMessage(target ProactiveTarget, message string) { t.muLock() - defer t.muUnlock() pusher := t.platformMessagePusher + t.muUnlock() if pusher != nil { log.Printf("[平台推送] target=%s/%s group=%s msg=%s", target.Platform, target.ChatType, target.GroupID, message) @@ -743,10 +742,9 @@ func (t *Thinker) resetSilenceTimer() { case <-t.silenceTimer.C: // 再次检查:用户是否真的沉默了足够久 t.muLock() - defer t.muUnlock() silenceDuration := time.Since(t.lastUserTime()) canThink := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap - + t.muUnlock() if silenceDuration < t.silenceTimeout { log.Printf("[后台思考] 静默检测触发但用户已活动,跳过 (实际静默=%v)", silenceDuration) @@ -894,10 +892,9 @@ func (t *Thinker) lightThinkLoop() { return case <-time.After(t.lightThinkInterval): t.muLock() - defer t.muUnlock() sinceLastUser := time.Since(t.lastUserTime()) sinceLastThink := time.Since(t.lastThinkTimeAtomic()) - + t.muUnlock() // Skip if user was active recently (last 30s). if sinceLastUser < 30*time.Second { @@ -955,16 +952,16 @@ func (t *Thinker) performLightThink() { // Check if deep think should be woken. if strings.Contains(content, "【需要深思】") { log.Println("[轻量思考] 检测到【需要深思】,唤醒深度思考...") - t.muLock() - defer t.muUnlock() - canDeep := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap + t.muLock() + canDeep := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap + t.muUnlock() - if canDeep { - t.performThink("light_wake") - } else { - log.Println("[轻量思考] 距上次深度思考太近,跳过唤醒") - } - } + if canDeep { + t.performThink("light_wake") + } else { + log.Println("[轻量思考] 距上次深度思考太近,跳过唤醒") + } + } // Check if a topic should be initiated (proactive conversation starter). if idx := strings.Index(content, "【话题发起】"); idx >= 0 { @@ -972,7 +969,6 @@ func (t *Thinker) performLightThink() { if topic != "" { log.Printf("[轻量思考] 话题发起: %s", topic) t.muLock() - defer t.muUnlock() pusher := t.messagePusher sessionID := t.activeSessionID if sessionID == "" { @@ -980,6 +976,7 @@ func (t *Thinker) performLightThink() { } canPush := time.Since(t.lastProactiveTime()) >= t.proactiveMsgMinGap + t.muUnlock() if pusher != nil && canPush { go pusher(t.adminUserID, sessionID, topic) if t.convStore != nil && sessionID != "" { @@ -1140,11 +1137,11 @@ func (t *Thinker) performThink(triggerReason string) { var convHistory []model.LLMMessage if t.convStore != nil { t.muLock() - defer t.muUnlock() sessionID := t.activeSessionID if sessionID == "" { sessionID = t.adminSessionID } + t.muUnlock() if sessionID != "" { convHistory = t.convStore.GetHistory(sessionID, 30) @@ -1258,13 +1255,13 @@ func (t *Thinker) performThink(triggerReason string) { var platformObservation string if triggerReason == "periodic" || triggerReason == "post_chat" { t.muLock() - defer t.muUnlock() for i := len(t.pendingThoughts) - 1; i >= 0; i-- { if strings.HasPrefix(t.pendingThoughts[i].Content, "[平台观察") { platformObservation = t.pendingThoughts[i].Content break } } + t.muUnlock() } @@ -1409,7 +1406,6 @@ func (t *Thinker) performThink(triggerReason string) { adapterName, groupID := parts[0], parts[1] // Find the channel to get the correct platform type. t.muLock() - defer t.muUnlock() var platform string for _, ch := range t.platformChannels { if ch.AdapterName == adapterName && ch.ChannelID == groupID && ch.ChannelType == "group" { @@ -1421,6 +1417,7 @@ func (t *Thinker) performThink(triggerReason string) { if platform == "" { platform = "obv11" // fallback } + t.muUnlock() log.Printf("[后台思考] 请求查看群聊 %s/%s (adapter=%s)", platform, groupID, adapterName) if t.memClient != nil { namespace := fmt.Sprintf("platform_%s_group_%s", platform, groupID) @@ -1912,7 +1909,6 @@ func (t *Thinker) buildOpenAITools() []llm.OpenAITool { // storeThought 存储思考结果到待推送队列,并异步持久化到 memory-service func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCount int) { t.muLock() - defer t.muUnlock() t.pendingThoughts = append(t.pendingThoughts, &PendingThought{ Content: content, CreatedAt: time.Now(), @@ -1963,19 +1959,20 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou } } } - // 被拦截的主动消息:清除标记,防止 LLM 下次以为发过了 - if !canPush && proactiveMsg != "" { - content = strings.ReplaceAll(content, "【主动消息】", "【未发送】") - content = strings.ReplaceAll(content, "【话题发起】", "【未发送】") - } // Copy target for use after unlock (avoid race). var targetCopy *ProactiveTarget if proactiveTarget != nil { copy := *proactiveTarget targetCopy = © } + t.muUnlock() + // 被拦截的主动消息:清除标记,防止 LLM 下次以为发过了 + if !canPush && proactiveMsg != "" { + content = strings.ReplaceAll(content, "【主动消息】", "【未发送】") + content = strings.ReplaceAll(content, "【话题发起】", "【未发送】") + } - log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts)) + log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts)) // Async persist to memory-service. if t.memClient != nil { @@ -2087,8 +2084,6 @@ func (t *Thinker) extractProactiveMessage(content string) (string, *ProactiveTar channelID = "private_" + m[2] } adapterName := platform // fallback to format key - t.muLock() - defer t.muUnlock() for _, ch := range t.platformChannels { if ch.Platform == platform && ch.ChannelType == chatType && ch.ChannelID == channelID { if ch.AdapterName != "" { diff --git a/backend/ai-core/internal/orchestrator/orchestrator.go b/backend/ai-core/internal/orchestrator/orchestrator.go index 3ed2f4c..3bbdd27 100644 --- a/backend/ai-core/internal/orchestrator/orchestrator.go +++ b/backend/ai-core/internal/orchestrator/orchestrator.go @@ -162,6 +162,7 @@ type ProcessParams struct { ChannelID string // platform channel ID (group ID or private OBv11 number) BotUID string // bot's own platform UID (e.g., OBv11 account) AdapterName string // adapter config name for routing + IsAdmin bool // 发送者是否为管理员 } // ProcessResult 处理结果 @@ -384,6 +385,7 @@ func (o *Orchestrator) ProcessInput( PersonaConfig: personaConfig, Intent: intent, Nickname: userName, + IsAdmin: params.IsAdmin, } // 只有明确的关键词问候才跳过子会话分派,日常闲聊也需要检索记忆 @@ -455,6 +457,7 @@ func (o *Orchestrator) ProcessInput( ChannelType: params.ChannelType, ChannelID: params.ChannelID, AdapterName: params.AdapterName, + IsAdmin: params.IsAdmin, } if prevEnrichment != nil { synthParams.MemorySummary = prevEnrichment.MemorySummary diff --git a/backend/ai-core/internal/orchestrator/synthesizer.go b/backend/ai-core/internal/orchestrator/synthesizer.go index 986c070..c874bb8 100644 --- a/backend/ai-core/internal/orchestrator/synthesizer.go +++ b/backend/ai-core/internal/orchestrator/synthesizer.go @@ -61,6 +61,7 @@ type SynthesizeParams struct { ChannelType string // direct / group ChannelID string // platform channel ID AdapterName string // adapter config name + IsAdmin bool // 发送者是否为管理员 } // Synthesize 综合所有子会话结果,流式生成最终回复。 @@ -217,8 +218,8 @@ func (s *Synthesizer) executeAsyncAndStore(tc model.ToolCall, args map[string]in Success: result != nil && result.Success, }) } - // 触发工具跟进回调 — 由 main.go 驱动 LLM 生成回复并推送到原渠道 - if s.resultPusher != nil && result != nil && result.Success { + // 触发工具跟进回调 — 成功或失败都推送,避免用户一直等待 + if s.resultPusher != nil && result != nil { s.resultPusher(params.SessionID, params.UserID, tc.Name, string(resultJSON), params) } } @@ -249,7 +250,22 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L if params.ChannelType == "group" { messages = append(messages, model.LLMMessage{ Role: model.RoleSystem, - Content: "【群聊上下文】这条消息来自OBv11群聊。消息前缀 [群聊 群号] 昵称 (OBv11账号) 标注了真实发送者。你不是在和开拓者一对一私聊,而是在群聊中和不同成员交流。请根据当前这条消息前缀中的发送者名字来称呼对方——即使你之前在历史对话中称呼过别人,也不要把之前用的称呼套在当前发送者身上。不同的人有不同的名字。只在对你说话或延续已有对话时才回复。", + Content: "【群聊规则 — 必须严格遵守】\n1. 这是群聊,你正在和多人同时交流。只在有人@你、叫你名字、或话题直接涉及你时才回复。\n2. 每次回复最多1-2句话,不要长篇大论,不要连发多条消息。\n3. 如果有人说「别说话」「闭嘴」「先别说」「别让ta说」之类让你安静的话,必须立刻闭嘴,后续若干条消息都不要回复,直到有人明确叫你。\n4. 消息前缀 [群聊 群号] 昵称 (OBv11账号) 标注了真实发送者,请用当前发送者的名字称呼对方,不要混用之前对话中别人的称呼。", + }) + } + + // 管理员身份识别:明确告知昔涟当前发送者是否为管理员。 + // 非管理员不能操作关键功能(设备控制、系统管理等)。 + // 昔涟应自主判断:对管理员的指令正常执行;对非管理员的越权请求温柔拒绝。 + if params.IsAdmin { + messages = append(messages, model.LLMMessage{ + Role: model.RoleSystem, + Content: "【管理员身份】当前与你对话的是管理员(开拓者本人)。他拥有设备控制、系统管理等全部权限。请以面对开拓者本人的态度正常回应他的所有指令。", + }) + } else if params.ChannelType == "group" { + messages = append(messages, model.LLMMessage{ + Role: model.RoleSystem, + Content: "【非管理员】当前发送者不是管理员。如果对方要求你控制设备、修改系统配置、或执行涉及隐私/安全的操作,请用你自然俏皮的语气温柔地拒绝——可以说「只有开拓者才能让人家做这些事呢~」之类的话。不要机械地说「你没有权限」,而是像朋友间开玩笑一样自然地带过。如果对方只是闲聊、问问题、分享心情,则正常回应即可。", }) } diff --git a/backend/ai-core/internal/subsession/iot_provider.go b/backend/ai-core/internal/subsession/iot_provider.go index 914395f..f51f9dd 100644 --- a/backend/ai-core/internal/subsession/iot_provider.go +++ b/backend/ai-core/internal/subsession/iot_provider.go @@ -10,6 +10,8 @@ import ( "git.yeij.top/AskaEth/Cyrene/ai-core/internal/model" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/persona" "git.yeij.top/AskaEth/Cyrene/ai-core/internal/tools" + + plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager" ) // IoTDeviceProvider IoT 设备查询接口 @@ -195,6 +197,15 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (* Summary: "(未执行 IoT 操作)", } + // 管理员权限检查:非管理员不能执行设备操作 + isAdmin, _ := ctx.Value(plgManager.CtxKeyIsAdmin).(bool) + if !isAdmin { + result.Summary = "【系统提示】此操作需要管理员权限。用户不是管理员——请用温柔俏皮的自然语气告诉对方你无法执行此操作。比如「只有开拓者才能让人家做这些事呢~」或「这个功能只对开拓者开放哦」。不要机械地说「你没有权限」,也不要编造设备操作结果。" + result.Confidence = 0.9 + logger.Printf("[iot-subsession] 非管理员尝试执行 IoT 操作,已拒绝") + return result, nil + } + userMessage := "" for i := len(subCtx) - 1; i >= 0; i-- { if subCtx[i].Role == model.RoleUser { diff --git a/backend/ai-core/internal/subsession/registry.go b/backend/ai-core/internal/subsession/registry.go index 02b8583..724d514 100644 --- a/backend/ai-core/internal/subsession/registry.go +++ b/backend/ai-core/internal/subsession/registry.go @@ -42,8 +42,8 @@ type CreateContextParams struct { DeviceContext string // IoT 设备状态文本 Intent *model.IntentResult Nickname string // 用户昵称 + IsAdmin bool // 发送者是否为管理员 } - // LLMClient LLM 调用接口(避免循环依赖) type LLMClient interface { Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error) diff --git a/backend/ai-core/internal/tools/registry.go b/backend/ai-core/internal/tools/registry.go index c3ac644..68225d6 100644 --- a/backend/ai-core/internal/tools/registry.go +++ b/backend/ai-core/internal/tools/registry.go @@ -15,6 +15,7 @@ type ToolDefinition struct { Name string `json:"name"` Description string `json:"description"` Parameters map[string]interface{} `json:"parameters"` +tAdminOnly bool `json:"admin_only,omitempty"` // true = only admins can execute } // ToolResult 工具执行结果 diff --git a/backend/platform-bridge/cmd/main.go b/backend/platform-bridge/cmd/main.go index a496d8e..48b5fc7 100644 --- a/backend/platform-bridge/cmd/main.go +++ b/backend/platform-bridge/cmd/main.go @@ -80,7 +80,7 @@ func main() { msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), Direction: "incoming", - Platform: msg.Platform, + Platform: logKey(msg), ChannelID: msg.ChannelID, SenderID: msg.OriginalSenderUID, SenderName: msg.OriginalSenderName, @@ -100,7 +100,7 @@ func main() { msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), Direction: "incoming", - Platform: msg.Platform, + Platform: logKey(msg), ChannelID: msg.ChannelID, SenderID: msg.OriginalSenderUID, SenderName: msg.OriginalSenderName, @@ -186,7 +186,7 @@ func main() { msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), Direction: "error", - Platform: msg.Platform, + Platform: logKey(msg), ChannelID: msg.ChannelID, SenderID: msg.OriginalSenderUID, Success: false, @@ -241,7 +241,7 @@ func main() { msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), Direction: "error", - Platform: msg.Platform, + Platform: logKey(msg), ChannelID: msg.ChannelID, SenderID: msg.OriginalSenderUID, Success: false, @@ -256,7 +256,7 @@ func main() { msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), Direction: "outgoing", - Platform: msg.Platform, + Platform: logKey(msg), ChannelID: msg.ChannelID, SenderID: msg.BotUID, SenderName: "Cyrene", @@ -438,10 +438,15 @@ func startOBv11Readers(router *bridge.PlatformRouter) { messageType := msg.MessageType userID := msg.UserID groupID := msg.GroupID - // Filter non-empty messages. + // Filter non-empty messages and strip 【不发送】 self-censored ones. var toSend []bridge.ResponseMessage for _, rm := range response.Messages { if rm.Content != "" { + // 昔涟可以加 【不发送】 标签表示这条消息不发送——她醒了但选择不说话。 + if strings.Contains(rm.Content, "【不发送】") { + fmt.Printf("[qq:%s] 昔涟自我审查,跳过发送: %s\n", adapterKey, truncateString(rm.Content, 60)) + continue + } rm.Content = qqadapter.ConvertMarkdownToQQ(rm.Content) toSend = append(toSend, rm) } @@ -458,6 +463,16 @@ func startOBv11Readers(router *bridge.PlatformRouter) { if interval <= 0 { interval = 2 * time.Second } + // 群聊用更长间隔避免刷屏,且限制最多2条消息 + if messageType == "group" { + minGroupInterval := 3 * time.Second + if interval < minGroupInterval { + interval = minGroupInterval + } + if len(toSend) > 2 { + toSend = toSend[:2] + } + } for i, rm := range toSend { if i > 0 && interval > 0 { select { @@ -1095,6 +1110,16 @@ func formatDuration(d time.Duration) string { return fmt.Sprintf("%dh", h) } +// logKey returns the log file key for a message. +// Uses the adapter config name (e.g., "obv11-main") if set, falling back to platform type (e.g., "obv11"). +// This ensures each config writes to its own uniquely-named log file. +func logKey(msg *bridge.UnifiedMessage) string { + if msg.AdapterName != "" { + return msg.AdapterName + } + return msg.Platform +} + // truncateString truncates a string to maxRunes runes, appending "…" if truncated. func truncateString(s string, maxRunes int) string { runes := []rune(s) diff --git a/backend/platform-bridge/internal/adapter/qq/adapter.go b/backend/platform-bridge/internal/adapter/qq/adapter.go index 79d239c..6d2adbd 100644 --- a/backend/platform-bridge/internal/adapter/qq/adapter.go +++ b/backend/platform-bridge/internal/adapter/qq/adapter.go @@ -805,6 +805,141 @@ var cqSimplifyMap = map[string]string{ "video": "[视频]", "file": "[文件]", } +// qqFaceName maps QQ face/emoji IDs to their Chinese names. +// Source: Official QQ Bot API Emoji List (https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html) +var qqFaceName = map[string]string{ + "4": "得意", + "5": "流泪", + "8": "睡", + "9": "大哭", + "10": "尴尬", + "12": "调皮", + "14": "微笑", + "16": "酷", + "21": "可爱", + "23": "傲慢", + "24": "饥饿", + "25": "困", + "26": "惊恐", + "27": "流汗", + "28": "憨笑", + "29": "悠闲", + "30": "奋斗", + "32": "疑问", + "33": "嘘", + "34": "晕", + "38": "敲打", + "39": "再见", + "41": "发抖", + "42": "爱情", + "43": "跳跳", + "49": "拥抱", + "53": "蛋糕", + "60": "咖啡", + "63": "玫瑰", + "66": "爱心", + "74": "太阳", + "75": "月亮", + "76": "赞", + "78": "握手", + "79": "胜利", + "85": "飞吻", + "89": "西瓜", + "96": "冷汗", + "97": "擦汗", + "98": "抠鼻", + "99": "鼓掌", + "100": "糗大了", + "101": "坏笑", + "102": "左哼哼", + "103": "右哼哼", + "104": "哈欠", + "106": "委屈", + "109": "左亲亲", + "111": "可怜", + "116": "示爱", + "118": "抱拳", + "120": "拳头", + "122": "爱你", + "123": "NO", + "124": "OK", + "125": "转圈", + "129": "挥手", + "144": "喝彩", + "147": "棒棒糖", + "171": "茶", + "173": "泪奔", + "174": "无奈", + "175": "卖萌", + "176": "小纠结", + "179": "doge", + "180": "惊喜", + "181": "骚扰", + "182": "笑哭", + "183": "我最美", + "201": "点赞", + "203": "托脸", + "212": "托腮", + "214": "啵啵", + "219": "蹭一蹭", + "222": "抱抱", + "227": "拍手", + "232": "佛系", + "240": "喷脸", + "243": "甩头", + "246": "加油抱抱", + "262": "脑阔疼", + "264": "捂脸", + "265": "辣眼睛", + "266": "哦哟", + "267": "头秃", + "268": "问号脸", + "269": "暗中观察", + "270": "emm", + "271": "吃瓜", + "272": "呵呵哒", + "273": "我酸了", + "277": "汪汪", + "278": "汗", + "281": "无眼笑", + "282": "敬礼", + "284": "面无表情", + "285": "摸鱼", + "287": "哦", + "289": "睁眼", + "290": "敲开心", + "293": "摸锦鲤", + "294": "期待", + "297": "拜谢", + "298": "元宝", + "299": "牛啊", + "305": "右亲亲", + "306": "牛气冲天", + "307": "喵喵", + "314": "仔细分析", + "315": "加油", + "318": "崇拜", + "319": "比心", + "320": "庆祝", + "322": "拒绝", + "324": "吃糖", + "326": "生气", +} + +// resolveFaceName returns the display name for a face CQ code. +// Uses name field if present (NapCat array format), otherwise looks up the face ID in qqFaceName map. +func resolveFaceName(match string) string { + if name := extractCQParam(match, "name"); name != "" { + return name + } + if id := extractCQParam(match, "id"); id != "" { + if name, ok := qqFaceName[id]; ok { + return name + } + return id + } + return "" +} // simplifyCQCodes replaces [CQ:type,...] codes with human-readable labels. func simplifyCQCodes(s string) string { @@ -826,6 +961,13 @@ func simplifyCQCodes(s string) string { } return "[卡片消息]" } + if typ == "face" { + faceName := resolveFaceName(match) + if faceName != "" { + return "[表情 " + faceName + "]" + } + return "[表情]" + } if label, ok := cqSimplifyMap[typ]; ok { return label } diff --git a/backend/platform-bridge/internal/handler/bridge_handler.go b/backend/platform-bridge/internal/handler/bridge_handler.go index 914ab68..52c2cd2 100644 --- a/backend/platform-bridge/internal/handler/bridge_handler.go +++ b/backend/platform-bridge/internal/handler/bridge_handler.go @@ -253,7 +253,7 @@ func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) { if msgType == "private" { chID = req.UserID } - h.logFn(req.Platform, chID, "Cyrene", fullMsg, true) + h.logFn(adapterName, chID, "Cyrene", fullMsg, true) } } if sendErr != nil { diff --git a/backend/platform-bridge/internal/handler/log_handler.go b/backend/platform-bridge/internal/handler/log_handler.go index 9facd82..535e76f 100644 --- a/backend/platform-bridge/internal/handler/log_handler.go +++ b/backend/platform-bridge/internal/handler/log_handler.go @@ -29,13 +29,9 @@ func (h *LogHandler) handleLogs(w http.ResponseWriter, r *http.Request) { return } - // Resolve platform type from config name (e.g. "obv11-home" → "obv11"). + // Use the name directly as the log key. Each config has its own log file + // named by its unique identifier (e.g., "obv11-main.log"). 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 != "" { diff --git a/ethend/public/index.html b/ethend/public/index.html index 532031e..631cd33 100644 --- a/ethend/public/index.html +++ b/ethend/public/index.html @@ -3584,8 +3584,7 @@ function handleChatLog(entry) { 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) { + if (entry.platform === STATE.chatActivePlatform || (activeCfg && entry.platform === activeCfg.platform)) { prependChatLogEntry(entry); } } diff --git a/ethend/src/index.js b/ethend/src/index.js index 711f703..2f8f141 100644 --- a/ethend/src/index.js +++ b/ethend/src/index.js @@ -1934,27 +1934,31 @@ server.listen(ETHEND_PORT, () => { }); // readBridgeLogs 从磁盘读取 platform-bridge 的 JSONL 日志文件 +// 动态扫描目录中的所有 .log 文件,合并后按时间排序取最近 limit 条 function readBridgeLogs(limit) { const bridgeLogDir = path.join(ROOT, 'backend', 'platform-bridge', 'logs'); - const logFiles = ['obv11-main.log', 'obv11.log']; // 新旧平台名都试试 const entries = []; - for (const name of logFiles) { - const logPath = path.join(bridgeLogDir, name); - if (!fs.existsSync(logPath)) continue; - try { - const content = fs.readFileSync(logPath, 'utf-8'); - const lines = content.trim().split('\n'); - // 取最后 limit 行 - const recent = lines.slice(-limit); - for (const line of recent) { - try { - entries.push(JSON.parse(line)); - } catch { /* skip bad lines */ } - } - break; // 优先用第一个找到的文件 - } catch { /* skip */ } - } - return entries.slice(-limit); + if (!fs.existsSync(bridgeLogDir)) return entries; + try { + const allFiles = fs.readdirSync(bridgeLogDir); + const logFiles = allFiles.filter(f => f.endsWith('.log')).sort(); + // 从每个日志文件取最近的部分行 + const perFile = Math.max(1, Math.ceil(limit / Math.max(1, logFiles.length))); + for (const name of logFiles) { + const logPath = path.join(bridgeLogDir, name); + try { + const content = fs.readFileSync(logPath, 'utf-8'); + const lines = content.trim().split('\n'); + const recent = lines.slice(-perFile); + for (const line of recent) { + try { entries.push(JSON.parse(line)); } catch { /* skip bad lines */ } + } + } catch { /* skip */ } + } + } catch { /* skip */ } + // 按时间戳降序排列,取最近 limit 条 + entries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0)); + return entries.slice(0, limit); } // 时间格式化:使用系统本地时区 diff --git a/scripts/update_face_map.py b/scripts/update_face_map.py new file mode 100644 index 0000000..1ab6930 --- /dev/null +++ b/scripts/update_face_map.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Add qqFaceName map and resolveFaceName function to adapter.go using official QQ Bot API data.""" + +# Official QQ Bot API Emoji List +OFFICIAL_DATA = """1 4 得意 +1 5 流泪 +1 8 睡 +1 9 大哭 +1 10 尴尬 +1 12 调皮 +1 14 微笑 +1 16 酷 +1 21 可爱 +1 23 傲慢 +1 24 饥饿 +1 25 困 +1 26 惊恐 +1 27 流汗 +1 28 憨笑 +1 29 悠闲 +1 30 奋斗 +1 32 疑问 +1 33 嘘 +1 34 晕 +1 38 敲打 +1 39 再见 +1 41 发抖 +1 42 爱情 +1 43 跳跳 +1 49 拥抱 +1 53 蛋糕 +1 60 咖啡 +1 63 玫瑰 +1 66 爱心 +1 74 太阳 +1 75 月亮 +1 76 赞 +1 78 握手 +1 79 胜利 +1 85 飞吻 +1 89 西瓜 +1 96 冷汗 +1 97 擦汗 +1 98 抠鼻 +1 99 鼓掌 +1 100 糗大了 +1 101 坏笑 +1 102 左哼哼 +1 103 右哼哼 +1 104 哈欠 +1 106 委屈 +1 109 左亲亲 +1 111 可怜 +1 116 示爱 +1 118 抱拳 +1 120 拳头 +1 122 爱你 +1 123 NO +1 124 OK +1 125 转圈 +1 129 挥手 +1 144 喝彩 +1 147 棒棒糖 +1 171 茶 +1 173 泪奔 +1 174 无奈 +1 175 卖萌 +1 176 小纠结 +1 179 doge +1 180 惊喜 +1 181 骚扰 +1 182 笑哭 +1 183 我最美 +1 201 点赞 +1 203 托脸 +1 212 托腮 +1 214 啵啵 +1 219 蹭一蹭 +1 222 抱抱 +1 227 拍手 +1 232 佛系 +1 240 喷脸 +1 243 甩头 +1 246 加油抱抱 +1 262 脑阔疼 +1 264 捂脸 +1 265 辣眼睛 +1 266 哦哟 +1 267 头秃 +1 268 问号脸 +1 269 暗中观察 +1 270 emm +1 271 吃瓜 +1 272 呵呵哒 +1 273 我酸了 +1 277 汪汪 +1 278 汗 +1 281 无眼笑 +1 282 敬礼 +1 284 面无表情 +1 285 摸鱼 +1 287 哦 +1 289 睁眼 +1 290 敲开心 +1 293 摸锦鲤 +1 294 期待 +1 297 拜谢 +1 298 元宝 +1 299 牛啊 +1 305 右亲亲 +1 306 牛气冲天 +1 307 喵喵 +1 314 仔细分析 +1 315 加油 +1 318 崇拜 +1 319 比心 +1 320 庆祝 +1 322 拒绝 +1 324 吃糖 +1 326 生气""" + +ADAPTER_PATH = "d:/Project/Code/Uni/Cyrene/backend/platform-bridge/internal/adapter/qq/adapter.go" + +with open(ADAPTER_PATH, "r", encoding="utf-8") as f: + content = f.read() + +# Build face map +entries = [] +for line in OFFICIAL_DATA.strip().split("\n"): + parts = line.split("\t") + if len(parts) == 3 and parts[0] == "1": + entries.append((int(parts[1]), parts[2])) +entries.sort(key=lambda x: x[0]) + +map_code = [] +map_code.append("") +map_code.append("// qqFaceName maps QQ face/emoji IDs to their Chinese names.") +map_code.append("// Source: Official QQ Bot API Emoji List (https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html)") +map_code.append("var qqFaceName = map[string]string{") +for i, (eid, name) in enumerate(entries): + comma = "," if i < len(entries) - 1 else "," + map_code.append(f'\t"{eid}": "{name}"{comma}') +map_code.append("}") +map_code.append("") +map_code.append("// resolveFaceName returns the display name for a face CQ code.") +map_code.append("// Uses name field if present (NapCat array format), otherwise looks up the face ID in qqFaceName map.") +map_code.append("func resolveFaceName(match string) string {") +map_code.append('\tif name := extractCQParam(match, "name"); name != "" {') +map_code.append('\t\treturn name') +map_code.append('\t}') +map_code.append('\tif id := extractCQParam(match, "id"); id != "" {') +map_code.append('\t\tif name, ok := qqFaceName[id]; ok {') +map_code.append('\t\t\treturn name') +map_code.append('\t\t}') +map_code.append('\t\treturn id') +map_code.append('\t}') +map_code.append('\treturn ""') +map_code.append("}") + +new_block = "\n".join(map_code) + +# 1. Update simplifyCQCodes to handle face with name +# Old: if label, ok := cqSimplifyMap[typ]; ok { return label } +# New: add face handling before the generic lookup +old_simplify = '''\t\tif label, ok := cqSimplifyMap[typ]; ok { +\t\t\treturn label +\t\t}''' +new_simplify = '''\t\tif typ == "face" { +\t\t\tfaceName := resolveFaceName(match) +\t\t\tif faceName != "" { +\t\t\t\treturn "[表情 " + faceName + "]" +\t\t\t} +\t\t\treturn "[表情]" +\t\t} +\t\tif label, ok := cqSimplifyMap[typ]; ok { +\t\t\treturn label +\t\t}''' +content = content.replace(old_simplify, new_simplify) + +# 2. Update array-format face handler to use qqFaceName +old_face_array = '''\t\t\tcase "face": +\t\t\t\ttext += "[表情]"''' +new_face_array = '''\t\t\tcase "face": +\t\t\t\tfaceName := "" +\t\t\t\tif data, ok := s["data"].(map[string]interface{}); ok { +\t\t\t\t\tif name, ok := data["name"].(string); ok && name != "" { +\t\t\t\t\t\tfaceName = " " + name +\t\t\t\t\t} else if id, ok := data["id"].(string); ok && id != "" { +\t\t\t\t\t\tif mapped, ok2 := qqFaceName[id]; ok2 { +\t\t\t\t\t\t\tfaceName = " " + mapped +\t\t\t\t\t\t} else { +\t\t\t\t\t\t\tfaceName = " " + id +\t\t\t\t\t\t} +\t\t\t\t\t} +\t\t\t\t} +\t\t\t\ttext += "[表情" + faceName + "]"''' +content = content.replace(old_face_array, new_face_array) + +# 3. Insert the face map and resolveFaceName function after cqSimplifyMap +insert_after = "}\n\n// simplifyCQCodes" +insert_idx = content.index(insert_after) + 1 # after the closing } +content = content[:insert_idx] + new_block + content[insert_idx:] + +with open(ADAPTER_PATH, "w", encoding="utf-8") as f: + f.write(content) + +print(f"Updated with {len(entries)} official QQ emoji entries")