Files
qiweimanager-master/helper/auto_reply_identity.go

2399 lines
72 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/json"
"fmt"
"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 ""
}