package main import ( "fmt" "strings" "time" "qiweimanager/config" ) func (e *AutoReplyEngine) handoff(msg autoReplyMessage, reason string, hits []KnowledgeChunk) { e.handoffWithTimings(msg, reason, hits, autoReplyTimings{}) } func (e *AutoReplyEngine) handoffWithTimings(msg autoReplyMessage, reason string, hits []KnowledgeChunk, timings autoReplyTimings) { if msg.isInternalSender() { e.replyInternalNoHandoff(msg, reason, timings) return } if e.shouldHoldUnknownHandoff(msg) { e.replyUnknownNoHandoff(msg, reason, timings) return } if isManualHandoffReason(reason) { e.customerHandoffWithTimings(msg, reason, hits, timings) return } e.textHandoffWithTimings(msg, reason, reason, hits, timings, "") } func (e *AutoReplyEngine) textHandoffWithTimings(msg autoReplyMessage, notificationReason string, recordReason string, hits []KnowledgeChunk, timings autoReplyTimings, cardStatus string) { if err := e.sendHandoffMessage(msg, notificationReason, hits); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeHandoff, "转人工发送失败: "+err.Error()) 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: recordReason + "; handoff_failed: " + err.Error(), SenderIdentity: msg.SenderIdentity, IdentitySource: msg.IdentitySource, CardStatus: cardStatus, KeywordScore: timings.KeywordScore, VectorScore: timings.VectorScore, RerankScore: timings.RerankScore, RetrievalMode: timings.RetrievalMode, UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "), KnowledgeDurationMS: timings.KnowledgeDurationMS, AIDurationMS: timings.AIDurationMS, TotalDurationMS: timings.TotalDurationMS, }) return } e.markCooldown(msg) e.incStatus("handoff") 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: "handoff", Reason: recordReason, SenderIdentity: msg.SenderIdentity, IdentitySource: msg.IdentitySource, CardStatus: cardStatus, 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, }) } func (e *AutoReplyEngine) customerHandoffWithTimings(msg autoReplyMessage, reason string, hits []KnowledgeChunk, timings autoReplyTimings) { cardResult := e.sendHandoffCards(msg) recordReason := reason if suffix := cardResult.reasonSuffix(); suffix != "" { recordReason += "; " + suffix } e.textHandoffWithTimings(msg, reason, recordReason, hits, timings, cardResult.summary()) } func (e *AutoReplyEngine) replyUnknownNoHandoff(msg autoReplyMessage, originalReason string, timings autoReplyTimings) { cfg := e.getConfig() e.startUnknownIdentityLookup(msg, originalReason) answer := strings.TrimSpace(cfg.Identity.UnknownNoHandoffReply) if answer == "" { answer = "正在核验联系人身份,暂不触发转人工。如需协助请直接联系对应同事。" } reason := "identity_unknown_no_handoff" if originalReason != "" { reason += "; original_reason: " + originalReason } e.noteReason("identity_unknown_no_handoff") if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, answer); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeIdentity, "未知身份拦截回复失败: "+err.Error()) 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_unknown_no_handoff_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 } e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, answer) e.markCooldown(msg) e.incStatus("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: reason, Answer: answer, 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, }) } func (e *AutoReplyEngine) replyInternalNoHandoff(msg autoReplyMessage, originalReason string, timings autoReplyTimings) { cfg := e.getConfig() answer := strings.TrimSpace(cfg.Identity.InternalNoHandoffReply) if answer == "" { answer = "内部员工消息不触发转人工,如需协助请直接联系对应同事。" } reason := "internal_employee_no_handoff" if originalReason != "" { reason += "; original_reason: " + originalReason } e.noteReason("internal_employee_no_handoff") if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, answer); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeIdentity, "内部员工拦截回复失败: "+err.Error()) 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_internal_no_handoff_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 } e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, answer) e.markCooldown(msg) e.incStatus("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: reason, Answer: answer, 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, }) } func (e *AutoReplyEngine) sendHandoffMessage(msg autoReplyMessage, reason string, hits []KnowledgeChunk) error { cfg := e.getConfig() conversationID, err := e.resolveHumanConversationID(msg, cfg) if err != nil { return err } content := renderHandoffTemplate(cfg.Handoff.MessageTemplate, msg, reason) if cfg.Handoff.IncludeKnowledgeHits && len(hits) > 0 { content += "\n\n知识库候选:" for i, hit := range hits { if i >= 3 { break } content += fmt.Sprintf("\n%d. %s / score=%.3f", i+1, hit.Source, hit.Score) } } if err := sendAutoReplyText(uint32(msg.ClientID), conversationID, content); err != nil { return err } e.rememberAutoSentMessage(uint32(msg.ClientID), conversationID, content) return nil } type handoffCardResult struct { statuses []string errors []string } func (r handoffCardResult) summary() string { items := append([]string{}, r.statuses...) items = append(items, r.errors...) return strings.Join(items, "; ") } func (r handoffCardResult) reasonSuffix() string { return strings.Join(r.errors, "; ") } func (e *AutoReplyEngine) sendHandoffCards(msg autoReplyMessage) handoffCardResult { cfg := e.getConfig() result := handoffCardResult{} if cfg.Handoff.SendHumanCardToCustomer { humanID := e.resolveHumanUserID(msg, cfg) if humanID == "" { result.errors = append(result.errors, "human_card_missing_user_id") e.noteReason("human_card_missing_user_id") } else if err := sendAutoReplyCard(uint32(msg.ClientID), msg.ConversationID, humanID); err != nil { result.errors = append(result.errors, "human_card_failed: "+err.Error()) e.noteReason("human_card_failed") } else { result.statuses = append(result.statuses, "human_card_sent") e.noteReason("human_card_sent") if err := e.sendCustomerHandoffNotice(msg, cfg); err != nil { result.errors = append(result.errors, "customer_notice_failed: "+err.Error()) e.noteReason("customer_notice_failed") } else { result.statuses = append(result.statuses, "customer_notice_sent") e.noteReason("customer_notice_sent") } } } if cfg.Handoff.SendCustomerCardToHuman { conversationID, err := e.resolveHumanConversationID(msg, cfg) switch { case err != nil: result.errors = append(result.errors, "customer_card_failed: "+err.Error()) e.noteReason("customer_card_failed") case strings.TrimSpace(msg.FromWxID) == "": result.errors = append(result.errors, "customer_card_failed: 缺少客户user_id") e.noteReason("customer_card_failed") default: if err := sendAutoReplyCard(uint32(msg.ClientID), conversationID, msg.FromWxID); err != nil { result.errors = append(result.errors, "customer_card_failed: "+err.Error()) e.noteReason("customer_card_failed") } else { result.statuses = append(result.statuses, "customer_card_sent") e.noteReason("customer_card_sent") } } } return result } func (e *AutoReplyEngine) sendCustomerHandoffNotice(msg autoReplyMessage, cfg config.AutoReplyConfig) error { notice := strings.TrimSpace(cfg.Handoff.CustomerHandoffNotice) if notice == "" { notice = config.NewDefaultAutoReplyConfig().Handoff.CustomerHandoffNotice } if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, notice); err != nil { return err } e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, notice) return nil } func isManualHandoffReason(reason string) bool { return strings.HasPrefix(strings.TrimSpace(reason), "manual_keyword:") } func (e *AutoReplyEngine) shouldSendHandoffCards(_ autoReplyMessage, reason string) bool { return isManualHandoffReason(reason) } func (e *AutoReplyEngine) resolveHumanConversationID(msg autoReplyMessage, cfg config.AutoReplyConfig) (string, error) { conversationID := strings.TrimSpace(cfg.Handoff.HumanConversationID) if conversationID != "" { return conversationID, nil } humanID := e.resolveHumanUserID(msg, cfg) if humanID == "" { return "", fmt.Errorf("未配置人工接管同事") } robotID := msg.RobotID if robotID == "" { clientIdMutex.Lock() robotID = globalClientMap[uint32(msg.ClientID)] clientIdMutex.Unlock() } if robotID == "" || strings.HasPrefix(robotID, "client:") { return "", fmt.Errorf("无法推导人工私信会话,缺少当前接管账号ID") } return fmt.Sprintf("S:%s_%s", robotID, humanID), nil } func (e *AutoReplyEngine) resolveHumanUserID(msg autoReplyMessage, cfg config.AutoReplyConfig) string { if humanID := strings.TrimSpace(cfg.Handoff.HumanUserID); humanID != "" { return humanID } return extractPeerIDFromConversation(cfg.Handoff.HumanConversationID, msg.RobotID) } func (m autoReplyMessage) isInternalSender() bool { return m.SenderIdentity == senderIdentityInternal } func (e *AutoReplyEngine) testHandoff() error { msg := autoReplyMessage{ ClientID: int32(GetGlobalClientId()), RobotID: globalClientMap[GetGlobalClientId()], ConversationID: "test", FromWxID: "test-customer", FromNickName: "测试客户", Content: "这是一条自动客服转人工测试消息。", } if msg.ClientID == 0 { for clientID, userID := range globalClientMap { msg.ClientID = int32(clientID) msg.RobotID = userID break } } if msg.ClientID == 0 { return fmt.Errorf("没有活跃企微账号,无法测试发送") } return e.sendHandoffMessage(msg, "test_handoff", nil) } func renderHandoffTemplate(template string, msg autoReplyMessage, reason string) string { if strings.TrimSpace(template) == "" || isLegacyHandoffTemplate(template) { template = defaultHandoffTemplate(msg) } messageTime := strings.TrimSpace(msg.MessageTime) if messageTime == "" { messageTime = time.Now().Format("2006-01-02 15:04:05") } groupName := strings.TrimSpace(msg.GroupName) if groupName == "" && msg.IsGroup { groupName = msg.ConversationID } replacements := map[string]string{ "{{customerName}}": fallbackString(msg.FromNickName, "未知客户"), "{{fromWxId}}": msg.FromWxID, "{{source}}": msg.sourceDisplayLabel(), "{{sourceLabel}}": msg.sourceDisplayLabel(), "{{conversationId}}": msg.ConversationID, "{{groupName}}": groupName, "{{question}}": msg.Content, "{{reason}}": handoffReasonLabel(reason), "{{reasonCode}}": reason, "{{messageTime}}": messageTime, "{{time}}": messageTime, } for key, value := range replacements { template = strings.ReplaceAll(template, key, value) } return template } func defaultHandoffTemplate(msg autoReplyMessage) string { if msg.IsGroup { return "群聊问题待处理\n\n群聊:{{groupName}}\n客户:{{customerName}}\n客户ID:{{fromWxId}}\n来源:{{sourceLabel}}\n时间:{{messageTime}}\n问题:{{question}}\n原因:{{reason}}\n会话ID:{{conversationId}}" } return "客户问题待处理\n\n客户:{{customerName}}\n客户ID:{{fromWxId}}\n来源:{{sourceLabel}}\n时间:{{messageTime}}\n问题:{{question}}\n原因:{{reason}}\n会话ID:{{conversationId}}" } func isLegacyHandoffTemplate(template string) bool { return strings.Contains(template, "客户问题需要人工处理") && strings.Contains(template, "{{customerName}}") && strings.Contains(template, "{{question}}") } func handoffReasonLabel(reason string) string { reason = strings.TrimSpace(reason) switch { case reason == "knowledge_low_score": return "知识库未匹配到答案" case reason == "question_too_long": return "问题过长" case reason == "non_text_message": return "非文本消息" case reason == "ai_no_answer": return "AI 无法回答" case strings.HasPrefix(reason, "ai_error:"): lower := strings.ToLower(reason) if strings.Contains(lower, "deadline exceeded") || strings.Contains(lower, "timeout") || strings.Contains(lower, "timed out") { return "AI 请求超时" } return "AI 请求失败" case strings.HasPrefix(reason, "send_reply_failed:"): return "自动回复发送失败" case strings.HasPrefix(reason, "send_greeting_failed:"): return "问候回复发送失败" case strings.HasPrefix(reason, "manual_keyword:"): keyword := strings.TrimSpace(strings.TrimPrefix(reason, "manual_keyword:")) if keyword == "" { return "命中敏感关键词" } return "命中敏感关键词:" + keyword default: if reason == "" { return "未知原因" } return reason } } func fallbackString(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value }