Files
qiweimanager-master/helper/auto_reply.go

1622 lines
50 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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"
}
return msg
}
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"]))
}
}
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
}
if data, ok := raw["data"].(map[string]interface{}); ok {
switch intFromAny(firstNonNil(data["messageType"], data["content_type"], data["contentType"])) {
case 2:
return 11042
case 4:
return 11043
case 16:
return 11044
case 6:
return 11045
case 48:
return 11046
}
}
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]) + "..."
}