fix: 死锁修复、管理员权限集中管控、群聊频率控制、表情映射修复
thinker.go: 移除所有 defer t.muUnlock() 持锁跨阻塞调用的模式,消除8处死锁点 - performThink: defer→立即解锁,LLM调用不再持锁 - lightThinkLoop: defer在for循环内→第2次迭代自死锁 - resetSilenceTimer: defer持锁调performThink - UpdatePresence: defer持锁调time.Sleep+performThink - storeThought: defer+panic→锁泄露; 移除extractProactiveMessage嵌套锁 is_admin三层防御: - synthesizer: 系统提示词注入管理员/非管理员身份标签 - iot_provider: 非管理员直接拒绝IoT操作 - plugin-manager: ToolDefinition.AdminOnly自动拦截,集中管控 群聊优化: - group_ambient: 强化审查指令,【不发送】自审查标签 - 群聊间隔4s→3s,最多2条/轮 - 工具失败也推跟进消息,避免沉默 平台桥接: - 日志文件名使用适配器唯一标识符(ConfigName) - QQ表情映射替换为官方116条目数据 - CQ表情保留名称/ID
This commit is contained in:
@@ -972,6 +972,19 @@ func registerPluginTools(registry *plgManager.ToolRegistry, plugin plgSDK.Plugin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wrapTool 包装 ai-core 旧 ToolExecutor 为 sdk.Tool
|
// 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 {
|
func wrapTool(executor tools.ToolExecutor, id, displayName, category string) plgSDK.Tool {
|
||||||
return &toolAdapter{
|
return &toolAdapter{
|
||||||
executor: executor,
|
executor: executor,
|
||||||
@@ -981,6 +994,7 @@ func wrapTool(executor tools.ToolExecutor, id, displayName, category string) plg
|
|||||||
Category: category,
|
Category: category,
|
||||||
Complexity: plgSDK.ComplexitySimple,
|
Complexity: plgSDK.ComplexitySimple,
|
||||||
Parameters: executor.Definition().Parameters,
|
Parameters: executor.Definition().Parameters,
|
||||||
|
AdminOnly: adminOnlyToolIDs[id],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1084,8 +1098,13 @@ func handleChat(
|
|||||||
if thinker != nil {
|
if thinker != nil {
|
||||||
thinker.RecordUserMessage(req.SessionID)
|
thinker.RecordUserMessage(req.SessionID)
|
||||||
}
|
}
|
||||||
// 在消息前加审查指令,让 LLM 自己判断是否插话
|
// 在消息前加审查指令,让 LLM 自己判断是否插话。
|
||||||
req.Message = "【群聊环境消息】" + message + "\n\n你不需要回复这条消息,除非你真有特别相关的话要说。如果不想说话,只用 <action> 在心里轻轻想一下即可,不用发消息。"
|
// 关键规则:
|
||||||
|
// 1. 绝大多数群聊消息不需要你回复。只有话题直接涉及你、有人@你、或你真有特别相关的信息时才开口。
|
||||||
|
// 2. 如果有人说"别说话""闭嘴""先别说""别让ta说"之类让你安静的话,必须立刻闭嘴,后续几条消息都不要回复。
|
||||||
|
// 3. 不想说话时不要发消息。不要为了刷存在感而发言。
|
||||||
|
// 4. 每轮最多说 1-2 句话,不要长篇大论。
|
||||||
|
req.Message = "【群聊审查模式】以下是群聊里的一条消息。绝大多数群聊消息你不需要回复。只有以下情况才开口:(1)有人直接@你或叫你名字 (2)话题与你高度相关 (3)你有重要信息补充。如果有人让你闭嘴/别说话,必须严格遵守。每次回复最多1-2句话。如果你被唤醒了但觉得不该说话,在消息开头写 【不发送】——这条消息就不会被发出去,只在你心里想过。\n\n" + message
|
||||||
req.Mode = "text"
|
req.Mode = "text"
|
||||||
}
|
}
|
||||||
if req.Mode == "platform_silent" {
|
if req.Mode == "platform_silent" {
|
||||||
@@ -1199,6 +1218,7 @@ func handleChat(
|
|||||||
ChannelID: req.Source.ChannelID,
|
ChannelID: req.Source.ChannelID,
|
||||||
BotUID: req.Source.BotUID,
|
BotUID: req.Source.BotUID,
|
||||||
AdapterName: req.Source.AdapterName,
|
AdapterName: req.Source.AdapterName,
|
||||||
|
IsAdmin: req.IsAdmin,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fallback := getFallbackMessage()
|
fallback := getFallbackMessage()
|
||||||
|
|||||||
@@ -363,7 +363,6 @@ func (t *Thinker) SetEmotionTracker(et *persona.EmotionTracker) {
|
|||||||
// Called by the ai-core presence endpoint when gateway detects connect/disconnect.
|
// Called by the ai-core presence endpoint when gateway detects connect/disconnect.
|
||||||
func (t *Thinker) UpdatePresence(online bool, sessionID string) {
|
func (t *Thinker) UpdatePresence(online bool, sessionID string) {
|
||||||
t.muLock()
|
t.muLock()
|
||||||
defer t.muUnlock()
|
|
||||||
wasOffline := !t.isUserOnline()
|
wasOffline := !t.isUserOnline()
|
||||||
t.setUserOnline(online)
|
t.setUserOnline(online)
|
||||||
t.lastOnlineChange = time.Now()
|
t.lastOnlineChange = time.Now()
|
||||||
@@ -371,7 +370,8 @@ func (t *Thinker) UpdatePresence(online bool, sessionID string) {
|
|||||||
t.userSessionID = sessionID
|
t.userSessionID = sessionID
|
||||||
t.activeSessionID = sessionID
|
t.activeSessionID = sessionID
|
||||||
}
|
}
|
||||||
|
emotionTracker := t.emotionTracker
|
||||||
|
t.muUnlock()
|
||||||
|
|
||||||
if online && wasOffline {
|
if online && wasOffline {
|
||||||
log.Printf("[后台思考] 用户上线 (session=%s),触发重连思考", sessionID)
|
log.Printf("[后台思考] 用户上线 (session=%s),触发重连思考", sessionID)
|
||||||
@@ -379,8 +379,8 @@ func (t *Thinker) UpdatePresence(online bool, sessionID string) {
|
|||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
t.performThink("user_returned")
|
t.performThink("user_returned")
|
||||||
// Also update emotion tracker
|
// Also update emotion tracker
|
||||||
if t.emotionTracker != nil {
|
if emotionTracker != nil {
|
||||||
t.emotionTracker.UpdateMood("user_returned")
|
emotionTracker.UpdateMood("user_returned")
|
||||||
}
|
}
|
||||||
} else if !online {
|
} else if !online {
|
||||||
log.Printf("[后台思考] 用户离线")
|
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.
|
// Called by the internal reminder-trigger endpoint when a reminder fires.
|
||||||
func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) {
|
func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) {
|
||||||
t.muLock()
|
t.muLock()
|
||||||
defer t.muUnlock()
|
|
||||||
pusher := t.messagePusher
|
pusher := t.messagePusher
|
||||||
pushSessionID := sessionID
|
pushSessionID := sessionID
|
||||||
if pushSessionID == "" {
|
if pushSessionID == "" {
|
||||||
@@ -400,7 +399,7 @@ func (t *Thinker) TriggerReminderMessage(userID, sessionID, message string) {
|
|||||||
if pushSessionID == "" {
|
if pushSessionID == "" {
|
||||||
pushSessionID = t.adminSessionID
|
pushSessionID = t.adminSessionID
|
||||||
}
|
}
|
||||||
|
t.muUnlock()
|
||||||
|
|
||||||
if pusher != nil && message != "" {
|
if pusher != nil && message != "" {
|
||||||
log.Printf("[提醒推送] 推送LLM提醒: user=%s session=%s msg=%s", userID, pushSessionID, 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.
|
// PushPlatformMessage pushes a message to a platform channel via the platform pusher.
|
||||||
func (t *Thinker) PushPlatformMessage(target ProactiveTarget, message string) {
|
func (t *Thinker) PushPlatformMessage(target ProactiveTarget, message string) {
|
||||||
t.muLock()
|
t.muLock()
|
||||||
defer t.muUnlock()
|
|
||||||
pusher := t.platformMessagePusher
|
pusher := t.platformMessagePusher
|
||||||
|
t.muUnlock()
|
||||||
|
|
||||||
if pusher != nil {
|
if pusher != nil {
|
||||||
log.Printf("[平台推送] target=%s/%s group=%s msg=%s", target.Platform, target.ChatType, target.GroupID, message)
|
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:
|
case <-t.silenceTimer.C:
|
||||||
// 再次检查:用户是否真的沉默了足够久
|
// 再次检查:用户是否真的沉默了足够久
|
||||||
t.muLock()
|
t.muLock()
|
||||||
defer t.muUnlock()
|
|
||||||
silenceDuration := time.Since(t.lastUserTime())
|
silenceDuration := time.Since(t.lastUserTime())
|
||||||
canThink := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
|
canThink := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
|
||||||
|
t.muUnlock()
|
||||||
|
|
||||||
if silenceDuration < t.silenceTimeout {
|
if silenceDuration < t.silenceTimeout {
|
||||||
log.Printf("[后台思考] 静默检测触发但用户已活动,跳过 (实际静默=%v)", silenceDuration)
|
log.Printf("[后台思考] 静默检测触发但用户已活动,跳过 (实际静默=%v)", silenceDuration)
|
||||||
@@ -894,10 +892,9 @@ func (t *Thinker) lightThinkLoop() {
|
|||||||
return
|
return
|
||||||
case <-time.After(t.lightThinkInterval):
|
case <-time.After(t.lightThinkInterval):
|
||||||
t.muLock()
|
t.muLock()
|
||||||
defer t.muUnlock()
|
|
||||||
sinceLastUser := time.Since(t.lastUserTime())
|
sinceLastUser := time.Since(t.lastUserTime())
|
||||||
sinceLastThink := time.Since(t.lastThinkTimeAtomic())
|
sinceLastThink := time.Since(t.lastThinkTimeAtomic())
|
||||||
|
t.muUnlock()
|
||||||
|
|
||||||
// Skip if user was active recently (last 30s).
|
// Skip if user was active recently (last 30s).
|
||||||
if sinceLastUser < 30*time.Second {
|
if sinceLastUser < 30*time.Second {
|
||||||
@@ -956,8 +953,8 @@ func (t *Thinker) performLightThink() {
|
|||||||
if strings.Contains(content, "【需要深思】") {
|
if strings.Contains(content, "【需要深思】") {
|
||||||
log.Println("[轻量思考] 检测到【需要深思】,唤醒深度思考...")
|
log.Println("[轻量思考] 检测到【需要深思】,唤醒深度思考...")
|
||||||
t.muLock()
|
t.muLock()
|
||||||
defer t.muUnlock()
|
|
||||||
canDeep := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
|
canDeep := time.Since(t.lastThinkTimeAtomic()) >= t.minThinkGap
|
||||||
|
t.muUnlock()
|
||||||
|
|
||||||
if canDeep {
|
if canDeep {
|
||||||
t.performThink("light_wake")
|
t.performThink("light_wake")
|
||||||
@@ -972,7 +969,6 @@ func (t *Thinker) performLightThink() {
|
|||||||
if topic != "" {
|
if topic != "" {
|
||||||
log.Printf("[轻量思考] 话题发起: %s", topic)
|
log.Printf("[轻量思考] 话题发起: %s", topic)
|
||||||
t.muLock()
|
t.muLock()
|
||||||
defer t.muUnlock()
|
|
||||||
pusher := t.messagePusher
|
pusher := t.messagePusher
|
||||||
sessionID := t.activeSessionID
|
sessionID := t.activeSessionID
|
||||||
if sessionID == "" {
|
if sessionID == "" {
|
||||||
@@ -980,6 +976,7 @@ func (t *Thinker) performLightThink() {
|
|||||||
}
|
}
|
||||||
canPush := time.Since(t.lastProactiveTime()) >= t.proactiveMsgMinGap
|
canPush := time.Since(t.lastProactiveTime()) >= t.proactiveMsgMinGap
|
||||||
|
|
||||||
|
t.muUnlock()
|
||||||
if pusher != nil && canPush {
|
if pusher != nil && canPush {
|
||||||
go pusher(t.adminUserID, sessionID, topic)
|
go pusher(t.adminUserID, sessionID, topic)
|
||||||
if t.convStore != nil && sessionID != "" {
|
if t.convStore != nil && sessionID != "" {
|
||||||
@@ -1140,11 +1137,11 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
var convHistory []model.LLMMessage
|
var convHistory []model.LLMMessage
|
||||||
if t.convStore != nil {
|
if t.convStore != nil {
|
||||||
t.muLock()
|
t.muLock()
|
||||||
defer t.muUnlock()
|
|
||||||
sessionID := t.activeSessionID
|
sessionID := t.activeSessionID
|
||||||
if sessionID == "" {
|
if sessionID == "" {
|
||||||
sessionID = t.adminSessionID
|
sessionID = t.adminSessionID
|
||||||
}
|
}
|
||||||
|
t.muUnlock()
|
||||||
|
|
||||||
if sessionID != "" {
|
if sessionID != "" {
|
||||||
convHistory = t.convStore.GetHistory(sessionID, 30)
|
convHistory = t.convStore.GetHistory(sessionID, 30)
|
||||||
@@ -1258,13 +1255,13 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
var platformObservation string
|
var platformObservation string
|
||||||
if triggerReason == "periodic" || triggerReason == "post_chat" {
|
if triggerReason == "periodic" || triggerReason == "post_chat" {
|
||||||
t.muLock()
|
t.muLock()
|
||||||
defer t.muUnlock()
|
|
||||||
for i := len(t.pendingThoughts) - 1; i >= 0; i-- {
|
for i := len(t.pendingThoughts) - 1; i >= 0; i-- {
|
||||||
if strings.HasPrefix(t.pendingThoughts[i].Content, "[平台观察") {
|
if strings.HasPrefix(t.pendingThoughts[i].Content, "[平台观察") {
|
||||||
platformObservation = t.pendingThoughts[i].Content
|
platformObservation = t.pendingThoughts[i].Content
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
t.muUnlock()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1409,7 +1406,6 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
adapterName, groupID := parts[0], parts[1]
|
adapterName, groupID := parts[0], parts[1]
|
||||||
// Find the channel to get the correct platform type.
|
// Find the channel to get the correct platform type.
|
||||||
t.muLock()
|
t.muLock()
|
||||||
defer t.muUnlock()
|
|
||||||
var platform string
|
var platform string
|
||||||
for _, ch := range t.platformChannels {
|
for _, ch := range t.platformChannels {
|
||||||
if ch.AdapterName == adapterName && ch.ChannelID == groupID && ch.ChannelType == "group" {
|
if ch.AdapterName == adapterName && ch.ChannelID == groupID && ch.ChannelType == "group" {
|
||||||
@@ -1421,6 +1417,7 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
if platform == "" {
|
if platform == "" {
|
||||||
platform = "obv11" // fallback
|
platform = "obv11" // fallback
|
||||||
}
|
}
|
||||||
|
t.muUnlock()
|
||||||
log.Printf("[后台思考] 请求查看群聊 %s/%s (adapter=%s)", platform, groupID, adapterName)
|
log.Printf("[后台思考] 请求查看群聊 %s/%s (adapter=%s)", platform, groupID, adapterName)
|
||||||
if t.memClient != nil {
|
if t.memClient != nil {
|
||||||
namespace := fmt.Sprintf("platform_%s_group_%s", platform, groupID)
|
namespace := fmt.Sprintf("platform_%s_group_%s", platform, groupID)
|
||||||
@@ -1912,7 +1909,6 @@ func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
|
|||||||
// storeThought 存储思考结果到待推送队列,并异步持久化到 memory-service
|
// storeThought 存储思考结果到待推送队列,并异步持久化到 memory-service
|
||||||
func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCount int) {
|
func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCount int) {
|
||||||
t.muLock()
|
t.muLock()
|
||||||
defer t.muUnlock()
|
|
||||||
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
||||||
Content: content,
|
Content: content,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -1963,17 +1959,18 @@ 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).
|
// Copy target for use after unlock (avoid race).
|
||||||
var targetCopy *ProactiveTarget
|
var targetCopy *ProactiveTarget
|
||||||
if proactiveTarget != nil {
|
if proactiveTarget != nil {
|
||||||
copy := *proactiveTarget
|
copy := *proactiveTarget
|
||||||
targetCopy = ©
|
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))
|
||||||
|
|
||||||
@@ -2087,8 +2084,6 @@ func (t *Thinker) extractProactiveMessage(content string) (string, *ProactiveTar
|
|||||||
channelID = "private_" + m[2]
|
channelID = "private_" + m[2]
|
||||||
}
|
}
|
||||||
adapterName := platform // fallback to format key
|
adapterName := platform // fallback to format key
|
||||||
t.muLock()
|
|
||||||
defer t.muUnlock()
|
|
||||||
for _, ch := range t.platformChannels {
|
for _, ch := range t.platformChannels {
|
||||||
if ch.Platform == platform && ch.ChannelType == chatType && ch.ChannelID == channelID {
|
if ch.Platform == platform && ch.ChannelType == chatType && ch.ChannelID == channelID {
|
||||||
if ch.AdapterName != "" {
|
if ch.AdapterName != "" {
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ type ProcessParams struct {
|
|||||||
ChannelID string // platform channel ID (group ID or private OBv11 number)
|
ChannelID string // platform channel ID (group ID or private OBv11 number)
|
||||||
BotUID string // bot's own platform UID (e.g., OBv11 account)
|
BotUID string // bot's own platform UID (e.g., OBv11 account)
|
||||||
AdapterName string // adapter config name for routing
|
AdapterName string // adapter config name for routing
|
||||||
|
IsAdmin bool // 发送者是否为管理员
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessResult 处理结果
|
// ProcessResult 处理结果
|
||||||
@@ -384,6 +385,7 @@ func (o *Orchestrator) ProcessInput(
|
|||||||
PersonaConfig: personaConfig,
|
PersonaConfig: personaConfig,
|
||||||
Intent: intent,
|
Intent: intent,
|
||||||
Nickname: userName,
|
Nickname: userName,
|
||||||
|
IsAdmin: params.IsAdmin,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有明确的关键词问候才跳过子会话分派,日常闲聊也需要检索记忆
|
// 只有明确的关键词问候才跳过子会话分派,日常闲聊也需要检索记忆
|
||||||
@@ -455,6 +457,7 @@ func (o *Orchestrator) ProcessInput(
|
|||||||
ChannelType: params.ChannelType,
|
ChannelType: params.ChannelType,
|
||||||
ChannelID: params.ChannelID,
|
ChannelID: params.ChannelID,
|
||||||
AdapterName: params.AdapterName,
|
AdapterName: params.AdapterName,
|
||||||
|
IsAdmin: params.IsAdmin,
|
||||||
}
|
}
|
||||||
if prevEnrichment != nil {
|
if prevEnrichment != nil {
|
||||||
synthParams.MemorySummary = prevEnrichment.MemorySummary
|
synthParams.MemorySummary = prevEnrichment.MemorySummary
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type SynthesizeParams struct {
|
|||||||
ChannelType string // direct / group
|
ChannelType string // direct / group
|
||||||
ChannelID string // platform channel ID
|
ChannelID string // platform channel ID
|
||||||
AdapterName string // adapter config name
|
AdapterName string // adapter config name
|
||||||
|
IsAdmin bool // 发送者是否为管理员
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synthesize 综合所有子会话结果,流式生成最终回复。
|
// Synthesize 综合所有子会话结果,流式生成最终回复。
|
||||||
@@ -217,8 +218,8 @@ func (s *Synthesizer) executeAsyncAndStore(tc model.ToolCall, args map[string]in
|
|||||||
Success: result != nil && result.Success,
|
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)
|
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" {
|
if params.ChannelType == "group" {
|
||||||
messages = append(messages, model.LLMMessage{
|
messages = append(messages, model.LLMMessage{
|
||||||
Role: model.RoleSystem,
|
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: "【非管理员】当前发送者不是管理员。如果对方要求你控制设备、修改系统配置、或执行涉及隐私/安全的操作,请用你自然俏皮的语气温柔地拒绝——可以说「只有开拓者才能让人家做这些事呢~」之类的话。不要机械地说「你没有权限」,而是像朋友间开玩笑一样自然地带过。如果对方只是闲聊、问问题、分享心情,则正常回应即可。",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
|
"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/persona"
|
||||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/tools"
|
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/tools"
|
||||||
|
|
||||||
|
plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IoTDeviceProvider IoT 设备查询接口
|
// IoTDeviceProvider IoT 设备查询接口
|
||||||
@@ -195,6 +197,15 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
|||||||
Summary: "(未执行 IoT 操作)",
|
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 := ""
|
userMessage := ""
|
||||||
for i := len(subCtx) - 1; i >= 0; i-- {
|
for i := len(subCtx) - 1; i >= 0; i-- {
|
||||||
if subCtx[i].Role == model.RoleUser {
|
if subCtx[i].Role == model.RoleUser {
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ type CreateContextParams struct {
|
|||||||
DeviceContext string // IoT 设备状态文本
|
DeviceContext string // IoT 设备状态文本
|
||||||
Intent *model.IntentResult
|
Intent *model.IntentResult
|
||||||
Nickname string // 用户昵称
|
Nickname string // 用户昵称
|
||||||
|
IsAdmin bool // 发送者是否为管理员
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLMClient LLM 调用接口(避免循环依赖)
|
// LLMClient LLM 调用接口(避免循环依赖)
|
||||||
type LLMClient interface {
|
type LLMClient interface {
|
||||||
Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
|
Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type ToolDefinition struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Parameters map[string]interface{} `json:"parameters"`
|
Parameters map[string]interface{} `json:"parameters"`
|
||||||
|
tAdminOnly bool `json:"admin_only,omitempty"` // true = only admins can execute
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolResult 工具执行结果
|
// ToolResult 工具执行结果
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func main() {
|
|||||||
msgLogger.Log(logging.LogEntry{
|
msgLogger.Log(logging.LogEntry{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Direction: "incoming",
|
Direction: "incoming",
|
||||||
Platform: msg.Platform,
|
Platform: logKey(msg),
|
||||||
ChannelID: msg.ChannelID,
|
ChannelID: msg.ChannelID,
|
||||||
SenderID: msg.OriginalSenderUID,
|
SenderID: msg.OriginalSenderUID,
|
||||||
SenderName: msg.OriginalSenderName,
|
SenderName: msg.OriginalSenderName,
|
||||||
@@ -100,7 +100,7 @@ func main() {
|
|||||||
msgLogger.Log(logging.LogEntry{
|
msgLogger.Log(logging.LogEntry{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Direction: "incoming",
|
Direction: "incoming",
|
||||||
Platform: msg.Platform,
|
Platform: logKey(msg),
|
||||||
ChannelID: msg.ChannelID,
|
ChannelID: msg.ChannelID,
|
||||||
SenderID: msg.OriginalSenderUID,
|
SenderID: msg.OriginalSenderUID,
|
||||||
SenderName: msg.OriginalSenderName,
|
SenderName: msg.OriginalSenderName,
|
||||||
@@ -186,7 +186,7 @@ func main() {
|
|||||||
msgLogger.Log(logging.LogEntry{
|
msgLogger.Log(logging.LogEntry{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Direction: "error",
|
Direction: "error",
|
||||||
Platform: msg.Platform,
|
Platform: logKey(msg),
|
||||||
ChannelID: msg.ChannelID,
|
ChannelID: msg.ChannelID,
|
||||||
SenderID: msg.OriginalSenderUID,
|
SenderID: msg.OriginalSenderUID,
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -241,7 +241,7 @@ func main() {
|
|||||||
msgLogger.Log(logging.LogEntry{
|
msgLogger.Log(logging.LogEntry{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Direction: "error",
|
Direction: "error",
|
||||||
Platform: msg.Platform,
|
Platform: logKey(msg),
|
||||||
ChannelID: msg.ChannelID,
|
ChannelID: msg.ChannelID,
|
||||||
SenderID: msg.OriginalSenderUID,
|
SenderID: msg.OriginalSenderUID,
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -256,7 +256,7 @@ func main() {
|
|||||||
msgLogger.Log(logging.LogEntry{
|
msgLogger.Log(logging.LogEntry{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Direction: "outgoing",
|
Direction: "outgoing",
|
||||||
Platform: msg.Platform,
|
Platform: logKey(msg),
|
||||||
ChannelID: msg.ChannelID,
|
ChannelID: msg.ChannelID,
|
||||||
SenderID: msg.BotUID,
|
SenderID: msg.BotUID,
|
||||||
SenderName: "Cyrene",
|
SenderName: "Cyrene",
|
||||||
@@ -438,10 +438,15 @@ func startOBv11Readers(router *bridge.PlatformRouter) {
|
|||||||
messageType := msg.MessageType
|
messageType := msg.MessageType
|
||||||
userID := msg.UserID
|
userID := msg.UserID
|
||||||
groupID := msg.GroupID
|
groupID := msg.GroupID
|
||||||
// Filter non-empty messages.
|
// Filter non-empty messages and strip 【不发送】 self-censored ones.
|
||||||
var toSend []bridge.ResponseMessage
|
var toSend []bridge.ResponseMessage
|
||||||
for _, rm := range response.Messages {
|
for _, rm := range response.Messages {
|
||||||
if rm.Content != "" {
|
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)
|
rm.Content = qqadapter.ConvertMarkdownToQQ(rm.Content)
|
||||||
toSend = append(toSend, rm)
|
toSend = append(toSend, rm)
|
||||||
}
|
}
|
||||||
@@ -458,6 +463,16 @@ func startOBv11Readers(router *bridge.PlatformRouter) {
|
|||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = 2 * time.Second
|
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 {
|
for i, rm := range toSend {
|
||||||
if i > 0 && interval > 0 {
|
if i > 0 && interval > 0 {
|
||||||
select {
|
select {
|
||||||
@@ -1095,6 +1110,16 @@ func formatDuration(d time.Duration) string {
|
|||||||
return fmt.Sprintf("%dh", h)
|
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.
|
// truncateString truncates a string to maxRunes runes, appending "…" if truncated.
|
||||||
func truncateString(s string, maxRunes int) string {
|
func truncateString(s string, maxRunes int) string {
|
||||||
runes := []rune(s)
|
runes := []rune(s)
|
||||||
|
|||||||
@@ -805,6 +805,141 @@ var cqSimplifyMap = map[string]string{
|
|||||||
"video": "[视频]",
|
"video": "[视频]",
|
||||||
"file": "[文件]",
|
"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.
|
// simplifyCQCodes replaces [CQ:type,...] codes with human-readable labels.
|
||||||
func simplifyCQCodes(s string) string {
|
func simplifyCQCodes(s string) string {
|
||||||
@@ -826,6 +961,13 @@ func simplifyCQCodes(s string) string {
|
|||||||
}
|
}
|
||||||
return "[卡片消息]"
|
return "[卡片消息]"
|
||||||
}
|
}
|
||||||
|
if typ == "face" {
|
||||||
|
faceName := resolveFaceName(match)
|
||||||
|
if faceName != "" {
|
||||||
|
return "[表情 " + faceName + "]"
|
||||||
|
}
|
||||||
|
return "[表情]"
|
||||||
|
}
|
||||||
if label, ok := cqSimplifyMap[typ]; ok {
|
if label, ok := cqSimplifyMap[typ]; ok {
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) {
|
|||||||
if msgType == "private" {
|
if msgType == "private" {
|
||||||
chID = req.UserID
|
chID = req.UserID
|
||||||
}
|
}
|
||||||
h.logFn(req.Platform, chID, "Cyrene", fullMsg, true)
|
h.logFn(adapterName, chID, "Cyrene", fullMsg, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sendErr != nil {
|
if sendErr != nil {
|
||||||
|
|||||||
@@ -29,13 +29,9 @@ func (h *LogHandler) handleLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
platform := name
|
||||||
if h.store != nil {
|
|
||||||
if cfg, err := h.store.Get(name); err == nil && cfg.Platform != "" {
|
|
||||||
platform = cfg.Platform
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := 100
|
limit := 100
|
||||||
if l := r.URL.Query().Get("limit"); l != "" {
|
if l := r.URL.Query().Get("limit"); l != "" {
|
||||||
|
|||||||
@@ -3584,8 +3584,7 @@ function handleChatLog(entry) {
|
|||||||
if (STATE.activePanel === 'chatPlatforms' && STATE.chatActivePlatform) {
|
if (STATE.activePanel === 'chatPlatforms' && STATE.chatActivePlatform) {
|
||||||
// Check if the entry's platform matches the active config's platform type.
|
// 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 activeCfg = (STATE.chatConfigs || []).find(function(c) { return c.name === STATE.chatActivePlatform; }) || null;
|
||||||
var activePtype = (activeCfg && activeCfg.platform) || STATE.chatActivePlatform;
|
if (entry.platform === STATE.chatActivePlatform || (activeCfg && entry.platform === activeCfg.platform)) {
|
||||||
if (entry.platform === activePtype) {
|
|
||||||
prependChatLogEntry(entry);
|
prependChatLogEntry(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-9
@@ -1934,27 +1934,31 @@ server.listen(ETHEND_PORT, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// readBridgeLogs 从磁盘读取 platform-bridge 的 JSONL 日志文件
|
// readBridgeLogs 从磁盘读取 platform-bridge 的 JSONL 日志文件
|
||||||
|
// 动态扫描目录中的所有 .log 文件,合并后按时间排序取最近 limit 条
|
||||||
function readBridgeLogs(limit) {
|
function readBridgeLogs(limit) {
|
||||||
const bridgeLogDir = path.join(ROOT, 'backend', 'platform-bridge', 'logs');
|
const bridgeLogDir = path.join(ROOT, 'backend', 'platform-bridge', 'logs');
|
||||||
const logFiles = ['obv11-main.log', 'obv11.log']; // 新旧平台名都试试
|
|
||||||
const entries = [];
|
const entries = [];
|
||||||
|
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) {
|
for (const name of logFiles) {
|
||||||
const logPath = path.join(bridgeLogDir, name);
|
const logPath = path.join(bridgeLogDir, name);
|
||||||
if (!fs.existsSync(logPath)) continue;
|
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(logPath, 'utf-8');
|
const content = fs.readFileSync(logPath, 'utf-8');
|
||||||
const lines = content.trim().split('\n');
|
const lines = content.trim().split('\n');
|
||||||
// 取最后 limit 行
|
const recent = lines.slice(-perFile);
|
||||||
const recent = lines.slice(-limit);
|
|
||||||
for (const line of recent) {
|
for (const line of recent) {
|
||||||
try {
|
try { entries.push(JSON.parse(line)); } catch { /* skip bad lines */ }
|
||||||
entries.push(JSON.parse(line));
|
|
||||||
} catch { /* skip bad lines */ }
|
|
||||||
}
|
}
|
||||||
break; // 优先用第一个找到的文件
|
|
||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
}
|
}
|
||||||
return entries.slice(-limit);
|
} catch { /* skip */ }
|
||||||
|
// 按时间戳降序排列,取最近 limit 条
|
||||||
|
entries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
|
||||||
|
return entries.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 时间格式化:使用系统本地时区
|
// 时间格式化:使用系统本地时区
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user