2399 lines
72 KiB
Go
2399 lines
72 KiB
Go
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 ""
|
||
}
|