package main import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "time" "qiweimanager/config" ) const ( senderIdentityInternal = "internal" senderIdentityExternal = "external" senderIdentityUnknown = "unknown" identitySourceInternalCache = "internal_cache" identitySourceExternalCache = "external_cache" identitySourceSingleInfo = "single_info" identitySourceManualInternal = "manual_internal" identitySourceManualExternal = "manual_external" identitySourceKnownRobot = "known_robot" identitySourceConfiguredHuman = "configured_human" identitySourceObservedMessage = "observed_message" identitySourceInternalGroup = "internal_group_member" identitySourceUnknownAsCustomer = "identity_unknown_as_customer" identitySourceUnknownIgnored = "identity_unknown_ignored" maxIdentityRefreshPages = 10 maxIdentityPrelookup = 200 maxIdentityGroupPages = 20 identityLookupCooldown = 5 * time.Minute identityInitialWaitMax = 1200 * time.Millisecond identityInitialWaitStep = 50 * time.Millisecond identityEmptyCacheWarning = "联系人列表未返回或未解析;未知身份转人工已被拦截,请稍后重试刷新联系人或使用手动身份兜底" ) type autoReplyIdentityContact struct { UserID string `json:"userId"` Name string `json:"name"` Kind string `json:"kind"` Source string `json:"source"` ClientID int32 `json:"clientId"` Scope string `json:"scope"` LastSeenAt int64 `json:"lastSeenAt"` ConversationID string `json:"conversationId"` } type autoReplyIdentityCache struct { Internal map[string]autoReplyIdentityContact External map[string]autoReplyIdentityContact Observed map[string]autoReplyIdentityContact LastRefreshAt int64 } type autoReplyIdentityStore struct { Internal map[string]autoReplyIdentityContact `json:"internal"` External map[string]autoReplyIdentityContact `json:"external"` Observed map[string]autoReplyIdentityContact `json:"observed"` Groups map[string]autoReplyGroupOption `json:"groups,omitempty"` Scopes map[string]autoReplyIdentityBucket `json:"scopes,omitempty"` LastSavedAt int64 `json:"lastSavedAt"` } type autoReplyIdentityBucket struct { Internal map[string]autoReplyIdentityContact `json:"internal"` External map[string]autoReplyIdentityContact `json:"external"` Observed map[string]autoReplyIdentityContact `json:"observed"` } type autoReplySenderIdentity struct { Kind string Source string Name string TreatAsCustomer bool } type autoReplyIdentityOption struct { UserID string `json:"userId"` Name string `json:"name"` Source string `json:"source"` ClientID int32 `json:"clientId"` Scope string `json:"scope"` LastSeenAt int64 `json:"lastSeenAt"` SourceAccountUserID string `json:"sourceAccountUserId"` SourceAccountName string `json:"sourceAccountName"` } type autoReplyGroupOption struct { ConversationID string `json:"conversationId"` Name string `json:"name"` Source string `json:"source"` ClientID int32 `json:"clientId"` LastSeenAt int64 `json:"lastSeenAt"` MemberCount int `json:"memberCount"` } func (e *AutoReplyEngine) classifySenderIdentity(msg autoReplyMessage) autoReplySenderIdentity { senderID := strings.TrimSpace(msg.FromWxID) if senderID == "" { return e.unknownSenderIdentity() } cfg := e.getConfig() if humanID := strings.TrimSpace(cfg.Handoff.HumanUserID); humanID != "" && senderID == humanID { return autoReplySenderIdentity{Kind: senderIdentityInternal, Source: identitySourceConfiguredHuman, Name: msg.FromNickName} } if humanID := extractPeerIDFromConversation(cfg.Handoff.HumanConversationID, msg.RobotID); humanID != "" && senderID == humanID { return autoReplySenderIdentity{Kind: senderIdentityInternal, Source: identitySourceConfiguredHuman, Name: msg.FromNickName} } if isKnownRobotUserID(senderID) { return autoReplySenderIdentity{Kind: senderIdentityInternal, Source: identitySourceKnownRobot, Name: msg.FromNickName} } if containsConfiguredUserID(cfg.Identity.InternalUserIDs, senderID) { return autoReplySenderIdentity{Kind: senderIdentityInternal, Source: identitySourceManualInternal, Name: msg.FromNickName} } if containsConfiguredUserID(cfg.Identity.ExternalUserIDs, senderID) { return autoReplySenderIdentity{Kind: senderIdentityExternal, Source: identitySourceManualExternal, Name: msg.FromNickName, TreatAsCustomer: true} } e.waitForIdentityInitialization(msg) scope := e.identityScopeForClient(msg.ClientID) e.mu.Lock() cache := e.identityCaches[msg.ClientID] if cache != nil { ensureIdentityCacheMaps(cache) e.adoptLegacyIdentityScopeLocked(msg.ClientID, scope) internalContact, hasInternal := cache.Internal[senderID] externalContact, hasExternal := cache.External[senderID] observedContact, hasObserved := cache.Observed[senderID] hasInternal = hasInternal && contactMatchesIdentityScope(internalContact, scope) hasExternal = hasExternal && contactMatchesIdentityScope(externalContact, scope) hasObserved = hasObserved && contactMatchesIdentityScope(observedContact, scope) if hasInternal && hasExternal { e.mu.Unlock() return resolveCachedIdentityConflict(internalContact, externalContact, msg.FromNickName) } if hasExternal { e.mu.Unlock() return cachedExternalIdentity(externalContact, msg.FromNickName) } if hasInternal { e.mu.Unlock() return cachedInternalIdentity(internalContact, msg.FromNickName) } if hasObserved { e.mu.Unlock() identity := e.unknownSenderIdentity() identity.Name = fallbackString(observedContact.Name, msg.FromNickName) return identity } } e.mu.Unlock() return e.unknownSenderIdentity() } func resolveCachedIdentityConflict(internalContact autoReplyIdentityContact, externalContact autoReplyIdentityContact, fallbackName string) autoReplySenderIdentity { internalRank := identitySourceRank(internalContact.Source) externalRank := identitySourceRank(externalContact.Source) if internalRank > externalRank { return cachedInternalIdentity(internalContact, fallbackName) } return cachedExternalIdentity(externalContact, fallbackName) } func cachedInternalIdentity(contact autoReplyIdentityContact, fallbackName string) autoReplySenderIdentity { return autoReplySenderIdentity{ Kind: senderIdentityInternal, Source: fallbackString(contact.Source, identitySourceInternalCache), Name: fallbackString(cleanIdentityName(contact.Name, contact.UserID), fallbackName), } } func cachedExternalIdentity(contact autoReplyIdentityContact, fallbackName string) autoReplySenderIdentity { return autoReplySenderIdentity{ Kind: senderIdentityExternal, Source: fallbackString(contact.Source, identitySourceExternalCache), Name: fallbackString(cleanIdentityName(contact.Name, contact.UserID), fallbackName), TreatAsCustomer: true, } } func identitySourceRank(source string) int { switch strings.TrimSpace(source) { case identitySourceInternalCache, identitySourceExternalCache, identitySourceInternalGroup: return 2 case identitySourceSingleInfo: return 1 default: return 0 } } func (e *AutoReplyEngine) unknownSenderIdentity() autoReplySenderIdentity { cfg := e.getConfig() switch strings.ToLower(strings.TrimSpace(cfg.Identity.UnknownPolicy)) { case "ignore", "ignored": return autoReplySenderIdentity{Kind: senderIdentityUnknown, Source: identitySourceUnknownIgnored} case "internal", "employee": return autoReplySenderIdentity{Kind: senderIdentityInternal, Source: "identity_unknown_as_internal"} default: return autoReplySenderIdentity{Kind: senderIdentityUnknown, Source: identitySourceUnknownAsCustomer, TreatAsCustomer: true} } } func (e *AutoReplyEngine) waitForIdentityInitialization(msg autoReplyMessage) { if msg.IsGroup { return } if strings.TrimSpace(msg.FromWxID) == "" { return } scope := e.identityScopeForClient(msg.ClientID) deadline := time.Now().Add(identityInitialWaitMax) for { e.mu.Lock() initializing := e.status.IdentityInitializing if initializing { cache := e.identityCaches[msg.ClientID] if cache != nil { ensureIdentityCacheMaps(cache) _, hasInternal := cache.Internal[msg.FromWxID] internalContact := cache.Internal[msg.FromWxID] _, hasExternal := cache.External[msg.FromWxID] externalContact := cache.External[msg.FromWxID] hasInternal = hasInternal && contactMatchesIdentityScope(internalContact, scope) hasExternal = hasExternal && contactMatchesIdentityScope(externalContact, scope) if hasInternal || hasExternal { e.mu.Unlock() return } } } e.mu.Unlock() if !initializing || time.Now().After(deadline) { return } time.Sleep(identityInitialWaitStep) } } func (id autoReplySenderIdentity) isInternal() bool { return id.Kind == senderIdentityInternal } func isKnownRobotUserID(userID string) bool { userID = strings.TrimSpace(userID) if userID == "" { return false } clientIdMutex.Lock() defer clientIdMutex.Unlock() for _, robotID := range globalClientMap { if strings.TrimSpace(robotID) == userID { return true } } return false } func knownRobotUserIDsSnapshot() []string { clientIdMutex.Lock() defer clientIdMutex.Unlock() seen := make(map[string]bool) result := make([]string, 0, len(globalClientMap)) for _, robotID := range globalClientMap { robotID = strings.TrimSpace(robotID) if robotID == "" || seen[robotID] { continue } seen[robotID] = true result = append(result, robotID) } sort.Strings(result) return result } func (e *AutoReplyEngine) identityScopeForClient(clientID int32) string { if clientID > 0 { if corpID := e.robotCorpIDForClient(clientID); corpID != "" { return "corp:" + corpID } if robotID := strings.TrimSpace(getClientUserID(uint32(clientID))); robotID != "" { return "robot:" + robotID } return fmt.Sprintf("client:%d", clientID) } return "" } func (e *AutoReplyEngine) currentIdentityScope() string { clientIDs := identityRefreshClientIDs() for _, clientID := range clientIDs { if scope := e.identityScopeForClient(int32(clientID)); scope != "" { return scope } } return "" } func contactMatchesIdentityScope(contact autoReplyIdentityContact, scope string) bool { contactScope := strings.TrimSpace(contact.Scope) scope = strings.TrimSpace(scope) return scope == "" || contactScope == scope } func scopedInternalGroupIDs(identity config.IdentityConfig, scope string) []string { scope = strings.TrimSpace(scope) if scope != "" && identity.InternalGroupIDsByScope != nil { if ids := dedupeNonEmptyStrings(identity.InternalGroupIDsByScope[scope]); len(ids) > 0 { return ids } } return dedupeNonEmptyStrings(identity.InternalGroupConversationIDs) } func extractPeerIDFromConversation(conversationID string, robotID string) string { conversationID = strings.TrimSpace(conversationID) robotID = strings.TrimSpace(robotID) if conversationID == "" || robotID == "" || strings.HasPrefix(robotID, "client:") || !strings.HasPrefix(conversationID, "S:") { return "" } parts := strings.Split(strings.TrimPrefix(conversationID, "S:"), "_") if len(parts) != 2 { return "" } if parts[0] == robotID { return strings.TrimSpace(parts[1]) } if parts[1] == robotID { return strings.TrimSpace(parts[0]) } return "" } func (e *AutoReplyEngine) observeIdentityContacts(clientID int32, raw map[string]interface{}) bool { requestType := intFromAny(raw["type"]) if requestType != 11036 && requestType != 11037 && requestType != 11038 && requestType != 11039 && requestType != 11040 && requestType != 11052 { return false } switch requestType { case 11036, 11037: kind := senderIdentityInternal source := identitySourceInternalCache if requestType == 11037 { kind = senderIdentityExternal source = identitySourceExternalCache } contacts := extractIdentityContacts(raw, kind, source) e.mergeIdentityContacts(clientID, kind, contacts) e.recordIdentityResponse(requestType, len(contacts), "") case 11038: groups := extractIdentityGroups(raw) e.mergeIdentityGroups(clientID, groups) e.recordIdentityResponse(requestType, len(groups), "") case 11039, 11052: contactsByKind := e.extractInferredIdentityContacts(clientID, raw) total := 0 for kind, contacts := range contactsByKind { total += len(contacts) e.mergeIdentityContacts(clientID, kind, contacts) } e.recordIdentityResponse(requestType, total, "") case 11040: contacts := extractIdentityContacts(raw, senderIdentityInternal, identitySourceInternalGroup) e.mergeIdentityContacts(clientID, senderIdentityInternal, contacts) e.startIdentityNameLookups(clientID, contacts, "internal_group_member") e.recordIdentityResponse(requestType, len(contacts), "") e.recordInternalGroupMemberSyncResult(len(contacts), "") } return true } func extractIdentityContacts(raw map[string]interface{}, kind string, source string) []autoReplyIdentityContact { contacts := make(map[string]autoReplyIdentityContact) collectIdentityContacts(raw, kind, source, contacts) result := make([]autoReplyIdentityContact, 0, len(contacts)) now := time.Now().Unix() for _, contact := range contacts { contact.LastSeenAt = now contact.Name = cleanIdentityName(contact.Name, contact.UserID) result = append(result, contact) } return result } func collectIdentityContacts(value interface{}, kind string, source string, out map[string]autoReplyIdentityContact) { switch v := value.(type) { case map[string]interface{}: if identityGroupConversationIDFromMap(v) == "" { userID := identityUserIDFromMap(v) if userID != "" { if !isKnownRobotUserID(userID) { name := cleanIdentityName(identityNameFromMap(v), userID) contact := autoReplyIdentityContact{ UserID: userID, Name: name, Kind: kind, Source: source, } if existing, ok := out[userID]; ok && contact.Name == "" { contact.Name = existing.Name } out[userID] = contact } } } for _, item := range v { collectIdentityContacts(item, kind, source, out) } case []interface{}: for _, item := range v { collectIdentityContacts(item, kind, source, out) } } } func extractIdentityGroups(raw map[string]interface{}) []autoReplyGroupOption { groups := make(map[string]autoReplyGroupOption) collectIdentityGroups(raw, groups) result := make([]autoReplyGroupOption, 0, len(groups)) now := time.Now().Unix() for _, group := range groups { group.LastSeenAt = now result = append(result, group) } return result } func collectIdentityGroups(value interface{}, out map[string]autoReplyGroupOption) { switch v := value.(type) { case map[string]interface{}: conversationID := identityGroupConversationIDFromMap(v) if conversationID != "" { name := identityGroupNameFromMap(v) out[conversationID] = autoReplyGroupOption{ ConversationID: conversationID, Name: name, Source: "group_list", MemberCount: identityGroupMemberCountFromMap(v), } } for _, item := range v { collectIdentityGroups(item, out) } case []interface{}: for _, item := range v { collectIdentityGroups(item, out) } } } func identityGroupConversationIDFromMap(v map[string]interface{}) string { id := stringFromAny(firstNonNil( v["conversation_id"], v["conversationId"], v["room_conversation_id"], v["roomConversationId"], v["room_id"], v["roomId"], )) if id == "" || !strings.HasPrefix(id, "R:") { return "" } return id } func identityGroupNameFromMap(v map[string]interface{}) string { return stringFromAny(firstNonNil( v["room_name"], v["roomName"], v["group_name"], v["groupName"], v["conversation_name"], v["conversationName"], v["name"], v["nickname"], )) } func identityGroupMemberCountFromMap(v map[string]interface{}) int { return intFromAny(firstNonNil( v["member_count"], v["memberCount"], v["member_num"], v["memberNum"], v["member_cnt"], v["memberCnt"], v["participant_count"], v["participantCount"], v["chatroom_member_count"], v["chatroomMemberCount"], v["total"], v["total_count"], v["totalCount"], )) } func (e *AutoReplyEngine) extractInferredIdentityContacts(clientID int32, raw map[string]interface{}) map[string][]autoReplyIdentityContact { contacts := map[string]map[string]autoReplyIdentityContact{ senderIdentityInternal: {}, senderIdentityExternal: {}, } e.collectInferredIdentityContacts(clientID, raw, contacts) result := make(map[string][]autoReplyIdentityContact) now := time.Now().Unix() for kind, items := range contacts { if len(items) == 0 { continue } result[kind] = make([]autoReplyIdentityContact, 0, len(items)) for _, contact := range items { contact.LastSeenAt = now result[kind] = append(result[kind], contact) } } return result } func (e *AutoReplyEngine) collectInferredIdentityContacts(clientID int32, value interface{}, out map[string]map[string]autoReplyIdentityContact) { switch v := value.(type) { case map[string]interface{}: userID := identityUserIDFromMap(v) if userID != "" { if kind := e.inferIdentityKind(clientID, v); kind != "" { out[kind][userID] = autoReplyIdentityContact{ UserID: userID, Name: cleanIdentityName(identityNameFromMap(v), userID), Kind: kind, Source: identitySourceSingleInfo, } } else if kind := e.cachedIdentityKindForUser(clientID, userID); kind != "" { out[kind][userID] = autoReplyIdentityContact{ UserID: userID, Name: cleanIdentityName(identityNameFromMap(v), userID), Kind: kind, Source: identitySourceSingleInfo, } } } for _, item := range v { e.collectInferredIdentityContacts(clientID, item, out) } case []interface{}: for _, item := range v { e.collectInferredIdentityContacts(clientID, item, out) } } } func (e *AutoReplyEngine) cachedIdentityKindForUser(clientID int32, userID string) string { userID = strings.TrimSpace(userID) if userID == "" { return "" } scope := e.identityScopeForClient(clientID) e.mu.Lock() defer e.mu.Unlock() cache := e.identityCaches[clientID] if cache == nil { return "" } ensureIdentityCacheMaps(cache) if contact, ok := cache.Internal[userID]; ok && contactMatchesIdentityScope(contact, scope) { return senderIdentityInternal } if contact, ok := cache.External[userID]; ok && contactMatchesIdentityScope(contact, scope) { return senderIdentityExternal } return "" } func identityUserIDFromMap(v map[string]interface{}) string { userID := stringFromAny(firstNonNil( v["user_id"], v["userId"], v["userid"], v["acctid"], v["account"], v["wxid"], v["wx_id"], v["id"], v["vid"], v["open_user_id"], v["openUserId"], v["external_userid"], v["external_user_id"], v["externalUserId"], )) if userID == "" || strings.HasPrefix(userID, "R:") || strings.HasPrefix(userID, "S:") { return "" } return userID } func identityNameFromMap(v map[string]interface{}) string { return firstNonEmptyIdentityString( v["remark"], v["remark_name"], v["remarkName"], v["name"], v["real_name"], v["realName"], v["realname"], v["real_name_py"], v["member_name"], v["memberName"], v["user_name"], v["userName"], v["nickname"], v["nick_name"], v["nickName"], v["display_name"], v["displayName"], v["username"], v["alias"], v["avatar_name"], v["avatarName"], v["corp_name"], v["corpName"], ) } func firstNonEmptyIdentityString(values ...interface{}) string { for _, value := range values { text := stringFromAny(value) if strings.TrimSpace(text) != "" { return text } } return "" } func (e *AutoReplyEngine) inferIdentityKind(clientID int32, data map[string]interface{}) string { if identityMapHasTruthy(data, "is_internal", "isInternal", "internal", "is_employee", "isEmployee") { return senderIdentityInternal } if identityMapHasTruthy(data, "is_external", "isExternal", "external", "is_customer", "isCustomer") { return senderIdentityExternal } for _, key := range []string{"kind", "type", "contact_type", "contactType", "user_type", "userType", "relation", "source", "friend_type", "friendType"} { text := strings.ToLower(stringFromAny(data[key])) switch { case strings.Contains(text, "internal") || strings.Contains(text, "employee") || strings.Contains(text, "corp") || strings.Contains(text, "内部") || strings.Contains(text, "员工") || strings.Contains(text, "同事"): return senderIdentityInternal case strings.Contains(text, "external") || strings.Contains(text, "customer") || strings.Contains(text, "client") || strings.Contains(text, "外部") || strings.Contains(text, "客户"): return senderIdentityExternal } } if identityUserIDFromMap(data) != "" && stringFromAny(firstNonNil(data["external_userid"], data["external_user_id"], data["externalUserId"])) != "" { return senderIdentityExternal } if robotCorpID := e.robotCorpIDForClient(clientID); robotCorpID != "" { corpID := stringFromAny(firstNonNil(data["corp_id"], data["corpId"], data["company_id"], data["companyId"])) if corpID != "" { if corpID == robotCorpID { return senderIdentityInternal } return senderIdentityExternal } } return "" } func identityMapHasTruthy(data map[string]interface{}, keys ...string) bool { for _, key := range keys { value, exists := data[key] if !exists { continue } switch v := value.(type) { case bool: if v { return true } case string: text := strings.ToLower(strings.TrimSpace(v)) if text == "1" || text == "true" || text == "yes" || text == "y" || text == "是" { return true } case float64: if v != 0 { return true } case int: if v != 0 { return true } } } return false } func (e *AutoReplyEngine) mergeIdentityContacts(clientID int32, kind string, contacts []autoReplyIdentityContact) { changed := false e.mu.Lock() if e.identityCaches == nil { e.identityCaches = make(map[int32]*autoReplyIdentityCache) } cache := e.identityCaches[clientID] if cache == nil { cache = &autoReplyIdentityCache{ Internal: make(map[string]autoReplyIdentityContact), External: make(map[string]autoReplyIdentityContact), } e.identityCaches[clientID] = cache } ensureIdentityCacheMaps(cache) scope := e.identityScopeForClient(clientID) e.adoptLegacyIdentityScopeLocked(clientID, scope) target := cache.Internal if kind == senderIdentityExternal { target = cache.External } for _, contact := range contacts { if strings.TrimSpace(contact.UserID) == "" { continue } contact.UserID = strings.TrimSpace(contact.UserID) if isKnownRobotUserID(contact.UserID) { delete(cache.Internal, contact.UserID) delete(cache.External, contact.UserID) delete(cache.Observed, contact.UserID) changed = true continue } contact.Name = cleanIdentityName(contact.Name, contact.UserID) if strings.TrimSpace(contact.Scope) == "" { contact.Scope = scope } if contact.Name == "" { contact.Name = identityDisplayNameFromCache(cache, contact.UserID, contact.Scope) } contact.ClientID = clientID contact.Kind = kind if contact.LastSeenAt <= 0 { contact.LastSeenAt = time.Now().Unix() } if existing, ok := target[contact.UserID]; ok { contact = mergeIdentityContact(existing, contact) } if target[contact.UserID] != contact { target[contact.UserID] = contact changed = true } } cache.LastRefreshAt = time.Now().Unix() e.status.IdentityLastRefreshAt = cache.LastRefreshAt e.status.InternalContactCount, e.status.ExternalContactCount = e.identityContactCountsLocked() if len(contacts) > 0 { e.status.IdentityRefreshError = "" } e.mu.Unlock() if changed { e.saveIdentityCache() } } func mergeIdentityContact(existing autoReplyIdentityContact, next autoReplyIdentityContact) autoReplyIdentityContact { if cleanIdentityName(next.Name, next.UserID) == "" && cleanIdentityName(existing.Name, existing.UserID) != "" { next.Name = existing.Name } if strings.TrimSpace(next.Source) == "" { next.Source = existing.Source } if next.LastSeenAt <= 0 || (existing.LastSeenAt > 0 && existing.LastSeenAt > next.LastSeenAt && cleanIdentityName(next.Name, next.UserID) == "") { next.LastSeenAt = existing.LastSeenAt } if next.ClientID <= 0 { next.ClientID = existing.ClientID } if strings.TrimSpace(next.Scope) == "" { next.Scope = existing.Scope } if strings.TrimSpace(next.ConversationID) == "" { next.ConversationID = existing.ConversationID } if strings.TrimSpace(next.Kind) == "" { next.Kind = existing.Kind } return next } func ensureIdentityCacheMaps(cache *autoReplyIdentityCache) { if cache == nil { return } if cache.Internal == nil { cache.Internal = make(map[string]autoReplyIdentityContact) } if cache.External == nil { cache.External = make(map[string]autoReplyIdentityContact) } if cache.Observed == nil { cache.Observed = make(map[string]autoReplyIdentityContact) } } func (e *AutoReplyEngine) adoptLegacyIdentityScopeLocked(clientID int32, scope string) { scope = strings.TrimSpace(scope) if scope == "" { return } cache := e.identityCaches[clientID] if cache == nil { return } ensureIdentityCacheMaps(cache) adopt := func(contacts map[string]autoReplyIdentityContact) { for userID, contact := range contacts { if strings.TrimSpace(contact.Scope) != "" { continue } contact.Scope = scope contacts[userID] = contact } } adopt(cache.Internal) adopt(cache.External) adopt(cache.Observed) } func (e *AutoReplyEngine) mergeIdentityGroups(clientID int32, groups []autoReplyGroupOption) { changed := false e.mu.Lock() if e.identityGroups == nil { e.identityGroups = make(map[int32]map[string]autoReplyGroupOption) } if e.groupNames == nil { e.groupNames = make(map[string]string) } target := e.identityGroups[clientID] if target == nil { target = make(map[string]autoReplyGroupOption) e.identityGroups[clientID] = target } for _, group := range groups { conversationID := strings.TrimSpace(group.ConversationID) if conversationID == "" { continue } group.ConversationID = conversationID group.ClientID = clientID if group.LastSeenAt <= 0 { group.LastSeenAt = time.Now().Unix() } if strings.TrimSpace(group.Source) == "" { group.Source = "group_list" } current, exists := target[conversationID] if group.MemberCount <= 0 && current.MemberCount > 0 { group.MemberCount = current.MemberCount } if !exists || current.LastSeenAt <= group.LastSeenAt || (current.Name == "" && group.Name != "") || (current.MemberCount <= 0 && group.MemberCount > 0) { target[conversationID] = group changed = true } if strings.TrimSpace(group.Name) != "" && e.groupNames[conversationID] != group.Name { e.groupNames[conversationID] = group.Name changed = true } } e.status.IdentityGroupOptionCount = e.identityGroupOptionCountLocked() e.mu.Unlock() if changed { e.saveIdentityCache() } } func (e *AutoReplyEngine) identityGroupOptionsSnapshot() []autoReplyGroupOption { e.mu.Lock() byClient := make(map[int32][]autoReplyGroupOption) for clientID, groups := range e.identityGroups { for conversationID, group := range groups { conversationID = strings.TrimSpace(fallbackString(group.ConversationID, conversationID)) if conversationID == "" { continue } group.ConversationID = conversationID group.ClientID = clientID if !shouldExposeIdentityGroupOption(group) { continue } byClient[clientID] = append(byClient[clientID], group) } } e.mu.Unlock() byID := make(map[string]autoReplyGroupOption) for _, groups := range byClient { for _, group := range totalGroupCandidates(groups) { current, exists := byID[group.ConversationID] if !exists || current.LastSeenAt < group.LastSeenAt || (current.Name == "" && group.Name != "") || (current.MemberCount <= 0 && group.MemberCount > 0) { byID[group.ConversationID] = group } } } result := make([]autoReplyGroupOption, 0, len(byID)) for _, group := range byID { result = append(result, group) } sort.Slice(result, func(i, j int) bool { left := strings.ToLower(strings.TrimSpace(result[i].Name)) right := strings.ToLower(strings.TrimSpace(result[j].Name)) if left != "" && right != "" && left != right { return left < right } if left != "" && right == "" { return true } if left == "" && right != "" { return false } return result[i].ConversationID < result[j].ConversationID }) return result } func totalGroupCandidates(groups []autoReplyGroupOption) []autoReplyGroupOption { if len(groups) == 0 { return groups } maxMembers := 0 for _, group := range groups { if group.MemberCount > maxMembers { maxMembers = group.MemberCount } } if maxMembers <= 0 { return groups } result := make([]autoReplyGroupOption, 0, len(groups)) for _, group := range groups { if group.MemberCount == maxMembers { result = append(result, group) } } return result } func (e *AutoReplyEngine) identityGroupOptionCountLocked() int { count := 0 for _, groups := range e.identityGroups { for _, group := range groups { if shouldExposeIdentityGroupOption(group) { count++ } } } return count } func shouldExposeIdentityGroupOption(group autoReplyGroupOption) bool { if strings.TrimSpace(group.ConversationID) == "" { return false } return group.MemberCount != 1 } var identityCachePathOverride string func autoReplyIdentityCachePath() string { if strings.TrimSpace(identityCachePathOverride) != "" { return identityCachePathOverride } return resolveAutoReplyPath("config/auto_reply_identity_cache.json") } func (e *AutoReplyEngine) loadIdentityCache() error { path := autoReplyIdentityCachePath() data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil } return err } var store autoReplyIdentityStore if err := json.Unmarshal(data, &store); err != nil { return err } e.mu.Lock() if e.identityCaches == nil { e.identityCaches = make(map[int32]*autoReplyIdentityCache) } if e.identityGroups == nil { e.identityGroups = make(map[int32]map[string]autoReplyGroupOption) } if e.groupNames == nil { e.groupNames = make(map[string]string) } loadPersisted := func(items map[string]autoReplyIdentityContact, kind string, scope string) { for userID, contact := range items { contact.UserID = strings.TrimSpace(fallbackString(contact.UserID, userID)) if contact.UserID == "" { continue } contact.Name = cleanIdentityName(contact.Name, contact.UserID) contact.Kind = fallbackString(contact.Kind, kind) if strings.TrimSpace(contact.Scope) == "" { contact.Scope = strings.TrimSpace(scope) } if contact.ClientID <= 0 { contact.ClientID = 0 } cache := e.identityCaches[contact.ClientID] if cache == nil { cache = &autoReplyIdentityCache{} e.identityCaches[contact.ClientID] = cache } ensureIdentityCacheMaps(cache) switch kind { case senderIdentityInternal: cache.Internal[contact.UserID] = mergeIdentityContact(cache.Internal[contact.UserID], contact) case senderIdentityExternal: cache.External[contact.UserID] = mergeIdentityContact(cache.External[contact.UserID], contact) default: cache.Observed[contact.UserID] = mergeIdentityContact(cache.Observed[contact.UserID], contact) } } } loadPersisted(store.Internal, senderIdentityInternal, "") loadPersisted(store.External, senderIdentityExternal, "") loadPersisted(store.Observed, senderIdentityUnknown, "") for conversationID, group := range store.Groups { group.ConversationID = strings.TrimSpace(fallbackString(group.ConversationID, conversationID)) if group.ConversationID == "" { continue } clientID := group.ClientID target := e.identityGroups[clientID] if target == nil { target = make(map[string]autoReplyGroupOption) e.identityGroups[clientID] = target } target[group.ConversationID] = group if strings.TrimSpace(group.Name) != "" { e.groupNames[group.ConversationID] = strings.TrimSpace(group.Name) } } for scope, bucket := range store.Scopes { loadPersisted(bucket.Internal, senderIdentityInternal, scope) loadPersisted(bucket.External, senderIdentityExternal, scope) loadPersisted(bucket.Observed, senderIdentityUnknown, scope) } e.status.InternalContactCount, e.status.ExternalContactCount = e.identityContactCountsLocked() e.status.IdentityGroupOptionCount = e.identityGroupOptionCountLocked() e.mu.Unlock() return nil } func (e *AutoReplyEngine) saveIdentityCache() { if err := e.saveIdentityCacheToDisk(); err != nil { e.setLastErrorWithScope(autoReplyErrorScopeIdentity, "身份缓存保存失败: "+err.Error()) } } func (e *AutoReplyEngine) saveIdentityCacheToDisk() error { e.mu.Lock() store := e.identityStoreSnapshotLocked() e.mu.Unlock() path := autoReplyIdentityCachePath() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } data, err := json.MarshalIndent(store, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0644) } func (e *AutoReplyEngine) identityStoreSnapshotLocked() autoReplyIdentityStore { store := autoReplyIdentityStore{ Internal: make(map[string]autoReplyIdentityContact), External: make(map[string]autoReplyIdentityContact), Observed: make(map[string]autoReplyIdentityContact), Groups: make(map[string]autoReplyGroupOption), Scopes: make(map[string]autoReplyIdentityBucket), LastSavedAt: time.Now().Unix(), } bucketForScope := func(scope string) autoReplyIdentityBucket { scope = strings.TrimSpace(scope) if scope == "" { scope = "legacy" } bucket := store.Scopes[scope] if bucket.Internal == nil { bucket.Internal = make(map[string]autoReplyIdentityContact) bucket.External = make(map[string]autoReplyIdentityContact) bucket.Observed = make(map[string]autoReplyIdentityContact) } store.Scopes[scope] = bucket return bucket } for clientID, cache := range e.identityCaches { if cache == nil { continue } ensureIdentityCacheMaps(cache) copyContacts := func(target map[string]autoReplyIdentityContact, contacts map[string]autoReplyIdentityContact) { for userID, contact := range contacts { contact.UserID = strings.TrimSpace(fallbackString(contact.UserID, userID)) if contact.UserID == "" { continue } contact.ClientID = clientID contact.Name = cleanIdentityName(contact.Name, contact.UserID) if strings.TrimSpace(contact.Scope) == "" { contact.Scope = e.identityScopeForClient(clientID) } target[contact.UserID] = contact } } copyContacts(store.Internal, cache.Internal) copyContacts(store.External, cache.External) copyContacts(store.Observed, cache.Observed) copyScopedContacts := func(kind string, contacts map[string]autoReplyIdentityContact) { for userID, contact := range contacts { contact.UserID = strings.TrimSpace(fallbackString(contact.UserID, userID)) if contact.UserID == "" { continue } contact.ClientID = clientID contact.Name = cleanIdentityName(contact.Name, contact.UserID) if strings.TrimSpace(contact.Scope) == "" { contact.Scope = e.identityScopeForClient(clientID) } scope := strings.TrimSpace(contact.Scope) bucket := bucketForScope(scope) switch kind { case senderIdentityInternal: bucket.Internal[contact.UserID] = contact case senderIdentityExternal: bucket.External[contact.UserID] = contact default: bucket.Observed[contact.UserID] = contact } store.Scopes[fallbackString(scope, "legacy")] = bucket } } copyScopedContacts(senderIdentityInternal, cache.Internal) copyScopedContacts(senderIdentityExternal, cache.External) copyScopedContacts(senderIdentityUnknown, cache.Observed) } for clientID, groups := range e.identityGroups { for conversationID, group := range groups { group.ConversationID = strings.TrimSpace(fallbackString(group.ConversationID, conversationID)) if group.ConversationID == "" { continue } if group.ClientID <= 0 { group.ClientID = clientID } if strings.TrimSpace(group.Name) == "" && e.groupNames != nil { group.Name = strings.TrimSpace(e.groupNames[group.ConversationID]) } store.Groups[group.ConversationID] = group } } return store } func (e *AutoReplyEngine) observeMessageIdentity(msg autoReplyMessage) { if msg.IsGroup || msg.isSelfMessage() { return } userID := strings.TrimSpace(msg.FromWxID) if userID == "" { return } name := cleanIdentityName(msg.FromNickName, userID) now := time.Now().Unix() scope := e.identityScopeForClient(msg.ClientID) changed := false e.mu.Lock() if e.identityCaches == nil { e.identityCaches = make(map[int32]*autoReplyIdentityCache) } cache := e.identityCaches[msg.ClientID] if cache == nil { cache = &autoReplyIdentityCache{} e.identityCaches[msg.ClientID] = cache } ensureIdentityCacheMaps(cache) contact := autoReplyIdentityContact{ UserID: userID, Name: name, Kind: senderIdentityUnknown, Source: identitySourceObservedMessage, ClientID: msg.ClientID, Scope: scope, LastSeenAt: now, ConversationID: msg.ConversationID, } if existing, ok := cache.Observed[userID]; ok { contact = mergeIdentityContact(existing, contact) } if cache.Observed[userID] != contact { cache.Observed[userID] = contact changed = true } updateKnownName := func(contacts map[string]autoReplyIdentityContact) { known, ok := contacts[userID] if !ok || name == "" || cleanIdentityName(known.Name, userID) != "" || !contactMatchesIdentityScope(known, scope) { return } known.Name = name known.LastSeenAt = now known.ConversationID = msg.ConversationID contacts[userID] = known changed = true } updateKnownName(cache.Internal) updateKnownName(cache.External) e.mu.Unlock() if changed { e.saveIdentityCache() } } func (e *AutoReplyEngine) displayNameForMessage(msg autoReplyMessage) string { if name := cleanIdentityName(msg.FromNickName, msg.FromWxID); name != "" { return name } userID := strings.TrimSpace(msg.FromWxID) if userID == "" { return "" } scope := e.identityScopeForClient(msg.ClientID) e.mu.Lock() defer e.mu.Unlock() if cache := e.identityCaches[msg.ClientID]; cache != nil { e.adoptLegacyIdentityScopeLocked(msg.ClientID, scope) if name := identityDisplayNameFromCache(cache, userID, scope); name != "" { return name } } for _, cache := range e.identityCaches { if name := identityDisplayNameFromCache(cache, userID, scope); name != "" { return name } } return "" } func identityDisplayNameFromCache(cache *autoReplyIdentityCache, userID string, scope string) string { if cache == nil { return "" } ensureIdentityCacheMaps(cache) for _, contacts := range []map[string]autoReplyIdentityContact{cache.External, cache.Internal, cache.Observed} { if contact, ok := contacts[userID]; ok { if !contactMatchesIdentityScope(contact, scope) { continue } if name := cleanIdentityName(contact.Name, userID); name != "" { return name } } } return "" } func cleanIdentityName(name string, userID string) string { name = strings.TrimSpace(name) if name == "" { return "" } lower := strings.ToLower(name) switch lower { case "unknown", "unnamed", "unknown contact", "null", "nil", "none": return "" } if name == "未命名联系人" || name == "未知联系人" || name == "未知客户" || name == "未知" || name == userID { return "" } return name } func (e *AutoReplyEngine) recordIdentityResponse(requestType int, contactCount int, errText string) { e.mu.Lock() defer e.mu.Unlock() e.status.IdentityLastResponseType = fmt.Sprintf("%d", requestType) e.status.IdentityLastResponseCount = contactCount e.status.IdentityLastResponseAt = time.Now().Unix() if errText != "" { e.status.IdentityRefreshError = errText } } func (e *AutoReplyEngine) identityContactCountsLocked() (int, int) { internalSeen := make(map[string]bool) externalSeen := make(map[string]bool) scope := e.currentIdentityScope() for clientID, cache := range e.identityCaches { if cache == nil { continue } e.adoptLegacyIdentityScopeLocked(clientID, scope) for userID, contact := range cache.Internal { if !isKnownRobotUserID(userID) && contactMatchesIdentityScope(contact, scope) { key := identityDedupKey(contact.Scope, userID) internalSeen[key] = true } } for userID, contact := range cache.External { if !isKnownRobotUserID(userID) && contactMatchesIdentityScope(contact, scope) { key := identityAccountDedupKey(clientID, contact, userID) externalSeen[key] = true } } } return len(internalSeen), len(externalSeen) } func (e *AutoReplyEngine) identityOptionsSnapshot() map[string][]autoReplyIdentityOption { e.mu.Lock() internalByID := make(map[string]autoReplyIdentityOption) externalByID := make(map[string]autoReplyIdentityOption) observedByID := make(map[string]autoReplyIdentityOption) scope := e.currentIdentityScope() for clientID, cache := range e.identityCaches { if cache == nil { continue } ensureIdentityCacheMaps(cache) e.adoptLegacyIdentityScopeLocked(clientID, scope) e.collectIdentityOptionsLocked(internalByID, clientID, cache.Internal, scope, false) e.collectIdentityOptionsLocked(externalByID, clientID, cache.External, scope, true) e.collectIdentityOptionsLocked(observedByID, clientID, cache.Observed, scope, false) } for userID, option := range observedByID { if current, ok := internalByID[userID]; ok && current.Name == "" && option.Name != "" { current.Name = option.Name internalByID[userID] = current } if current, ok := externalByID[userID]; ok && current.Name == "" && option.Name != "" { current.Name = option.Name externalByID[userID] = current } } e.mu.Unlock() return map[string][]autoReplyIdentityOption{ "internal": sortedIdentityOptions(internalByID), "external": sortedIdentityOptions(externalByID), "observed": sortedIdentityOptions(observedByID), } } func (e *AutoReplyEngine) collectIdentityOptionsLocked(target map[string]autoReplyIdentityOption, clientID int32, contacts map[string]autoReplyIdentityContact, scope string, keepPerAccount bool) { for userID, contact := range contacts { userID = strings.TrimSpace(fallbackString(contact.UserID, userID)) if userID == "" { continue } if isKnownRobotUserID(userID) { continue } if !contactMatchesIdentityScope(contact, scope) { continue } key := identityDedupKey(contact.Scope, userID) if keepPerAccount { key = identityAccountDedupKey(clientID, contact, userID) } sourceClientID := clientID if contact.ClientID > 0 { sourceClientID = contact.ClientID } sourceAccountUserID, sourceAccountName := e.sourceAccountForClientLocked(sourceClientID) if sourceAccountName == "" && sourceClientID > 0 { sourceAccountName = fmt.Sprintf("client %d", sourceClientID) } next := autoReplyIdentityOption{ UserID: userID, Name: cleanIdentityName(contact.Name, userID), Source: strings.TrimSpace(contact.Source), ClientID: sourceClientID, Scope: strings.TrimSpace(contact.Scope), LastSeenAt: contact.LastSeenAt, SourceAccountUserID: sourceAccountUserID, SourceAccountName: sourceAccountName, } current, exists := target[key] if !exists || shouldReplaceIdentityOption(current, next) { target[key] = next } } } func identityDedupKey(scope string, userID string) string { scope = strings.TrimSpace(scope) userID = strings.TrimSpace(userID) if scope == "" { return userID } return scope + "|" + userID } func identityAccountDedupKey(clientID int32, contact autoReplyIdentityContact, userID string) string { if contact.ClientID > 0 { clientID = contact.ClientID } return fmt.Sprintf("%d|%s", clientID, strings.TrimSpace(userID)) } func shouldReplaceIdentityOption(current autoReplyIdentityOption, next autoReplyIdentityOption) bool { if next.LastSeenAt != current.LastSeenAt { return next.LastSeenAt > current.LastSeenAt } if current.Name == "" && next.Name != "" { return true } return current.Source == "" && next.Source != "" } func sortedIdentityOptions(items map[string]autoReplyIdentityOption) []autoReplyIdentityOption { result := make([]autoReplyIdentityOption, 0, len(items)) for _, item := range items { result = append(result, item) } sort.Slice(result, func(i, j int) bool { leftName := strings.ToLower(strings.TrimSpace(result[i].Name)) rightName := strings.ToLower(strings.TrimSpace(result[j].Name)) if leftName != "" && rightName != "" && leftName != rightName { return leftName < rightName } if leftName != "" && rightName == "" { return true } if leftName == "" && rightName != "" { return false } return strings.TrimSpace(result[i].UserID) < strings.TrimSpace(result[j].UserID) }) return result } func (e *AutoReplyEngine) refreshIdentityContactsAsync(reason string) { if len(identityRefreshClientIDs()) == 0 && strings.TrimSpace(reason) != "manual" { e.scheduleIdentityRefreshWhenReady(reason) return } e.mu.Lock() if e.status.IdentityRefreshing { e.mu.Unlock() return } e.status.IdentityRefreshing = true if e.status.IdentityInitializedAt <= 0 || isInitialIdentityRefreshReason(reason) { e.status.IdentityInitializing = true } e.status.IdentityRefreshError = "" e.mu.Unlock() go func() { err := e.refreshIdentityContacts(reason) if err == nil { e.prelookupObservedPrivateContacts(reason) } cfg := e.getConfig() e.mu.Lock() e.status.IdentityRefreshing = false if err != nil { e.status.IdentityInitializing = false e.status.IdentityRefreshError = err.Error() e.mu.Unlock() e.setLastErrorWithScope(autoReplyErrorScopeIdentity, "联系人身份缓存刷新失败: "+err.Error()) return } e.status.IdentityLastRefreshAt = time.Now().Unix() e.status.IdentityInitializing = false if e.status.IdentityInitializedAt <= 0 { e.status.IdentityInitializedAt = e.status.IdentityLastRefreshAt } internalCount, externalCount := e.identityContactCountsLocked() if shouldWarnIdentityEmptyCache(cfg.Identity, internalCount, externalCount) { e.status.IdentityRefreshError = identityEmptyCacheWarning } if hasManualIdentityFallback(cfg.Identity) && isIdentityEmptyCacheWarning(e.status.IdentityRefreshError) { e.status.IdentityRefreshError = "" } e.mu.Unlock() }() } func isInitialIdentityRefreshReason(reason string) bool { reason = strings.ToLower(strings.TrimSpace(reason)) return strings.Contains(reason, "startup") || strings.Contains(reason, "reload") || strings.Contains(reason, "client_identified") || strings.Contains(reason, "client_ready") } func (e *AutoReplyEngine) scheduleIdentityRefreshWhenReady(reason string) { e.mu.Lock() if e.identityWait { e.mu.Unlock() return } e.identityWait = true e.status.IdentityInitializing = true e.status.IdentityRefreshError = "等待企微账号识别完成后自动刷新联系人" e.mu.Unlock() go func() { defer func() { e.mu.Lock() e.identityWait = false e.mu.Unlock() }() for i := 0; i < 30; i++ { time.Sleep(2 * time.Second) if len(identityRefreshClientIDs()) == 0 { continue } e.refreshIdentityContactsAsync(reason + "_client_ready") return } e.mu.Lock() e.status.IdentityInitializing = false if e.status.IdentityRefreshError == "等待企微账号识别完成后自动刷新联系人" { e.status.IdentityRefreshError = "未检测到可用企微账号,联系人刷新已等待超时" } e.mu.Unlock() }() } func (e *AutoReplyEngine) refreshIdentityContacts(reason string) error { cfg := e.getConfig() pageSize := cfg.Identity.PageSize if pageSize <= 0 { pageSize = 200 } clientIDs := identityRefreshClientIDs() if len(clientIDs) == 0 { if reason != "manual" { return nil } return fmt.Errorf("没有可用企微账号,无法刷新联系人身份缓存") } var errors []string for _, clientID := range clientIDs { for _, requestType := range []int{11036, 11037} { for page := 1; page <= maxIdentityRefreshPages; page++ { response, err := e.sendIdentityRefreshRequestAndWait(clientID, requestType, page, pageSize) if err != nil { errors = append(errors, err.Error()) break } contacts := extractIdentityContacts(response, senderIdentityInternal, identitySourceInternalCache) if requestType == 11037 { contacts = extractIdentityContacts(response, senderIdentityExternal, identitySourceExternalCache) } if shouldStopIdentityPagination(response, page, len(contacts)) { break } time.Sleep(60 * time.Millisecond) } } } if shouldSyncInternalGroupsForReason(reason) { if err := e.syncConfiguredInternalGroups(reason); err != nil { errors = append(errors, err.Error()) } } if len(errors) > 0 { return fmt.Errorf("%s", strings.Join(errors, "; ")) } return nil } func shouldSyncInternalGroupsForReason(reason string) bool { reason = strings.ToLower(strings.TrimSpace(reason)) return strings.Contains(reason, "startup") || strings.Contains(reason, "reload") || strings.Contains(reason, "client_identified") || strings.Contains(reason, "manual") || strings.Contains(reason, "group") } func (e *AutoReplyEngine) refreshIdentityGroupsAsync(reason string) { if len(identityRefreshClientIDs()) == 0 && strings.TrimSpace(reason) != "manual" { e.scheduleIdentityRefreshWhenReady(reason) return } go func() { if err := e.refreshIdentityGroups(reason); err != nil { e.mu.Lock() e.status.InternalGroupMemberSyncError = err.Error() e.mu.Unlock() e.setLastErrorWithScope(autoReplyErrorScopeIdentity, "群列表刷新失败: "+err.Error()) } }() } func (e *AutoReplyEngine) refreshIdentityGroups(reason string) error { cfg := e.getConfig() pageSize := cfg.Identity.PageSize if pageSize <= 0 { pageSize = 200 } clientIDs := identityRefreshClientIDs() if len(clientIDs) == 0 { return fmt.Errorf("没有可用企微账号,无法刷新群列表") } var errors []string for _, clientID := range clientIDs { for page := 1; page <= maxIdentityRefreshPages; page++ { response, err := e.sendIdentityGroupListRequestAndWait(clientID, page, pageSize) if err != nil { errors = append(errors, err.Error()) break } if shouldStopIdentityPagination(response, page, len(extractIdentityGroups(response))) { break } time.Sleep(120 * time.Millisecond) } } if len(errors) > 0 { return fmt.Errorf("%s", strings.Join(errors, "; ")) } return nil } func (e *AutoReplyEngine) syncConfiguredInternalGroupsAsync(reason string) { if len(identityRefreshClientIDs()) == 0 && strings.TrimSpace(reason) != "manual" { e.scheduleIdentityRefreshWhenReady(reason) return } go func() { if err := e.syncConfiguredInternalGroups(reason); err != nil { e.recordInternalGroupMemberSyncResult(0, err.Error()) e.setLastErrorWithScope(autoReplyErrorScopeIdentity, "内部总群成员同步失败: "+err.Error()) } }() } func (e *AutoReplyEngine) syncConfiguredInternalGroups(reason string) error { cfg := e.getConfig() scope := e.currentIdentityScope() groupIDs := scopedInternalGroupIDs(cfg.Identity, scope) if len(groupIDs) == 0 { return nil } e.mu.Lock() e.status.InternalGroupMemberLastSyncCount = 0 e.status.InternalGroupMemberSyncError = "" e.mu.Unlock() pageSize := cfg.Identity.PageSize if pageSize <= 0 { pageSize = 200 } clientIDs := identityRefreshClientIDs() if len(clientIDs) == 0 { return fmt.Errorf("没有可用企微账号,无法同步内部总群成员") } var errors []string uniqueMembers := make(map[string]bool) for _, clientID := range clientIDs { for _, conversationID := range groupIDs { for page := 1; page <= maxIdentityGroupPages; page++ { response, err := e.sendIdentityGroupMemberListRequestAndWait(clientID, conversationID, page, pageSize) if err != nil { errors = append(errors, err.Error()) break } contacts := extractIdentityContacts(response, senderIdentityInternal, identitySourceInternalGroup) for _, contact := range contacts { userID := strings.TrimSpace(contact.UserID) if userID != "" { uniqueMembers[userID] = true } } e.startIdentityNameLookups(int32(clientID), contacts, "internal_group_sync") if shouldStopIdentityPagination(response, page, len(contacts)) { break } time.Sleep(120 * time.Millisecond) } } } if len(uniqueMembers) > 0 { e.setInternalGroupMemberSyncCount(len(uniqueMembers)) } if len(errors) > 0 { return fmt.Errorf("%s", strings.Join(errors, "; ")) } return nil } func (e *AutoReplyEngine) recordInternalGroupMemberSyncResult(count int, errText string) { e.mu.Lock() defer e.mu.Unlock() e.status.InternalGroupMemberLastSyncAt = time.Now().Unix() if count > 0 { e.status.InternalGroupMemberLastSyncCount += count } if errText != "" { e.status.InternalGroupMemberSyncError = errText } else if count > 0 { e.status.InternalGroupMemberSyncError = "" } } func (e *AutoReplyEngine) setInternalGroupMemberSyncCount(count int) { e.mu.Lock() defer e.mu.Unlock() e.status.InternalGroupMemberLastSyncAt = time.Now().Unix() e.status.InternalGroupMemberLastSyncCount = count e.status.InternalGroupMemberSyncError = "" } func (e *AutoReplyEngine) observeCurrentAccountIdentity(clientID uint32, userID string, accountData map[string]interface{}) { userID = strings.TrimSpace(userID) if clientID == 0 || userID == "" { return } name := cleanIdentityName(identityNameFromMap(accountData), userID) if name == "" { name = cleanIdentityName(stringFromAny(firstNonNil(accountData["username"], accountData["name"], accountData["nickname"], accountData["acctid"], accountData["account"])), userID) } e.rememberCurrentAccountNames(int32(clientID), userID, name) contact := autoReplyIdentityContact{ UserID: userID, Name: name, Kind: senderIdentityInternal, Source: identitySourceInternalCache, ClientID: int32(clientID), LastSeenAt: time.Now().Unix(), } e.mergeIdentityContacts(int32(clientID), senderIdentityInternal, []autoReplyIdentityContact{contact}) } func (e *AutoReplyEngine) rememberCurrentAccountNames(clientID int32, userID string, names ...string) { if e == nil || clientID == 0 { return } items := append([]string{userID}, names...) items = dedupeNonEmptyStrings(items) e.mu.Lock() if e.accountNames == nil { e.accountNames = make(map[int32][]string) } e.accountNames[clientID] = items e.mu.Unlock() } func (e *AutoReplyEngine) sourceAccountForClient(clientID int32) (string, string) { if e == nil || clientID == 0 { return "", "" } e.mu.Lock() defer e.mu.Unlock() return e.sourceAccountForClientLocked(clientID) } func (e *AutoReplyEngine) sourceAccountForClientLocked(clientID int32) (string, string) { if clientID == 0 { return "", "" } userID := strings.TrimSpace(getClientUserID(uint32(clientID))) names := make([]string, 0, 4) if e != nil && e.accountNames != nil { names = append(names, e.accountNames[clientID]...) } if userID == "" && len(names) > 0 { userID = strings.TrimSpace(names[0]) } if userID == "" { userID = soleIdentifiedUserID() } realName := "" for _, candidate := range names { candidate = strings.TrimSpace(candidate) if candidate == "" || candidate == userID { continue } realName = candidate break } if realName == "" { realName = accountDisplayNameFromClientStatus(userID, clientID) } if realName == "" { realName = userID } return userID, realName } func soleIdentifiedUserID() string { clientIdMutex.Lock() defer clientIdMutex.Unlock() sole := "" for _, userID := range globalClientMap { userID = strings.TrimSpace(userID) if userID == "" { continue } if sole != "" && sole != userID { return "" } sole = userID } return sole } func accountDisplayNameFromClientStatus(userID string, clientID int32) string { userID = strings.TrimSpace(userID) exePath, err := os.Executable() if err != nil { return "" } path := filepath.Join(filepath.Dir(exePath), "config", "client_status.json") data, err := os.ReadFile(path) if err != nil { return "" } var status map[string]map[string]interface{} if err := json.Unmarshal(data, &status); err != nil { return "" } if userID != "" { if name := accountDisplayNameFromStatusAccount(status[userID], userID); name != "" { return name } } if clientID != 0 { for accountUserID, account := range status { if int32(intFromAny(account["client_id"])) == clientID || int32(intFromAny(account["clientId"])) == clientID { if name := accountDisplayNameFromStatusAccount(account, accountUserID); name != "" { return name } } } } if userID == "" && len(status) == 1 { for accountUserID, account := range status { return accountDisplayNameFromStatusAccount(account, accountUserID) } } return "" } func accountDisplayNameFromStatusAccount(account map[string]interface{}, userID string) string { if account == nil { return "" } userID = strings.TrimSpace(userID) for _, key := range []string{"username", "nickname", "name", "realname", "real_name", "acctid"} { if name := strings.TrimSpace(stringFromAny(account[key])); name != "" && name != userID { return name } } return "" } func (e *AutoReplyEngine) prelookupObservedPrivateContacts(reason string) { clientIDs := identityRefreshClientIDs() if len(clientIDs) == 0 { return } type lookupTarget struct { ClientID uint32 UserID string } targets := make([]lookupTarget, 0) seen := make(map[string]bool) e.mu.Lock() for clientID, cache := range e.identityCaches { if cache == nil { continue } ensureIdentityCacheMaps(cache) for userID, contact := range cache.Observed { userID = strings.TrimSpace(fallbackString(contact.UserID, userID)) if userID == "" { continue } targetClientID := uint32(contact.ClientID) if targetClientID == 0 && clientID > 0 { targetClientID = uint32(clientID) } if targetClientID == 0 { targetClientID = clientIDs[0] } key := fmt.Sprintf("%d|%s", targetClientID, userID) if seen[key] { continue } seen[key] = true targets = append(targets, lookupTarget{ClientID: targetClientID, UserID: userID}) if len(targets) >= maxIdentityPrelookup { break } } if len(targets) >= maxIdentityPrelookup { break } } e.mu.Unlock() if len(targets) == 0 { return } e.noteReason("identity_prelookup_started") for _, target := range targets { if err := sendIdentityLookupRequest(target.ClientID, 11039, target.UserID); err != nil { e.recordIdentityResponse(11039, 0, "启动预核验失败: "+err.Error()) } time.Sleep(40 * time.Millisecond) if err := sendIdentityLookupRequest(target.ClientID, 11052, target.UserID); err != nil { e.recordIdentityResponse(11052, 0, "启动预核验失败: "+err.Error()) } time.Sleep(40 * time.Millisecond) } } func (e *AutoReplyEngine) startIdentityNameLookups(clientID int32, contacts []autoReplyIdentityContact, reason string) { if clientID <= 0 || len(contacts) == 0 { return } type lookupTarget struct { UserID string } now := time.Now() targets := make([]lookupTarget, 0) e.mu.Lock() if e.identityLookups == nil { e.identityLookups = make(map[string]time.Time) } for _, contact := range contacts { userID := strings.TrimSpace(contact.UserID) if userID == "" || cleanIdentityName(contact.Name, userID) != "" { continue } key := fmt.Sprintf("name|%d|%s", clientID, userID) if last, ok := e.identityLookups[key]; ok && now.Sub(last) < identityLookupCooldown { continue } e.identityLookups[key] = now targets = append(targets, lookupTarget{UserID: userID}) } if len(targets) > 0 { e.status.IdentityLookupInFlight += len(targets) } e.mu.Unlock() if len(targets) == 0 { return } e.noteReason("identity_name_lookup_started") requester := sendIdentityLookupRequester go func() { defer func() { e.mu.Lock() e.status.IdentityLookupInFlight -= len(targets) if e.status.IdentityLookupInFlight < 0 { e.status.IdentityLookupInFlight = 0 } e.mu.Unlock() }() for _, target := range targets { msg := autoReplyMessage{ClientID: clientID, FromWxID: target.UserID} if err := e.lookupSingleIdentityWithRequester(msg, requester); err != nil { e.noteReason("identity_name_lookup_failed") e.recordIdentityResponse(11039, 0, "群成员名称补齐失败: "+err.Error()) } time.Sleep(80 * time.Millisecond) } if strings.TrimSpace(reason) != "" { e.recordIdentityResponse(11039, len(targets), "") } }() } func (e *AutoReplyEngine) identityRefreshLoop() { for { cfg := e.getConfig() interval := time.Duration(cfg.Identity.RefreshIntervalMinutes) * time.Minute if interval <= 0 { interval = 30 * time.Minute } time.Sleep(interval) cfg = e.getConfig() if cfg.Enabled { e.refreshIdentityContactsAsync("interval") } } } func identityRefreshClientIDs() []uint32 { clients := getUsableClientsMap() result := make([]uint32, 0, len(clients)) for clientIDText := range clients { n, err := strconv.ParseUint(strings.TrimSpace(clientIDText), 10, 32) if err == nil && n > 0 { result = append(result, uint32(n)) } } if len(result) == 0 { if clientID := GetGlobalClientId(); clientID > 0 { result = append(result, clientID) } } return result } func sendIdentityRefreshRequest(clientID uint32, requestType int, page int, pageSize int) error { request := map[string]interface{}{ "type": requestType, "data": map[string]interface{}{ "page_num": page, "page_size": pageSize, }, } return sendIdentityRequest(clientID, requestType, request) } func (e *AutoReplyEngine) sendIdentityRefreshRequestAndWait(clientID uint32, requestType int, page int, pageSize int) (map[string]interface{}, error) { request := map[string]interface{}{ "type": requestType, "data": map[string]interface{}{ "page_num": page, "page_size": pageSize, }, } return e.sendIdentityRequestAndWait(clientID, requestType, request, 8*time.Second) } func sendIdentityGroupListRequest(clientID uint32, page int, pageSize int) error { request := map[string]interface{}{ "type": 11038, "data": map[string]interface{}{ "page_num": page, "page_size": pageSize, }, } return sendIdentityRequest(clientID, 11038, request) } func (e *AutoReplyEngine) sendIdentityGroupListRequestAndWait(clientID uint32, page int, pageSize int) (map[string]interface{}, error) { request := map[string]interface{}{ "type": 11038, "data": map[string]interface{}{ "page_num": page, "page_size": pageSize, }, } return e.sendIdentityRequestAndWait(clientID, 11038, request, 8*time.Second) } func sendIdentityGroupMemberListRequest(clientID uint32, conversationID string, page int, pageSize int) error { conversationID = strings.TrimSpace(conversationID) if conversationID == "" { return nil } request := map[string]interface{}{ "type": 11040, "data": map[string]interface{}{ "conversation_id": conversationID, "page_num": page, "page_size": pageSize, }, } return sendIdentityRequest(clientID, 11040, request) } func (e *AutoReplyEngine) sendIdentityGroupMemberListRequestAndWait(clientID uint32, conversationID string, page int, pageSize int) (map[string]interface{}, error) { conversationID = strings.TrimSpace(conversationID) if conversationID == "" { return nil, nil } request := map[string]interface{}{ "type": 11040, "data": map[string]interface{}{ "conversation_id": conversationID, "page_num": page, "page_size": pageSize, }, } return e.sendIdentityRequestAndWait(clientID, 11040, request, 8*time.Second) } func (e *AutoReplyEngine) sendIdentityRequestAndWait(clientID uint32, requestType int, request map[string]interface{}, timeout time.Duration) (map[string]interface{}, error) { if clientID == 0 { return nil, fmt.Errorf("client %d type %d: clientId为空", clientID, requestType) } if timeout <= 0 { timeout = 8 * time.Second } iClientID := int32(clientID) responseChan := make(chan ClientResponseData, 4) deadline := time.Now().Add(20 * time.Second) for !TrySetResponseChannel(iClientID, responseChan) { if time.Now().After(deadline) { return nil, fmt.Errorf("client %d type %d: 响应通道仍被占用,请稍后重试", clientID, requestType) } time.Sleep(200 * time.Millisecond) } defer RemoveResponseChannel(iClientID) defer close(responseChan) if err := sendIdentityRequest(clientID, requestType, request); err != nil { return nil, err } timer := time.NewTimer(timeout) defer timer.Stop() for { select { case response := <-responseChan: normalized := normalizeIdentityResponseForRequest(requestType, response.Data) if normalized == nil { continue } return normalized, nil case <-timer.C: e.recordIdentityResponse(requestType, 0, fmt.Sprintf("client %d type %d: 等待企微回包超时", clientID, requestType)) return nil, fmt.Errorf("client %d type %d: 等待企微回包超时", clientID, requestType) } } } func shouldStopIdentityPagination(response map[string]interface{}, page int, itemCount int) bool { if page <= 0 { page = 1 } if totalPage := identityResponseTotalPage(response); totalPage > 0 { return page >= totalPage } return page > 1 && itemCount == 0 } func identityResponseTotalPage(response map[string]interface{}) int { if len(response) == 0 { return 0 } if totalPage := intFromAny(firstNonNil(response["total_page"], response["totalPage"], response["total_pages"], response["totalPages"])); totalPage > 0 { return totalPage } if data, ok := response["data"].(map[string]interface{}); ok { return identityResponseTotalPage(data) } return 0 } func normalizeIdentityResponseForRequest(requestType int, data map[string]interface{}) map[string]interface{} { if len(data) == 0 { return nil } if intFromAny(data["type"]) == requestType { return data } if nested, ok := data["data"].(map[string]interface{}); ok { if intFromAny(nested["type"]) == requestType { return nested } return map[string]interface{}{ "type": requestType, "data": nested, } } return map[string]interface{}{ "type": requestType, "data": data, } } func sendIdentityRequest(clientID uint32, requestType int, request map[string]interface{}) error { 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("client %d type %d: %v", clientID, requestType, resultMap["error"]) } } return nil } func containsConfiguredUserID(items []string, userID string) bool { userID = strings.TrimSpace(userID) if userID == "" { return false } for _, item := range items { for _, part := range strings.FieldsFunc(item, identityIDSeparator) { if strings.TrimSpace(part) == userID { return true } } } return false } func hasManualIdentityFallback(identity config.IdentityConfig) bool { return hasConfiguredIdentityIDs(identity.InternalUserIDs) || hasConfiguredIdentityIDs(identity.ExternalUserIDs) } func shouldWarnIdentityEmptyCache(identity config.IdentityConfig, internalCount int, externalCount int) bool { return internalCount == 0 && externalCount == 0 && !hasManualIdentityFallback(identity) } func hasConfiguredIdentityIDs(items []string) bool { for _, item := range items { for _, part := range strings.FieldsFunc(item, identityIDSeparator) { if strings.TrimSpace(part) != "" { return true } } } return false } func identityIDSeparator(r rune) bool { return r == ',' || r == ',' || r == '、' || r == ';' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' ' } func isIdentityEmptyCacheWarning(text string) bool { text = strings.TrimSpace(text) return text == identityEmptyCacheWarning || strings.Contains(text, "联系人列表未返回或未解析") } func (e *AutoReplyEngine) shouldHoldUnknownHandoff(msg autoReplyMessage) bool { if msg.SenderIdentity != senderIdentityUnknown { return false } if e.manualHandoffReason(msg.Content) != "" { return false } cfg := e.getConfig() policy := strings.ToLower(strings.TrimSpace(cfg.Identity.UnknownHandoffPolicy)) switch policy { case "allow", "customer", "handoff": return false case "ignore", "ignored", "internal", "hold", "verify", "no_handoff", "no_handoff_until_verified", "": return true default: return true } } func (e *AutoReplyEngine) startUnknownIdentityLookup(msg autoReplyMessage, reason string) { userID := strings.TrimSpace(msg.FromWxID) if msg.ClientID <= 0 || userID == "" { return } key := fmt.Sprintf("%d|%s", msg.ClientID, userID) now := time.Now() e.mu.Lock() if e.identityLookups == nil { e.identityLookups = make(map[string]time.Time) } if last, ok := e.identityLookups[key]; ok && now.Sub(last) < identityLookupCooldown { e.mu.Unlock() return } e.identityLookups[key] = now e.status.IdentityLookupInFlight++ e.mu.Unlock() e.noteReason("identity_lookup_started") requester := sendIdentityLookupRequester go func() { defer func() { e.mu.Lock() if e.status.IdentityLookupInFlight > 0 { e.status.IdentityLookupInFlight-- } e.mu.Unlock() }() if err := e.lookupSingleIdentityWithRequester(msg, requester); err != nil { e.noteReason("identity_lookup_failed") e.recordIdentityResponse(11039, 0, "单人身份查询失败: "+err.Error()) } if strings.TrimSpace(reason) != "" && strings.Contains(reason, "manual_keyword") { e.refreshIdentityContactsAsync("unknown_handoff") } }() } func (e *AutoReplyEngine) lookupSingleIdentity(msg autoReplyMessage) error { return e.lookupSingleIdentityWithRequester(msg, sendIdentityLookupRequester) } func (e *AutoReplyEngine) lookupSingleIdentityWithRequester(msg autoReplyMessage, requester func(uint32, int, string) error) error { clientID := uint32(msg.ClientID) userID := strings.TrimSpace(msg.FromWxID) if clientID == 0 || userID == "" { return fmt.Errorf("缺少clientID或userID") } if requester == nil { return fmt.Errorf("身份查询发送器未配置") } var errors []string if err := requester(clientID, 11039, userID); err != nil { errors = append(errors, err.Error()) } if err := requester(clientID, 11052, userID); err != nil { errors = append(errors, err.Error()) } if len(errors) > 0 { return fmt.Errorf("%s", strings.Join(errors, "; ")) } return nil } func sendIdentityLookupRequest(clientID uint32, requestType int, query string) error { return sendIdentityLookupRequester(clientID, requestType, query) } var sendIdentityLookupRequester = sendIdentityLookupRequestData func sendIdentityLookupRequestData(clientID uint32, requestType int, query string) error { query = strings.TrimSpace(query) if query == "" { return nil } data := map[string]interface{}{} switch requestType { case 11039: data["user_id"] = query case 11052: data["keyword"] = query default: return fmt.Errorf("unsupported identity lookup type %d", requestType) } request := map[string]interface{}{ "type": requestType, "data": data, } encoded, err := json.Marshal(request) if err != nil { return err } result, err := handleSendWxWorkData(map[string]interface{}{ "data": string(encoded), "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("client %d type %d: %v", clientID, requestType, resultMap["error"]) } } return nil } func (e *AutoReplyEngine) robotCorpIDForClient(clientID int32) string { if clientID <= 0 { return "" } robotID := strings.TrimSpace(getClientUserID(uint32(clientID))) if robotID == "" { return "" } return clientStatusField(robotID, "corp_id") } func clientStatusField(userID string, field string) string { userID = strings.TrimSpace(userID) field = strings.TrimSpace(field) if userID == "" || field == "" { return "" } exePath, err := os.Executable() if err != nil { return "" } path := filepath.Join(filepath.Dir(exePath), "config", "client_status.json") data, err := os.ReadFile(path) if err != nil { return "" } var status map[string]map[string]interface{} if err := json.Unmarshal(data, &status); err != nil { return "" } if account, ok := status[userID]; ok { return stringFromAny(account[field]) } return "" }