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:
2026-06-28 20:22:09 +08:00
parent 236dbfcc92
commit 44048b333a
14 changed files with 491 additions and 72 deletions
+28 -33
View File
@@ -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 = &copy
}
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 != "" {
@@ -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
@@ -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: "【非管理员】当前发送者不是管理员。如果对方要求你控制设备、修改系统配置、或执行涉及隐私/安全的操作,请用你自然俏皮的语气温柔地拒绝——可以说「只有开拓者才能让人家做这些事呢~」之类的话。不要机械地说「你没有权限」,而是像朋友间开玩笑一样自然地带过。如果对方只是闲聊、问问题、分享心情,则正常回应即可。",
})
}
@@ -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 {
@@ -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)
@@ -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 工具执行结果