package main import ( "encoding/json" "fmt" "path/filepath" "strconv" "strings" "time" "qiweimanager/config" ) type autoReplyMessage struct { ClientID int32 RobotID string ConversationID string GroupName string FromWxID string ToWxID string FromNickName string Content string LocalID string ServerID string SendTime string MessageTime string AtList []string IsGroup bool MessageType string MediaURL string MediaLocalPath string MediaKind string MediaFileID string MediaAESKey string MediaAuthKey string MediaFileName string MediaFileType int MediaSize int64 VoiceText string ContextText string RawType int SenderIdentity string IdentitySource string } func enqueueAutoReplyEvent(clientID int32, responseData map[string]interface{}) { engine := getAutoReplyEngine() engine.observeGroupNames(clientID, responseData) if engine.observeIdentityContacts(clientID, responseData) { return } cfg := engine.getConfig() if !cfg.Enabled { return } select { case engine.queue <- AutoReplyJob{ClientID: clientID, RawData: responseData, ReceivedAt: time.Now()}: default: engine.incStatus("ignored") engine.setLastErrorWithScope(autoReplyErrorScopeRecords, "自动客服队列已满,消息已忽略") } } func (e *AutoReplyEngine) worker() { for job := range e.queue { func() { defer func() { if r := recover(); r != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, fmt.Sprintf("自动客服处理panic: %v", r)) } }() e.processJob(job) }() } } func (e *AutoReplyEngine) processJob(job AutoReplyJob) { started := time.Now() timings := autoReplyTimings{} defer func() { timings.TotalDurationMS = time.Since(started).Milliseconds() e.setLastDurations(timings) }() currentTimings := func() autoReplyTimings { current := timings current.TotalDurationMS = time.Since(started).Milliseconds() return current } cfg := e.getConfig() if !cfg.Enabled { return } msg := extractAutoReplyMessage(job.ClientID, job.RawData) e.enrichAutoReplyMessage(&msg, job.ReceivedAt) if !isAutoReplyMessageEvent(msg) { e.ignoreMessage(msg, "non_message_event") return } if msg.ConversationID == "" { e.ignoreMessage(msg, "missing_conversation_id") return } e.incStatus("received") if e.isStartupStaleMessage(msg, job.ReceivedAt) { e.ignoreMessage(msg, "startup_stale_message") return } if e.isHandoffConversation(msg, cfg) { e.ignoreMessage(msg, "handoff_conversation") return } if msg.isSelfMessage() { if e.observeCollaborationHumanReply(msg) { e.rememberAssistantMessage(msg, msg.Content) e.ignoreMessage(msg, "collaboration_human_reply_observed") return } e.ignoreMessage(msg, "self_message_echo") return } e.observeMessageIdentity(msg) identity := e.classifySenderIdentity(msg) msg.SenderIdentity = identity.Kind msg.IdentitySource = identity.Source if name := e.displayNameForMessage(msg); name != "" { msg.FromNickName = name } if identity.Source == identitySourceUnknownAsCustomer { e.noteReason(identitySourceUnknownAsCustomer) if !msg.IsGroup { e.startUnknownIdentityLookup(msg, "identity_unknown_background") } } if identity.Source == identitySourceUnknownIgnored { e.ignoreMessage(msg, identitySourceUnknownIgnored) return } if msg.IsGroup { if !cfg.Listen.EnableGroupChat { e.ignoreMessage(msg, "group_disabled") return } if cfg.Listen.GroupTriggerMode == "mention_only" && !e.messageMentionsRobot(msg) { e.ignoreMessage(msg, "group_without_mention") return } } else if !cfg.Listen.EnablePrivateChat { e.ignoreMessage(msg, "private_disabled") return } if !job.SkipHumanAssist && e.isDuplicate(msg) { e.ignoreMessage(msg, "duplicate") return } if e.maybeHandleCollaborationCustomer(job, msg) { e.ignoreMessage(msg, "collaboration_waiting_human") return } collaborationTakeover := e.isCollaborationTakeoverMessage(msg) mediaPrepared := false if msg.RawType != 11041 { mediaStarted := time.Now() if err := e.prepareMediaMessage(&msg); err != nil { timings.AIDurationMS += time.Since(mediaStarted).Milliseconds() if strings.TrimSpace(msg.Content) == "" { msg.Content = nonTextMessageDescription(msg) } e.rememberUserMessage(msg) e.setLastErrorWithScope(autoReplyErrorScopeAI, "media recognition failed: "+err.Error()) if err := e.replyTextWithTimings(msg, mediaRecognitionFallbackAnswer(msg), "media_recognition_failed: "+err.Error(), nil, currentTimings()); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, "media fallback reply failed: "+err.Error()) } return } timings.AIDurationMS += time.Since(mediaStarted).Milliseconds() if strings.TrimSpace(msg.Content) == "" { msg.Content = nonTextMessageDescription(msg) } mediaPrepared = true } if strings.TrimSpace(msg.Content) == "" { e.ignoreMessage(msg, "empty_message") return } if isPreviousQuestionQuery(msg.Content) { answer := previousQuestionAnswer(e.previousUserQuestion(msg)) e.rememberUserMessage(msg) if err := e.replyTextWithTimings(msg, answer, "context_previous_question_replied", nil, currentTimings()); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, "context reply send failed: "+err.Error()) } return } e.rememberUserMessage(msg) if isPureTopicSwitchMessage(msg.Content) { if err := e.replyTextWithTimings(msg, topicSwitchGuidanceAnswer(), "topic_switch_guidance", nil, currentTimings()); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, "topic switch reply failed: "+err.Error()) } return } if answer, ok := greetingAnswer(msg.Content); ok { if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, answer); err != nil { e.handoffWithTimings(msg, "send_greeting_failed: "+err.Error(), nil, currentTimings()) return } e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, answer) e.markCooldown(msg) e.rememberAssistantMessage(msg, answer) e.incStatus("replied") e.noteReason("greeting_replied") e.addRecord(AutoReplyRecord{ RobotID: msg.RobotID, ClientID: msg.ClientID, UserID: msg.RobotID, ConversationID: msg.ConversationID, Source: msg.sourceLabel(), FromWxID: msg.FromWxID, FromNickName: msg.FromNickName, Question: msg.Content, Action: "replied", Reason: "greeting_replied", Answer: answer, SenderIdentity: msg.SenderIdentity, IdentitySource: msg.IdentitySource, TotalDurationMS: time.Since(started).Milliseconds(), }) return } if answer, ok := companyIdentityAnswer(msg.Content, cfg); ok { if err := e.replyTextWithTimings(msg, answer, "company_identity_replied", nil, currentTimings()); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, "company identity reply failed: "+err.Error()) } return } if answer, ok := e.quickConversationAnswerForMessage(msg); ok { if err := e.replyTextWithTimings(msg, answer, "quick_conversation_replied", nil, currentTimings()); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, "快捷回复发送失败: "+err.Error()) } return } if cfg.ReplyPolicy.MaxQuestionLength > 0 && len([]rune(msg.Content)) > cfg.ReplyPolicy.MaxQuestionLength { if err := e.replyTextWithTimings(msg, "您这条问题有点长,麻烦拆成一两个具体问题发我,我会逐条帮您看。", "question_too_long_no_handoff", nil, currentTimings()); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, "长问题提示发送失败: "+err.Error()) } return } if reason := e.manualHandoffReason(msg.Content); reason != "" { e.handoffWithTimings(msg, reason, nil, currentTimings()) return } if reason := trivialAutoReplyMessageReason(msg.Content); reason != "" { e.ignoreMessage(msg, reason) return } if !job.ForceNoCooldown && !collaborationTakeover && !mediaPrepared && e.inCooldown(msg) { e.ignoreMessage(msg, "cooldown") return } knowledgeStarted := time.Now() searchText := e.contextualSearchText(msg.Content, msg) searchResult := e.searchKnowledgeDetailed(searchText) hits := searchResult.Hits materialMatches := e.matchMaterials(msg.Content, searchText, hits) timings.KeywordDurationMS = searchResult.Timings.KeywordDurationMS timings.VectorDurationMS = searchResult.Timings.VectorDurationMS timings.RerankDurationMS = searchResult.Timings.RerankDurationMS timings.KnowledgeDurationMS = searchResult.Timings.KnowledgeDurationMS if timings.KnowledgeDurationMS <= 0 { timings.KnowledgeDurationMS = time.Since(knowledgeStarted).Milliseconds() } e.setLastRetrievalScores(searchResult.KeywordScore, searchResult.VectorScore, searchResult.RerankScore) if len(materialMatches) > 0 { if err := e.sendMaterials(msg, materialMatches, "materials_replied", withSearchMetadata(currentTimings(), searchResult)); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, "material send failed: "+err.Error()) } return } if isBroadAllMaterialRequest(msg.Content) || isGenericMaterialRequest(msg.Content) { if err := e.replyTextWithTimings(msg, materialClarificationAnswer(), "material_request_needs_clarification", hits, withSearchMetadata(currentTimings(), searchResult)); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, "material clarification reply failed: "+err.Error()) } return } if len(hits) == 0 || hits[0].Score < cfg.Knowledge.MinScore { if isKnowledgeScopedQuestion(searchText) { e.replyKnowledgeNoAnswerWithTimings(msg, "knowledge_no_answer_low_score", hits, withSearchMetadata(currentTimings(), searchResult)) return } e.replyGeneralWithTimings(msg, "general_reply_low_knowledge", hits, withSearchMetadata(currentTimings(), searchResult)) return } aiStarted := time.Now() aiResult, err := e.askAI(msg.Content, hits, msg) timings.AIDurationMS = time.Since(aiStarted).Milliseconds() if aiResult != nil && aiResult.DurationMS > 0 { timings.AIDurationMS = aiResult.DurationMS } if err != nil { e.incStatus("ai_failed") e.setLastErrorWithScope(autoReplyErrorScopeAI, "AI请求失败: "+err.Error()) if isKnowledgeScopedQuestion(searchText) { e.replyKnowledgeNoAnswerWithTimings(msg, "knowledge_no_answer_ai_error", hits, withSearchMetadata(currentTimings(), searchResult)) return } e.replyGeneralWithTimings(msg, "general_reply_after_knowledge_ai_error", hits, withSearchMetadata(currentTimings(), searchResult)) return } answer := strings.TrimSpace(aiResult.Answer) if answer == "" || isNoAnswer(answer, cfg.ReplyPolicy.UnknownAnswerToken) { e.incStatus("ai_failed") if isKnowledgeScopedQuestion(searchText) { e.replyKnowledgeNoAnswerWithTimings(msg, "knowledge_no_answer_ai_no_answer", hits, withSearchMetadata(currentTimings(), searchResult)) return } e.replyGeneralWithTimings(msg, "general_reply_after_knowledge_no_answer", hits, withSearchMetadata(currentTimings(), searchResult)) return } if err := e.replyTextWithTimings(msg, answer, "ok", hits, withSearchMetadata(currentTimings(), searchResult)); err != nil { e.handoffWithTimings(msg, "send_reply_failed: "+err.Error(), hits, withSearchMetadata(currentTimings(), searchResult)) return } } func extractAutoReplyMessage(clientID int32, raw map[string]interface{}) autoReplyMessage { msg := autoReplyMessage{ClientID: clientID} msg.RobotID = runtimeRobotID(uint32(clientID)) if typeVal, ok := raw["type"]; ok { msg.RawType = intFromAny(typeVal) } if msg.RawType == 0 { msg.RawType = rawTypeFromEvent(raw) } if data, ok := raw["data"].(map[string]interface{}); ok { msg.ConversationID = firstNonEmptyString(data["conversation_id"], data["conversationId"], data["room_conversation_id"]) msg.GroupName = firstNonEmptyString(data["room_name"], data["roomName"], data["group_name"], data["groupName"], data["conversation_name"], data["conversationName"]) msg.Content = firstNonEmptyString(data["content"], data["message"], data["text"], data["title"], data["desc"], data["address"]) msg.FromWxID = firstNonEmptyString(data["sender"], data["fromWxId"]) msg.ToWxID = firstNonEmptyString(data["receiver"], data["toWxId"]) msg.FromNickName = firstNonEmptyString(data["sender_name"], data["fromNickName"]) msg.LocalID = firstNonEmptyString(data["local_id"], data["localId"]) msg.ServerID = firstNonEmptyString(data["server_id"], data["serverId"]) msg.SendTime = firstNonEmptyString(data["send_time"], data["sendTime"], data["create_time"]) msg.MessageTime = formatAutoReplyMessageTime(msg.SendTime) msg.AtList = stringSliceFromAny(firstNonNil(data["at_list"], data["atWxIdList"])) msg.MediaURL = firstMediaURLFromValue(data) msg.MediaLocalPath = firstLocalMediaPathFromValue(data) msg.MediaKind = firstNonEmptyString(data["media_kind"], data["mediaKind"]) if msg.MediaKind == "" { msg.MediaKind = mediaKindForRawType(msg.RawType) } fillMediaFieldsFromValue(&msg, data) msg.VoiceText = firstVoiceTextFromValue(data) msg.RobotID = inferAutoReplyRobotID(clientID, msg.RobotID, msg.FromWxID, msg.ToWxID) } msg.IsGroup = strings.HasPrefix(msg.ConversationID, "R:") || len(msg.AtList) > 0 if msg.RawType == 11041 { msg.MessageType = "text" } else { msg.MessageType = "non_text" } logEmptyMediaDiagnostics(msg, raw) return msg } // logEmptyMediaDiagnostics 仅在"图片/视频/表情等非文本媒体消息,且 URL/本地路径/FileID 三个来源全空" // 时触发,把媒体相关字段(已脱敏)打到日志,用于定位企微 DLL 回调实际下发了哪些字段。 // 文本消息(11041)与字段已成功填充的消息都不会触发,避免刷屏与泄露正常聊天内容。 func logEmptyMediaDiagnostics(msg autoReplyMessage, raw map[string]interface{}) { if globalLogger == nil { return } // 只关心需要拉取媒体文件来识别的类型;纯文本/位置等不在此列。 switch msg.RawType { case 11042, 11043, 11047: // image / video / link(图片/表情) default: return } hasMediaSource := strings.TrimSpace(msg.MediaURL) != "" || strings.TrimSpace(msg.MediaLocalPath) != "" || strings.TrimSpace(msg.MediaFileID) != "" if hasMediaSource { return // 字段已填到,下载链路可以走,无需诊断 } // 只提取诊断相关的字段,避免泄露用户昵称、群名、会话内容等敏感信息 diagnosticFields := make(map[string]interface{}) diagnosticFields["event"] = raw["event"] diagnosticFields["type"] = raw["type"] if data, ok := raw["data"].(map[string]interface{}); ok { // 只记录媒体相关字段和类型标识 for _, key := range []string{"event", "content_type", "contentType", "messageType", "message_type", "image_url", "imageUrl", "preview_img_url", "previewImgUrl", "md_url", "mdUrl", "ld_url", "ldUrl", "url", "file_id", "fileId", "local_path", "localPath", "media_kind", "mediaKind", "aes_key", "aesKey", "auth_key", "authKey"} { if val, exists := data[key]; exists && val != nil { diagnosticFields[key] = val } } } diagJSON, err := json.Marshal(diagnosticFields) if err != nil { globalLogger.Warn("[媒体诊断] rawType=%d 媒体字段全空,且诊断数据序列化失败: %v", msg.RawType, err) return } globalLogger.Warn("[媒体诊断] rawType=%d mediaKind=%s 媒体字段(URL/本地路径/FileID)全空,无法识别。诊断字段: %s", msg.RawType, msg.MediaKind, string(diagJSON)) } func rawTypeFromEvent(raw map[string]interface{}) int { event := strings.TrimSpace(stringFromAny(raw["event"])) if event == "" { if data, ok := raw["data"].(map[string]interface{}); ok { event = strings.TrimSpace(stringFromAny(data["event"])) } } // 优先使用 event 字段(DLL 真实事件):20002=文本 20003=图片 20004=视频 20012=语音 20005=文件 20014=链接 switch event { case "20002": return 11041 case "20003": return 11042 case "20004": return 11043 case "20012": return 11044 case "20005": return 11045 case "20014": return 11047 } // event 为空时才用 content_type(模拟事件或老版本 DLL):2=文本 101=图片 103=视频 16=语音 102=文件 6=位置 13=链接 // 注意不能把文本(2)误判成图片,否则会触发图片识别并回退"无法识别"话术。 if data, ok := raw["data"].(map[string]interface{}); ok { switch intFromAny(firstNonNil(data["content_type"], data["contentType"])) { case 2: return 11041 case 101: return 11042 case 103: return 11043 case 16: return 11044 case 102: return 11045 case 6: return 11046 case 13: return 11047 } } return 0 } func inferAutoReplyRobotID(clientID int32, currentRobotID string, fromWxID string, toWxID string) string { currentRobotID = strings.TrimSpace(currentRobotID) fromWxID = strings.TrimSpace(fromWxID) toWxID = strings.TrimSpace(toWxID) if currentRobotID != "" && !strings.HasPrefix(currentRobotID, "client:") { return currentRobotID } knownAccounts := getIdentifiedUserIDSet() if fromWxID != "" && knownAccounts[fromWxID] { return fromWxID } if toWxID != "" && knownAccounts[toWxID] { return toWxID } if currentRobotID != "" { return currentRobotID } if clientID != 0 { return fmt.Sprintf("client:%d", clientID) } return "" } func isAutoReplyMessageEvent(msg autoReplyMessage) bool { switch msg.RawType { case 11041, 11042, 11043, 11044, 11045, 11046, 11047: return strings.TrimSpace(msg.SendTime) != "" || strings.TrimSpace(msg.ServerID) != "" || strings.TrimSpace(msg.LocalID) != "" default: return false } } func (e *AutoReplyEngine) isStartupStaleMessage(msg autoReplyMessage, receivedAt time.Time) bool { sendAt, ok := autoReplyMessageSendTime(msg) if !ok { return false } cutoff := e.autoReplyMessageFreshCutoff(msg.ClientID, receivedAt) return !cutoff.IsZero() && sendAt.Before(cutoff) } func (e *AutoReplyEngine) autoReplyMessageFreshCutoff(clientID int32, receivedAt time.Time) time.Time { const grace = 10 * time.Second e.mu.Lock() startedAt := e.startedAt enabledAt := e.enabledAt e.mu.Unlock() cutoff := maxTime(startedAt, enabledAt, clientIdentifiedAt(uint32(clientID)), clientConnectedAt(uint32(clientID))) if cutoff.IsZero() { cutoff = receivedAt } if cutoff.IsZero() { return time.Time{} } return cutoff.Add(-grace) } func autoReplyMessageSendTime(msg autoReplyMessage) (time.Time, bool) { raw := strings.TrimSpace(msg.SendTime) if raw == "" { return time.Time{}, false } n, err := strconv.ParseInt(raw, 10, 64) if err != nil || n <= 0 { return time.Time{}, false } if n > 1000000000000 { n = n / 1000 } return time.Unix(n, 0), true } func maxTime(values ...time.Time) time.Time { var result time.Time for _, value := range values { if value.IsZero() { continue } if result.IsZero() || value.After(result) { result = value } } return result } func withSearchMetadata(timings autoReplyTimings, searchResult KnowledgeSearchResult) autoReplyTimings { timings.KeywordScore = searchResult.KeywordScore timings.VectorScore = searchResult.VectorScore timings.RerankScore = searchResult.RerankScore timings.RetrievalMode = searchResult.RetrievalMode timings.UsedKnowledgeSources = append([]string(nil), searchResult.UsedKnowledgeSources...) return timings } func (e *AutoReplyEngine) enrichAutoReplyMessage(msg *autoReplyMessage, receivedAt time.Time) { if msg == nil { return } if strings.TrimSpace(msg.MessageTime) == "" { if receivedAt.IsZero() { receivedAt = time.Now() } msg.MessageTime = receivedAt.Local().Format("2006-01-02 15:04:05") } if msg.ConversationID == "" { return } if msg.GroupName != "" { e.rememberGroupName(msg.ClientID, msg.ConversationID, msg.GroupName, 0) return } if msg.IsGroup { if cached := e.groupNameForConversation(msg.ConversationID); cached != "" { msg.GroupName = cached return } msg.GroupName = msg.ConversationID e.noteReason("missing_group_name") } } func (m autoReplyMessage) isSelfMessage() bool { robotID := strings.TrimSpace(m.RobotID) fromWxID := strings.TrimSpace(m.FromWxID) if fromWxID == "" { return false } if robotID != "" && !strings.HasPrefix(robotID, "client:") && fromWxID == robotID { return true } return getIdentifiedUserIDSet()[fromWxID] } func (e *AutoReplyEngine) isHandoffConversation(msg autoReplyMessage, cfg config.AutoReplyConfig) bool { conversationID := strings.TrimSpace(msg.ConversationID) if conversationID == "" { return false } humanConversationID := strings.TrimSpace(cfg.Handoff.HumanConversationID) if humanConversationID != "" && sameConversationID(conversationID, humanConversationID) { return true } humanID := strings.TrimSpace(cfg.Handoff.HumanUserID) if humanID == "" { return false } if conversationMatchesPair(conversationID, msg.RobotID, humanID) { return true } if humanConversationID != "" && msg.RobotID != "" && conversationMatchesPair(humanConversationID, msg.RobotID, humanID) && sameConversationID(conversationID, humanConversationID) { return true } return false } func sameConversationID(a string, b string) bool { return strings.TrimSpace(a) == strings.TrimSpace(b) } func conversationMatchesPair(conversationID string, leftID string, rightID string) bool { conversationID = strings.TrimSpace(conversationID) leftID = strings.TrimSpace(leftID) rightID = strings.TrimSpace(rightID) if conversationID == "" || leftID == "" || rightID == "" || strings.HasPrefix(leftID, "client:") { return false } if !strings.HasPrefix(conversationID, "S:") { return false } parts := strings.Split(strings.TrimPrefix(conversationID, "S:"), "_") if len(parts) != 2 { return false } return (parts[0] == leftID && parts[1] == rightID) || (parts[0] == rightID && parts[1] == leftID) } func (e *AutoReplyEngine) observeGroupNames(clientID int32, raw map[string]interface{}) { if len(raw) == 0 { return } e.observeGroupNamesFromValue(clientID, raw) } func (e *AutoReplyEngine) observeGroupNamesFromValue(clientID int32, value interface{}) { switch v := value.(type) { case map[string]interface{}: conversationID := stringFromAny(firstNonNil(v["conversation_id"], v["conversationId"], v["room_conversation_id"], v["roomConversationId"], v["room_id"], v["roomId"])) groupName := stringFromAny(firstNonNil(v["room_name"], v["roomName"], v["group_name"], v["groupName"], v["conversation_name"], v["conversationName"])) if conversationID != "" && groupName != "" { e.rememberGroupName(clientID, conversationID, groupName, identityGroupMemberCountFromMap(v)) } for _, item := range v { e.observeGroupNamesFromValue(clientID, item) } case []interface{}: for _, item := range v { e.observeGroupNamesFromValue(clientID, item) } } } func (e *AutoReplyEngine) rememberGroupName(clientID int32, conversationID string, groupName string, memberCount int) { conversationID = strings.TrimSpace(conversationID) groupName = strings.TrimSpace(groupName) if conversationID == "" || groupName == "" { return } changed := false e.mu.Lock() if e.groupNames == nil { e.groupNames = make(map[string]string) } if e.groupNames[conversationID] != groupName { e.groupNames[conversationID] = groupName changed = true } if strings.HasPrefix(conversationID, "R:") { if e.identityGroups == nil { e.identityGroups = make(map[int32]map[string]autoReplyGroupOption) } target := e.identityGroups[clientID] if target == nil { target = make(map[string]autoReplyGroupOption) e.identityGroups[clientID] = target } current := target[conversationID] if memberCount <= 0 { memberCount = current.MemberCount } next := current next.ConversationID = conversationID next.Name = fallbackString(groupName, current.Name) next.Source = fallbackString(current.Source, "observed_group_message") next.ClientID = clientID next.MemberCount = memberCount if current.LastSeenAt <= 0 { next.LastSeenAt = time.Now().Unix() } if current != next { target[conversationID] = next changed = true } e.status.IdentityGroupOptionCount = e.identityGroupOptionCountLocked() } e.mu.Unlock() if changed { e.saveIdentityCache() } } func (e *AutoReplyEngine) ResolveGroupName(conversationID string) string { return e.groupNameForConversation(conversationID) } func (e *AutoReplyEngine) groupNameForConversation(conversationID string) string { conversationID = strings.TrimSpace(conversationID) if conversationID == "" { return "" } e.mu.Lock() defer e.mu.Unlock() if name := strings.TrimSpace(e.groupNames[conversationID]); name != "" { return name } for _, groups := range e.identityGroups { if group, ok := groups[conversationID]; ok { if name := strings.TrimSpace(group.Name); name != "" { return name } } } return "" } func (e *AutoReplyEngine) messageMentionsRobot(m autoReplyMessage) bool { for _, item := range m.AtList { if item != "" && (item == m.RobotID || item == m.ToWxID) { return true } } for _, name := range e.robotMentionNames(m) { if mentionTextContainsName(m.Content, name) { return true } } return false } func (e *AutoReplyEngine) robotMentionNames(m autoReplyMessage) []string { names := []string{m.RobotID, m.ToWxID} clientID := uint32(m.ClientID) if clientID != 0 { names = append(names, getClientUserID(clientID)) } if e != nil { e.mu.Lock() names = append(names, e.accountNames[m.ClientID]...) if cache := e.identityCaches[m.ClientID]; cache != nil { ensureIdentityCacheMaps(cache) scope := e.identityScopeForClient(m.ClientID) for _, candidateID := range names { candidateID = strings.TrimSpace(candidateID) if candidateID == "" { continue } if contact, ok := cache.Internal[candidateID]; ok && contactMatchesIdentityScope(contact, scope) { names = append(names, contact.Name) } if contact, ok := cache.External[candidateID]; ok && contactMatchesIdentityScope(contact, scope) { names = append(names, contact.Name) } if contact, ok := cache.Observed[candidateID]; ok && contactMatchesIdentityScope(contact, scope) { names = append(names, contact.Name) } } } e.mu.Unlock() } return dedupeNonEmptyStrings(names) } func mentionTextContainsName(content string, name string) bool { content = strings.TrimSpace(content) name = strings.TrimSpace(name) if content == "" || name == "" { return false } if strings.Contains(content, "@"+name) { return true } if strings.Contains(content, "@ "+name) { return true } return false } func (m autoReplyMessage) sourceLabel() string { if m.IsGroup { return "group" } return "private" } func (m autoReplyMessage) sourceDisplayLabel() string { if m.IsGroup { return "群聊" } return "私聊" } func (e *AutoReplyEngine) isDuplicate(msg autoReplyMessage) bool { key := msg.dedupeKey() if key == "" { return false } cfg := e.getConfig() ttl := time.Duration(cfg.Listen.DeduplicateSeconds) * time.Second if ttl <= 0 { ttl = 300 * time.Second } now := time.Now() e.mu.Lock() defer e.mu.Unlock() for k, ts := range e.dedupe { if now.Sub(ts) > ttl { delete(e.dedupe, k) } } if ts, exists := e.dedupe[key]; exists && now.Sub(ts) <= ttl { return true } e.dedupe[key] = now return false } func (m autoReplyMessage) dedupeKey() string { robotID := m.stableRobotID() if m.ServerID != "" { return strings.Join([]string{robotID, m.ConversationID, m.ServerID}, "|") } return strings.Join([]string{robotID, m.ConversationID, m.LocalID, m.SendTime, m.FromWxID, strings.TrimSpace(m.Content)}, "|") } func (m autoReplyMessage) stableRobotID() string { robotID := strings.TrimSpace(m.RobotID) if robotID != "" && !strings.HasPrefix(robotID, "client:") { return robotID } if m.ClientID != 0 { return fmt.Sprintf("client:%d", m.ClientID) } return robotID } func (e *AutoReplyEngine) inCooldown(msg autoReplyMessage) bool { cfg := e.getConfig() if cfg.ReplyPolicy.CooldownSeconds <= 0 { return false } key := msg.stableRobotID() + "|" + msg.ConversationID e.mu.Lock() defer e.mu.Unlock() last, exists := e.cooldowns[key] return exists && time.Since(last) < time.Duration(cfg.ReplyPolicy.CooldownSeconds)*time.Second } func (e *AutoReplyEngine) markCooldown(msg autoReplyMessage) { key := msg.stableRobotID() + "|" + msg.ConversationID e.mu.Lock() e.cooldowns[key] = time.Now() e.mu.Unlock() } func trivialAutoReplyMessageReason(content string) string { text := strings.TrimSpace(content) text = strings.Trim(text, " \t\r\n,,.。!!??~~;;::、") if text == "" { return "trivial_message" } lower := strings.ToLower(text) switch lower { case "ok", "okay", "k", "收到", "好的", "好", "嗯", "恩", "哦", "噢", "啊", "是", "对", "行", "可以", "谢谢", "谢了": return "short_ack_message" } if len([]rune(text)) <= 3 && isAllASCIIDigits(text) { return "short_numeric_message" } return "" } func isAllASCIIDigits(text string) bool { if text == "" { return false } for _, r := range text { if r < '0' || r > '9' { return false } } return true } func (e *AutoReplyEngine) manualHandoffReason(content string) string { normalized := normalizeGreetingText(content) for _, keyword := range e.manualHandoffKeywords() { keyword = strings.TrimSpace(keyword) if keyword != "" && strings.Contains(normalized, normalizeGreetingText(keyword)) { return "manual_keyword: " + keyword } } return "" } func (e *AutoReplyEngine) manualHandoffKeywords() []string { cfg := e.getConfig() items := []string{ "转人工", "人工客服", "真人客服", "人工接", "人工回复", "人工处理", "找人工", "接人工", "换人工", "人工来", "人工在吗", "要人工", "转客服", "找客服", "联系客服", "客服人工", "真人接待", } items = append(items, cfg.Handoff.ManualTriggerKeywords...) for _, item := range cfg.ReplyPolicy.SensitiveKeywords { if isExplicitManualHandoffKeyword(item) { items = append(items, item) } } return dedupeNonEmptyStrings(items) } func isExplicitManualHandoffKeyword(keyword string) bool { normalized := normalizeGreetingText(keyword) return normalized != "" && normalized != "客服" && strings.Contains(normalized, "人工") } func dedupeNonEmptyStrings(items []string) []string { seen := make(map[string]bool, len(items)) result := make([]string, 0, len(items)) for _, item := range items { item = strings.TrimSpace(item) key := normalizeGreetingText(item) if key == "" || seen[key] { continue } seen[key] = true result = append(result, item) } return result } func (e *AutoReplyEngine) ignoreMessage(msg autoReplyMessage, reason string) { e.incStatus("ignored") e.noteReason(reason) e.addRecord(AutoReplyRecord{ RobotID: msg.RobotID, ClientID: msg.ClientID, UserID: msg.RobotID, ConversationID: msg.ConversationID, Source: msg.sourceLabel(), FromWxID: msg.FromWxID, FromNickName: msg.FromNickName, Question: msg.Content, Action: "ignored", Reason: reason, SenderIdentity: msg.SenderIdentity, IdentitySource: msg.IdentitySource, }) } func (e *AutoReplyEngine) replyGeneralWithTimings(msg autoReplyMessage, reason string, hits []KnowledgeChunk, timings autoReplyTimings) { cfg := e.getConfig() aiStarted := time.Now() result, err := e.askGeneralAI(msg.Content, msg) if timings.AIDurationMS <= 0 { timings.AIDurationMS = time.Since(aiStarted).Milliseconds() } if result != nil && result.DurationMS > 0 { timings.AIDurationMS = result.DurationMS } answer := "" if err == nil && result != nil { answer = strings.TrimSpace(result.Answer) if isNoAnswer(answer, cfg.ReplyPolicy.UnknownAnswerToken) { answer = "" } } if answer == "" { answer = generalFallbackAnswer(msg.Content) if err != nil { reason += "; general_ai_error: " + err.Error() } else { reason += "; general_fallback" } } if sendErr := e.replyTextWithTimings(msg, answer, reason, hits, timings); sendErr != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, "通用回复发送失败: "+sendErr.Error()) } } func (e *AutoReplyEngine) replyKnowledgeNoAnswerWithTimings(msg autoReplyMessage, reason string, hits []KnowledgeChunk, timings autoReplyTimings) { answer := "\u77e5\u8bc6\u5e93\u4e2d\u6ca1\u6709\u627e\u5230\u660e\u786e\u5185\u5bb9\uff0c\u5efa\u8bae\u67e5\u770b\u539f\u6587\u4ef6\u6216\u8054\u7cfb\u76f8\u5173\u8d1f\u8d23\u4eba\u786e\u8ba4\u3002" if sendErr := e.replyTextWithTimings(msg, answer, reason, hits, timings); sendErr != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, "knowledge no-answer reply failed: "+sendErr.Error()) } } func isKnowledgeScopedQuestion(content string) bool { content = strings.ToLower(strings.TrimSpace(content)) if content == "" { return false } if len(extractKnowledgeReferenceTokens(content)) > 0 { return true } for _, ext := range []string{".xlsx", ".xls", ".docx", ".doc", ".pdf", ".md", ".txt", ".csv"} { if strings.Contains(content, ext) { return true } } keywords := []string{ "\u77e5\u8bc6\u5e93", "\u6587\u4ef6", "\u6587\u6863", "\u8868\u683c", "\u901a\u77e5", "\u4f1a\u8bae", "\u90e8\u95e8", "\u65f6\u95f4", "\u6807\u51c6", "\u89c4\u5b9a", "\u5b89\u6392", "\u5305\u542b", "\u6709\u54ea\u4e9b", "\u5185\u5bb9", "\u6761\u6b3e", "\u5f00\u4f1a", "\u6708\u5ea6", "\u56fa\u5b9a", } for _, keyword := range keywords { if strings.Contains(content, keyword) { return true } } return false } func companyIdentityAnswer(content string, cfg config.AutoReplyConfig) (string, bool) { text := normalizeGreetingText(content) if text == "" { return "", false } triggers := []string{ "你是什么公司", "你们是什么公司", "你们公司是做什么", "你们公司做什么", "你是哪个公司", "你们是哪家公司", "你是谁", "你是做什么的", "介绍一下你们公司", "公司介绍", } for _, trigger := range triggers { if strings.Contains(text, normalizeGreetingText(trigger)) { return companyIdentitySafeAnswer(cfg), true } } return "", false } func companyIdentitySafeAnswer(cfg config.AutoReplyConfig) string { return configuredIdentityAnswer(cfg) } func materialClarificationAnswer() string { return "资料比较多,您要哪一类?比如企业级 AI 数字员工宣传手册、包装机接线标准,或具体产品资料。" } func sanitizeAutoReplyAnswer(question string, answer string, hits []KnowledgeChunk, cfg config.AutoReplyConfig) (string, bool) { answer = strings.TrimSpace(answer) if answer == "" { return answer, false } if !looksLikePromptLeakage(answer) && !looksLikeBrokenKnowledgeAnswer(answer) { return answer, false } if isGenericProductQuery(question) { if summary := productOverviewFallbackFromHits(hits); summary != "" { return summary, true } return productOverviewClarificationAnswer(), true } if safe, ok := companyIdentityAnswer(question, cfg); ok { return safe, true } return "这个我帮您确认一下,您方便再说一下具体想了解哪方面内容吗?", true } func looksLikeBrokenKnowledgeAnswer(answer string) bool { text := strings.TrimSpace(answer) if text == "" { return false } normalized := normalizeGreetingText(text) signals := []string{ "knowledge库", "knowledge库内容无法确定", "知识库内容无法确定具体产品", "无法确定具体产品", "知识库无法确定", "根据提供的知识库内容无法", "没有足够信息确定具体产品", } for _, signal := range signals { if strings.Contains(normalized, normalizeGreetingText(signal)) { return true } } if strings.Count(text, "**") >= 4 { trimmed := strings.TrimSpace(strings.ReplaceAll(text, "**", "")) trimmed = strings.Trim(trimmed, "\r\n\t -_*。,.,、") if len([]rune(trimmed)) < 24 { return true } } return false } func productOverviewClarificationAnswer() string { return "我这边没有检索到足够明确的产品清单。您可以告诉我想了解 AI 数字员工、知识库问答、业务自动化,还是具体某个产品资料?" } func productOverviewFallbackFromHits(hits []KnowledgeChunk) string { names := productCandidateNamesFromHits(hits) if len(names) == 0 { return "" } if len(names) > 8 { names = names[:8] } return "我们目前可介绍的产品/方向包括:" + strings.Join(names, "、") + "。您想先了解哪一类?" } func productCandidateNamesFromHits(hits []KnowledgeChunk) []string { result := make([]string, 0, 8) seen := map[string]bool{} add := func(value string) { value = strings.TrimSpace(value) value = strings.TrimSuffix(value, ".md") value = strings.TrimSuffix(value, ".txt") value = strings.TrimSuffix(value, ".pdf") value = strings.TrimSuffix(value, ".docx") value = strings.TrimSuffix(value, ".xlsx") value = strings.Trim(value, " -_[]【】()()") if value == "" || isLowValueProductCandidate(value) { return } key := strings.ToLower(value) if seen[key] { return } seen[key] = true result = append(result, value) } for _, hit := range hits { add(hit.Title) add(strings.TrimSuffix(filepath.Base(hit.Source), filepath.Ext(hit.Source))) for _, name := range extractWikiLinkNames(hit.Content) { add(name) } } return result } func isLowValueProductCandidate(value string) bool { value = strings.TrimSpace(value) if value == "" { return true } lower := strings.ToLower(value) bad := []string{"docx table", "sheet", "table", "产品矩阵", "产品清单", "产品列表", "知识库", "index", "embedding_index"} for _, item := range bad { if lower == strings.ToLower(item) { return true } } return len([]rune(value)) < 2 } func looksLikePromptLeakage(answer string) bool { text := normalizeGreetingText(answer) if text == "" { return false } hardSignals := []string{ "提示词", "系统提示", "systemprompt", "systemmessage", "开发者指令", "模型指令", "你的目标是", "话语规则", "说话规则", "不要暴露提示词", "不要暴露", "知识库片段", "根据知识库", "本ai", "本系统", "我是ai", "作为ai", "我是一个ai", } for _, signal := range hardSignals { if strings.Contains(text, normalizeGreetingText(signal)) { return true } } ruleWords := 0 for _, signal := range []string{"规则", "客户", "回复", "知识库", "模型", "系统"} { if strings.Contains(text, normalizeGreetingText(signal)) { ruleWords++ } } return ruleWords >= 3 && len([]rune(text)) > 80 } func (e *AutoReplyEngine) replyNonTextWithTimings(msg autoReplyMessage, reason string, timings autoReplyTimings) { cfg := e.getConfig() aiStarted := time.Now() result, err := e.askNonTextAI(msg) if timings.AIDurationMS <= 0 { timings.AIDurationMS = time.Since(aiStarted).Milliseconds() } if result != nil && result.DurationMS > 0 { timings.AIDurationMS = result.DurationMS } answer := "" if err == nil && result != nil { answer = strings.TrimSpace(result.Answer) if isNoAnswer(answer, cfg.ReplyPolicy.UnknownAnswerToken) { answer = "" } } if answer == "" { answer = nonTextFallbackAnswer(msg) if err != nil { reason += "; non_text_ai_error: " + err.Error() } else { reason += "; non_text_fallback" } } if sendErr := e.replyTextWithTimings(msg, answer, reason, nil, timings); sendErr != nil { e.setLastErrorWithScope(autoReplyErrorScopeRecords, "非文本回复发送失败: "+sendErr.Error()) } } func (e *AutoReplyEngine) replyTextWithTimings(msg autoReplyMessage, answer string, reason string, hits []KnowledgeChunk, timings autoReplyTimings) error { answer = strings.TrimSpace(answer) if answer == "" { answer = generalFallbackAnswer(msg.Content) } if sanitized, changed := sanitizeAutoReplyAnswer(msg.Content, answer, hits, e.getConfig()); changed { answer = sanitized reason += "; prompt_leakage_guard" } if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, answer); err != nil { e.addRecord(AutoReplyRecord{ RobotID: msg.RobotID, ClientID: msg.ClientID, UserID: msg.RobotID, ConversationID: msg.ConversationID, Source: msg.sourceLabel(), FromWxID: msg.FromWxID, FromNickName: msg.FromNickName, Question: msg.Content, Action: "failed", Reason: reason + "; send_reply_failed: " + err.Error(), SenderIdentity: msg.SenderIdentity, IdentitySource: msg.IdentitySource, KeywordScore: timings.KeywordScore, VectorScore: timings.VectorScore, RerankScore: timings.RerankScore, RetrievalMode: timings.RetrievalMode, UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "), KnowledgeDurationMS: timings.KnowledgeDurationMS, KeywordDurationMS: timings.KeywordDurationMS, VectorDurationMS: timings.VectorDurationMS, RerankDurationMS: timings.RerankDurationMS, AIDurationMS: timings.AIDurationMS, TotalDurationMS: timings.TotalDurationMS, }) return err } e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, answer) e.markCooldown(msg) e.rememberAssistantMessage(msg, answer) e.incStatus("replied") score := 0.0 if len(hits) > 0 { score = hits[0].Score } e.addRecord(AutoReplyRecord{ RobotID: msg.RobotID, ClientID: msg.ClientID, UserID: msg.RobotID, ConversationID: msg.ConversationID, Source: msg.sourceLabel(), FromWxID: msg.FromWxID, FromNickName: msg.FromNickName, Question: msg.Content, Action: "replied", Reason: reason, Answer: answer, SenderIdentity: msg.SenderIdentity, IdentitySource: msg.IdentitySource, Score: score, KeywordScore: timings.KeywordScore, VectorScore: timings.VectorScore, RerankScore: timings.RerankScore, RetrievalMode: timings.RetrievalMode, UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "), KnowledgeDurationMS: timings.KnowledgeDurationMS, KeywordDurationMS: timings.KeywordDurationMS, VectorDurationMS: timings.VectorDurationMS, RerankDurationMS: timings.RerankDurationMS, AIDurationMS: timings.AIDurationMS, TotalDurationMS: timings.TotalDurationMS, }) return nil } var ( sendAutoReplyTextSender = sendAutoReplyTextRequest sendAutoReplyCardSender = sendAutoReplyCardRequest ) func sendAutoReplyText(clientID uint32, conversationID string, content string) error { return sendAutoReplyTextSender(clientID, conversationID, content) } func sendAutoReplyTextRequest(clientID uint32, conversationID string, content string) error { if strings.TrimSpace(conversationID) == "" { return fmt.Errorf("conversationId为空") } request := map[string]interface{}{ "type": 11029, "data": map[string]interface{}{ "conversation_id": conversationID, "content": content, }, } data, err := json.Marshal(request) if err != nil { return err } result, err := handleSendWxWorkData(map[string]interface{}{ "data": string(data), "clientId": clientID, }) if err != nil { return err } if resultMap, ok := result.(map[string]interface{}); ok { if success, ok := resultMap["success"].(bool); ok && !success { return fmt.Errorf("%v", resultMap["error"]) } } return nil } func sendAutoReplyCard(clientID uint32, conversationID string, shareUserID string) error { return sendAutoReplyCardSender(clientID, conversationID, shareUserID) } func sendAutoReplyCardRequest(clientID uint32, conversationID string, shareUserID string) error { if strings.TrimSpace(conversationID) == "" { return fmt.Errorf("conversationId为空") } if strings.TrimSpace(shareUserID) == "" { return fmt.Errorf("shareUserId为空") } request := map[string]interface{}{ "type": 11161, "data": map[string]interface{}{ "conversation_id": conversationID, "share_user_id": shareUserID, }, } data, err := json.Marshal(request) if err != nil { return err } result, err := handleSendWxWorkData(map[string]interface{}{ "data": string(data), "clientId": clientID, }) if err != nil { return err } if resultMap, ok := result.(map[string]interface{}); ok { if success, ok := resultMap["success"].(bool); ok && !success { return fmt.Errorf("%v", resultMap["error"]) } } return nil } func isNoAnswer(answer string, token string) bool { token = strings.TrimSpace(token) if token == "" { token = "NO_ANSWER" } normalized := strings.TrimSpace(strings.ToUpper(answer)) return normalized == strings.ToUpper(token) || strings.Contains(normalized, strings.ToUpper(token)) } func firstNonNil(values ...interface{}) interface{} { for _, value := range values { if value != nil { return value } } return nil } func stringFromAny(value interface{}) string { switch v := value.(type) { case string: return strings.TrimSpace(v) case float64: return fmt.Sprintf("%.0f", v) case int: return fmt.Sprintf("%d", v) case json.Number: return v.String() default: if value == nil { return "" } return strings.TrimSpace(fmt.Sprint(value)) } } func intFromAny(value interface{}) int { switch v := value.(type) { case int: return v case int32: return int(v) case int64: return int(v) case float64: return int(v) case string: var n int _, _ = fmt.Sscanf(v, "%d", &n) return n default: return 0 } } func firstMediaURLFromValue(value interface{}) string { switch v := value.(type) { case map[string]interface{}: for _, key := range []string{"image_url", "imageUrl", "preview_img_url", "previewImgUrl", "md_url", "mdUrl", "ld_url", "ldUrl", "url"} { if url := mediaURLFromString(stringFromAny(v[key])); url != "" { return url } } for _, item := range v { if url := firstMediaURLFromValue(item); url != "" { return url } } case []interface{}: for _, item := range v { if url := firstMediaURLFromValue(item); url != "" { return url } } } return "" } func mediaURLFromString(value string) string { value = strings.TrimSpace(value) lower := strings.ToLower(value) if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "data:image/") { return value } return "" } func formatAutoReplyMessageTime(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } n, err := strconv.ParseInt(raw, 10, 64) if err != nil || n <= 0 { return "" } if n > 1000000000000 { n = n / 1000 } return time.Unix(n, 0).Local().Format("2006-01-02 15:04:05") } func greetingAnswer(content string) (string, bool) { normalized := normalizeGreetingText(content) switch normalized { case "你好", "您好", "在吗", "在嘛", "在不在", "有人吗", "有人嘛", "hello", "hi", "hey", "哈喽", "哈罗", "嗨": return "您好,我在的,请问有什么可以帮您?", true default: return "", false } } func quickConversationAnswer(content string, cfg config.AutoReplyConfig) (string, bool) { normalized := normalizeGreetingText(content) if isCompanyIdentityQuestion(normalized) { return configuredIdentityAnswer(cfg), true } switch normalized { case "你是谁", "你是什么", "你是机器人吗", "你是客服吗", "你叫什么", "你能做什么", "你能干什么": return configuredIdentityAnswer(cfg), true default: return "", false } } func configuredIdentityAnswer(cfg config.AutoReplyConfig) string { identity := safeIdentityFromSystemPrompt(cfg.AI.SystemPrompt) if identity == "" { identity = "我是企业微信智能客服助手" } if strings.HasPrefix(identity, "我") { return "您好," + identity + "。普通问题我会尽量帮您解答;涉及产品、方案或售后资料时,我会优先按已有资料回复。" } return "您好,我是" + identity + "。普通问题我会尽量帮您解答;涉及产品、方案或售后资料时,我会优先按已有资料回复。" } func safeIdentityFromSystemPrompt(prompt string) string { text := strings.TrimSpace(prompt) if text == "" { return "" } text = strings.ReplaceAll(text, "\r\n", "\n") text = strings.ReplaceAll(text, "\r", "\n") parts := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '。' || r == '!' || r == '!' || r == ';' || r == ';' }) for _, part := range parts { candidate := cleanIdentityPromptSegment(part) if candidate == "" || unsafeIdentityPromptSegment(candidate) { continue } if looksLikeCompanyIdentity(candidate) || len([]rune(candidate)) <= 40 { return candidate } } candidate := cleanIdentityPromptSegment(text) if candidate == "" || unsafeIdentityPromptSegment(candidate) { return "" } if len([]rune(candidate)) > 40 && !looksLikeCompanyIdentity(candidate) { return "" } return candidate } func cleanIdentityPromptSegment(value string) string { text := strings.TrimRight(strings.TrimSpace(value), "。.!!") if text == "" { return "" } prefixes := []string{"你是一名", "你是一个", "你作为", "请你扮演", "请扮演", "作为", "你是"} for _, prefix := range prefixes { text = strings.TrimPrefix(text, prefix) } text = truncateIdentityInstructions(text) return strings.TrimSpace(strings.TrimRight(text, ",,、::")) } func unsafeIdentityPromptSegment(value string) bool { text := strings.TrimSpace(value) if text == "" { return true } for _, keyword := range []string{"规则", "必须", "禁止", "知识库", "系统", "提示词", "模型", "指令", "不要", "不能", "根据"} { if strings.Contains(text, keyword) { return true } } return false } func truncateIdentityInstructions(value string) string { text := strings.TrimSpace(value) cut := -1 for _, keyword := range []string{"规则", "必须", "禁止", "知识库", "系统", "提示词", "模型", "指令", "不要", "不能", "根据"} { if idx := strings.Index(text, keyword); idx >= 0 && (cut < 0 || idx < cut) { cut = idx } } if cut <= 0 { return text } return text[:cut] } func looksLikeCompanyIdentity(value string) bool { for _, keyword := range []string{"公司", "有限公司", "集团", "工厂", "品牌"} { if strings.Contains(value, keyword) { return true } } return false } func isCompanyIdentityQuestion(normalized string) bool { if strings.Contains(normalized, "什么公司") || strings.Contains(normalized, "哪个公司") || strings.Contains(normalized, "哪家公司") || strings.Contains(normalized, "哪个公司") { return true } return strings.Contains(normalized, "不是大铁") || strings.Contains(normalized, "不是大铁的吗") || strings.Contains(normalized, "你不是大铁") } func (e *AutoReplyEngine) quickConversationAnswerForMessage(msg autoReplyMessage) (string, bool) { if answer, ok := quickConversationAnswer(msg.Content, e.getConfig()); ok { return answer, true } normalized := normalizeGreetingText(msg.Content) switch normalized { case "我是谁", "我这边是谁", "你知道我是谁吗", "知道我是谁吗": if name := e.displayNameForMessage(msg); name != "" { return "您这边显示是 " + name + "。", true } e.startUnknownIdentityLookup(msg, "who_am_i") return "我这边正在核验您的联系人信息,您也可以补充姓名或公司方便我确认。", true default: return "", false } } func generalFallbackAnswer(content string) string { if answer, ok := quickConversationAnswer(content, config.AutoReplyConfig{}); ok { return answer } return "我在的。您可以把具体问题发给我,我会尽量帮您解答;涉及产品、方案或售后资料时,我会优先按知识库内容回复。" } func nonTextMessageDescription(msg autoReplyMessage) string { switch msg.RawType { case 11047: return "[图片/表情]" default: if msg.MessageType != "" { return "[" + msg.MessageType + "]" } return "[非文本消息]" } } func nonTextFallbackAnswer(msg autoReplyMessage) string { content := strings.TrimSpace(msg.Content) if content == "" { content = nonTextMessageDescription(msg) } if strings.Contains(content, "产品") || strings.Contains(content, "订单") || strings.Contains(content, "售后") || strings.Contains(content, "客服") || strings.Contains(content, "问题") { return "我看到了您发来的内容。如果是产品、订单或售后相关问题,麻烦您再补充一句文字说明,我好准确帮您处理。" } return "抱歉,你这问题超出我的岗位认知了,回答不了。" } func normalizeGreetingText(content string) string { content = strings.ToLower(strings.TrimSpace(content)) replacer := strings.NewReplacer( " ", "", "\t", "", "\r", "", "\n", "", ",", "", "。", "", "?", "", "?", "", "!", "", "!", "", "~", "", "~", "", ".", "", ",", "", ";", "", ";", "", ":", "", ":", "", ) return replacer.Replace(content) } func stringSliceFromAny(value interface{}) []string { switch v := value.(type) { case []string: return v case []interface{}: result := make([]string, 0, len(v)) for _, item := range v { text := stringFromAny(item) if text != "" { result = append(result, text) } } return result case string: if strings.TrimSpace(v) == "" { return nil } parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '|' || r == ' ' }) result := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { result = append(result, part) } } return result default: return nil } } func truncateText(text string, max int) string { runes := []rune(text) if len(runes) <= max { return text } return string(runes[:max]) + "..." }