1055 lines
29 KiB
Go
1055 lines
29 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
clientStatusConnected = "connected"
|
||
clientStatusIdentifying = "identifying"
|
||
clientStatusIdentified = "identified"
|
||
clientStatusMessageReady = "message_ready"
|
||
clientStatusUnidentified = "unidentified"
|
||
|
||
injectionStatusNotStarted = "not_started"
|
||
injectionStatusInjecting = "injecting"
|
||
injectionStatusConnected = "connected"
|
||
injectionStatusIdentified = "identified"
|
||
injectionStatusFailed = "failed"
|
||
)
|
||
|
||
type ClientRuntimeState struct {
|
||
ClientID uint32 `json:"clientId"`
|
||
PID uint32 `json:"pid"`
|
||
UserID string `json:"userId"`
|
||
Status string `json:"status"`
|
||
LastEvent string `json:"lastEvent"`
|
||
LastError string `json:"lastError"`
|
||
ConnectedAt string `json:"connectedAt"`
|
||
IdentifiedAt string `json:"identifiedAt"`
|
||
LastSeenAt string `json:"lastSeenAt"`
|
||
LastIdentifyRequestAt string `json:"lastIdentifyRequestAt"`
|
||
LastIdentifyResponseType string `json:"lastIdentifyResponseType"`
|
||
IgnoredIdentifyEvents int `json:"ignoredIdentifyEvents"`
|
||
LastIgnoredIdentifyReason string `json:"lastIgnoredIdentifyReason"`
|
||
LastIgnoredIdentifyEvent string `json:"lastIgnoredIdentifyEvent"`
|
||
FirstMessageAt string `json:"firstMessageAt"`
|
||
LastMessageAt string `json:"lastMessageAt"`
|
||
MessageCount int `json:"messageCount"`
|
||
}
|
||
|
||
type ClientProbeRecord struct {
|
||
Time string `json:"time"`
|
||
ClientID int32 `json:"clientId"`
|
||
Type string `json:"type"`
|
||
Summary string `json:"summary"`
|
||
Raw interface{} `json:"raw,omitempty"`
|
||
}
|
||
|
||
type ClientProbeResult struct {
|
||
ClientID int32 `json:"clientId"`
|
||
Started string `json:"started"`
|
||
Duration int `json:"durationSeconds"`
|
||
Records []ClientProbeRecord `json:"records"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
var (
|
||
clientStateMu sync.Mutex
|
||
clientStates = make(map[uint32]*ClientRuntimeState)
|
||
|
||
clientProbeMu sync.Mutex
|
||
clientProbes = make(map[int32][]chan ClientProbeRecord)
|
||
|
||
injectionMu sync.Mutex
|
||
injectionStatus = injectionStatusNotStarted
|
||
injectionLastPID uint32
|
||
injectionLastError string
|
||
injectionLastUpdate string
|
||
injectedPIDs = make(map[uint32]time.Time)
|
||
)
|
||
|
||
func nowText() string {
|
||
return time.Now().Format("2006-01-02 15:04:05")
|
||
}
|
||
|
||
func registerClientConnected(clientID uint32) {
|
||
if clientID == 0 {
|
||
return
|
||
}
|
||
clientStateMu.Lock()
|
||
state := ensureClientStateLocked(clientID)
|
||
if state.Status == "" {
|
||
state.Status = clientStatusConnected
|
||
}
|
||
state.LastEvent = "connect"
|
||
state.LastSeenAt = nowText()
|
||
clientStateMu.Unlock()
|
||
|
||
clientIdMutex.Lock()
|
||
if _, exists := globalClientMap[clientID]; !exists {
|
||
globalClientMap[clientID] = ""
|
||
}
|
||
clientIdMutex.Unlock()
|
||
}
|
||
|
||
func registerClientPID(clientID uint32, pid uint32) {
|
||
if clientID == 0 {
|
||
return
|
||
}
|
||
clientStateMu.Lock()
|
||
if pid != 0 {
|
||
for otherClientID, state := range clientStates {
|
||
if otherClientID != clientID && state.PID == pid {
|
||
delete(clientStates, otherClientID)
|
||
clientIdMutex.Lock()
|
||
delete(globalClientMap, otherClientID)
|
||
clientIdMutex.Unlock()
|
||
}
|
||
}
|
||
}
|
||
state := ensureClientStateLocked(clientID)
|
||
state.PID = pid
|
||
if state.Status == "" || state.Status == clientStatusConnected {
|
||
state.Status = clientStatusConnected
|
||
}
|
||
state.LastEvent = "11024_connect_event"
|
||
state.LastSeenAt = nowText()
|
||
clientStateMu.Unlock()
|
||
}
|
||
|
||
func startClientIdentification(clientID uint32) {
|
||
if clientID == 0 {
|
||
return
|
||
}
|
||
if !supportsAccountInfoRequest() {
|
||
markClientError(clientID, clientStatusUnidentified, "account info request disabled for this WXWork/DLL version; bind client manually")
|
||
return
|
||
}
|
||
clientStateMu.Lock()
|
||
state := ensureClientStateLocked(clientID)
|
||
if state.Status == clientStatusIdentified || state.Status == clientStatusIdentifying {
|
||
clientStateMu.Unlock()
|
||
return
|
||
}
|
||
if state.Status != clientStatusMessageReady {
|
||
state.Status = clientStatusIdentifying
|
||
}
|
||
state.LastError = ""
|
||
state.LastEvent = "account_identify_started"
|
||
state.LastSeenAt = nowText()
|
||
clientStateMu.Unlock()
|
||
|
||
go identifyClientAccountV2(clientID)
|
||
}
|
||
|
||
func identifyClientAccount(clientID uint32) {
|
||
if _, exists := GetResponseChannel(int32(clientID)); exists {
|
||
markClientError(clientID, clientStatusUnidentified, "账号识别失败:响应通道占用")
|
||
return
|
||
}
|
||
|
||
responseChan := make(chan ClientResponseData, 1)
|
||
defer close(responseChan)
|
||
defer RemoveResponseChannel(int32(clientID))
|
||
SetResponseChannel(int32(clientID), responseChan)
|
||
|
||
request := `{"type":11035,"data":{}}`
|
||
_, err := handleSendWxWorkData(map[string]interface{}{
|
||
"data": request,
|
||
"clientId": clientID,
|
||
})
|
||
if err != nil {
|
||
markClientError(clientID, clientStatusUnidentified, "账号信息请求失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
select {
|
||
case response := <-responseChan:
|
||
userID, accountData := extractAccountIdentity(response.Data)
|
||
if userID != "" {
|
||
markClientIdentified(clientID, userID, accountData)
|
||
return
|
||
}
|
||
if existingUserID := getClientUserID(clientID); existingUserID != "" {
|
||
return
|
||
}
|
||
markClientError(clientID, clientStatusUnidentified, "账号信息响应缺少user_id")
|
||
case <-time.After(10 * time.Second):
|
||
if getClientUserID(clientID) == "" && !isClientUsable(clientID) {
|
||
markClientError(clientID, clientStatusUnidentified, "账号信息请求超时,未收到11026/11179")
|
||
}
|
||
}
|
||
}
|
||
|
||
func retryClientIdentification(clientID uint32) {
|
||
if clientID == 0 {
|
||
return
|
||
}
|
||
if !supportsAccountInfoRequest() {
|
||
markClientError(clientID, clientStatusUnidentified, "account info request disabled for this WXWork/DLL version; bind client manually")
|
||
return
|
||
}
|
||
clientStateMu.Lock()
|
||
state := ensureClientStateLocked(clientID)
|
||
if state.Status == clientStatusIdentified || state.Status == clientStatusIdentifying {
|
||
clientStateMu.Unlock()
|
||
return
|
||
}
|
||
if state.Status != clientStatusMessageReady {
|
||
state.Status = clientStatusIdentifying
|
||
}
|
||
state.LastError = ""
|
||
state.LastEvent = "account_identify_retry"
|
||
state.LastSeenAt = nowText()
|
||
clientStateMu.Unlock()
|
||
|
||
go identifyClientAccountV2(clientID)
|
||
}
|
||
|
||
func identifyClientAccountV2(clientID uint32) {
|
||
if _, exists := GetResponseChannel(int32(clientID)); exists {
|
||
markClientError(clientID, clientStatusUnidentified, "account identify failed: response channel is busy")
|
||
return
|
||
}
|
||
|
||
responseChan := make(chan ClientResponseData, 1)
|
||
defer close(responseChan)
|
||
defer RemoveResponseChannel(int32(clientID))
|
||
SetResponseChannel(int32(clientID), responseChan)
|
||
|
||
request := `{"type":11035,"data":{}}`
|
||
markClientIdentifyRequest(clientID)
|
||
_, err := handleSendWxWorkData(map[string]interface{}{
|
||
"data": request,
|
||
"clientId": clientID,
|
||
})
|
||
if err != nil {
|
||
markClientError(clientID, clientStatusUnidentified, "account info request failed: "+err.Error())
|
||
return
|
||
}
|
||
|
||
timeout := time.After(10 * time.Second)
|
||
for {
|
||
if existingUserID := getClientUserID(clientID); existingUserID != "" {
|
||
return
|
||
}
|
||
select {
|
||
case response := <-responseChan:
|
||
markClientIdentifyResponse(clientID, response.Data)
|
||
if shouldIgnoreIdentifyResponse(response.Data) {
|
||
markClientIdentifyIgnored(clientID, response.Data, "non-account event")
|
||
continue
|
||
}
|
||
userID, accountData := extractAccountIdentity(response.Data)
|
||
if userID != "" {
|
||
markClientIdentified(clientID, userID, accountData)
|
||
return
|
||
}
|
||
if existingUserID := getClientUserID(clientID); existingUserID != "" {
|
||
return
|
||
}
|
||
markClientIdentifyIgnored(clientID, response.Data, "missing account identity")
|
||
case <-timeout:
|
||
if getClientUserID(clientID) == "" && !isClientUsable(clientID) {
|
||
markClientError(clientID, clientStatusUnidentified, "account identify timeout: no account info response")
|
||
}
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func shouldIgnoreIdentifyResponse(data map[string]interface{}) bool {
|
||
switch responseTypeString(data) {
|
||
case "11024", "10002", "11041", "11042", "11043", "11044", "11045", "11046", "11047", "20002", "20003", "20004", "20005", "20012", "20014":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func markClientIdentifyRequest(clientID uint32) {
|
||
clientStateMu.Lock()
|
||
state := ensureClientStateLocked(clientID)
|
||
state.LastIdentifyRequestAt = nowText()
|
||
state.LastIdentifyResponseType = ""
|
||
state.IgnoredIdentifyEvents = 0
|
||
state.LastIgnoredIdentifyReason = ""
|
||
state.LastIgnoredIdentifyEvent = ""
|
||
state.LastEvent = "account_identify_requested"
|
||
state.LastSeenAt = state.LastIdentifyRequestAt
|
||
clientStateMu.Unlock()
|
||
}
|
||
|
||
func markClientIdentifyResponse(clientID uint32, data map[string]interface{}) {
|
||
clientStateMu.Lock()
|
||
state, exists := clientStates[clientID]
|
||
if !exists {
|
||
clientStateMu.Unlock()
|
||
return
|
||
}
|
||
state.LastIdentifyResponseType = responseTypeString(data)
|
||
state.LastSeenAt = nowText()
|
||
clientStateMu.Unlock()
|
||
}
|
||
|
||
func markClientIdentifyIgnored(clientID uint32, data map[string]interface{}, reason string) {
|
||
clientStateMu.Lock()
|
||
state, exists := clientStates[clientID]
|
||
if !exists {
|
||
clientStateMu.Unlock()
|
||
return
|
||
}
|
||
state.IgnoredIdentifyEvents++
|
||
state.LastIgnoredIdentifyReason = reason
|
||
state.LastIgnoredIdentifyEvent = summarizeIdentifyEvent(data)
|
||
state.LastSeenAt = nowText()
|
||
clientStateMu.Unlock()
|
||
}
|
||
|
||
func markClientIdentified(clientID uint32, userID string, accountData map[string]interface{}) {
|
||
markClientIdentifiedWithOptions(clientID, userID, accountData, true)
|
||
}
|
||
|
||
func markClientManuallyBound(clientID uint32, userID string, accountData map[string]interface{}) {
|
||
markClientIdentifiedWithOptions(clientID, userID, accountData, false)
|
||
}
|
||
|
||
func markClientIdentifiedWithOptions(clientID uint32, userID string, accountData map[string]interface{}, refreshIdentity bool) {
|
||
userID = strings.TrimSpace(userID)
|
||
if clientID == 0 || userID == "" {
|
||
return
|
||
}
|
||
if accountData == nil {
|
||
accountData = make(map[string]interface{})
|
||
}
|
||
accountData["user_id"] = userID
|
||
accountData["client_id"] = clientID
|
||
|
||
clientStateMu.Lock()
|
||
state := ensureClientStateLocked(clientID)
|
||
state.UserID = userID
|
||
state.Status = clientStatusIdentified
|
||
state.LastError = ""
|
||
state.LastEvent = "account_identified"
|
||
state.IdentifiedAt = nowText()
|
||
state.LastSeenAt = state.IdentifiedAt
|
||
clientStateMu.Unlock()
|
||
|
||
clientIdMutex.Lock()
|
||
oldUserID := strings.TrimSpace(globalClientMap[clientID])
|
||
globalClientMap[clientID] = userID
|
||
clientIdMutex.Unlock()
|
||
if globalLogger != nil && oldUserID != "" && oldUserID != userID {
|
||
globalLogger.Warn("[辅助程序] 账号映射变化: clientId=%d old=%s new=%s event=%s", clientID, oldUserID, userID, responseTypeString(accountData))
|
||
} else if globalLogger != nil {
|
||
globalLogger.Info("[辅助程序] 账号映射确认: clientId=%d user_id=%s event=%s", clientID, userID, responseTypeString(accountData))
|
||
}
|
||
|
||
setInjectionStatus(injectionStatusIdentified, 0, "")
|
||
updateClientStatusWithResponseData(userID, accountData)
|
||
if autoReplyEngine != nil {
|
||
autoReplyEngine.observeCurrentAccountIdentity(clientID, userID, accountData)
|
||
}
|
||
if refreshIdentity && autoReplyEngine != nil {
|
||
cfg := autoReplyEngine.getConfig()
|
||
if cfg.Identity.RefreshOnStart {
|
||
go func() {
|
||
time.Sleep(800 * time.Millisecond)
|
||
autoReplyEngine.refreshIdentityContactsAsync("client_identified")
|
||
}()
|
||
}
|
||
}
|
||
}
|
||
|
||
func markClientMessageReady(clientID uint32, raw map[string]interface{}) {
|
||
if clientID == 0 {
|
||
return
|
||
}
|
||
if isAccountIdentityEvent(raw) {
|
||
if userID, accountData := extractAccountIdentity(raw); userID != "" {
|
||
markClientIdentified(clientID, userID, accountData)
|
||
return
|
||
}
|
||
}
|
||
|
||
now := nowText()
|
||
clientStateMu.Lock()
|
||
state := ensureClientStateLocked(clientID)
|
||
if state.Status != clientStatusIdentified {
|
||
state.Status = clientStatusMessageReady
|
||
}
|
||
state.LastError = ""
|
||
state.LastEvent = "message_ready"
|
||
if state.FirstMessageAt == "" {
|
||
state.FirstMessageAt = now
|
||
}
|
||
state.LastMessageAt = now
|
||
state.MessageCount++
|
||
state.LastSeenAt = now
|
||
clientStateMu.Unlock()
|
||
|
||
clientIdMutex.Lock()
|
||
if _, exists := globalClientMap[clientID]; !exists {
|
||
globalClientMap[clientID] = ""
|
||
}
|
||
clientIdMutex.Unlock()
|
||
|
||
setInjectionStatus(injectionStatusConnected, 0, "")
|
||
}
|
||
|
||
func markClientError(clientID uint32, status string, message string) {
|
||
if clientID == 0 {
|
||
return
|
||
}
|
||
clientStateMu.Lock()
|
||
state, exists := clientStates[clientID]
|
||
if !exists {
|
||
clientStateMu.Unlock()
|
||
return
|
||
}
|
||
state.Status = status
|
||
state.LastError = message
|
||
state.LastSeenAt = nowText()
|
||
clientStateMu.Unlock()
|
||
if message != "" {
|
||
globalLogger.Warn("[辅助程序] client %d %s: %s", clientID, status, message)
|
||
}
|
||
}
|
||
|
||
func removeClientState(clientID uint32) {
|
||
clientStateMu.Lock()
|
||
delete(clientStates, clientID)
|
||
remaining := len(clientStates)
|
||
clientStateMu.Unlock()
|
||
|
||
clientIdMutex.Lock()
|
||
delete(globalClientMap, clientID)
|
||
if globalClientId == clientID {
|
||
globalClientId = 0
|
||
}
|
||
clientIdMutex.Unlock()
|
||
|
||
if remaining == 0 {
|
||
setInjectionStatus(injectionStatusNotStarted, 0, "")
|
||
}
|
||
}
|
||
|
||
func getClientUserID(clientID uint32) string {
|
||
clientIdMutex.Lock()
|
||
defer clientIdMutex.Unlock()
|
||
return globalClientMap[clientID]
|
||
}
|
||
|
||
func recognizedClientCount() int {
|
||
clientIdMutex.Lock()
|
||
defer clientIdMutex.Unlock()
|
||
count := 0
|
||
for _, userID := range globalClientMap {
|
||
if strings.TrimSpace(userID) != "" {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func isUsableClientState(state *ClientRuntimeState) bool {
|
||
if state == nil {
|
||
return false
|
||
}
|
||
return state.Status == clientStatusIdentified || state.Status == clientStatusMessageReady
|
||
}
|
||
|
||
func usableClientCount() int {
|
||
clientStateMu.Lock()
|
||
defer clientStateMu.Unlock()
|
||
count := 0
|
||
for _, state := range clientStates {
|
||
if isUsableClientState(state) {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func isClientUsable(clientID uint32) bool {
|
||
clientStateMu.Lock()
|
||
defer clientStateMu.Unlock()
|
||
return isUsableClientState(clientStates[clientID])
|
||
}
|
||
|
||
func connectedClientCount() int {
|
||
clientStateMu.Lock()
|
||
defer clientStateMu.Unlock()
|
||
return len(clientStates)
|
||
}
|
||
|
||
func unidentifiedClientCount() int {
|
||
clientStateMu.Lock()
|
||
defer clientStateMu.Unlock()
|
||
count := 0
|
||
for _, state := range clientStates {
|
||
if strings.TrimSpace(state.UserID) == "" && state.Status != clientStatusMessageReady {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func runtimeRobotID(clientID uint32) string {
|
||
if clientID == 0 {
|
||
return ""
|
||
}
|
||
if userID := strings.TrimSpace(getClientUserID(clientID)); userID != "" {
|
||
return userID
|
||
}
|
||
return fmt.Sprintf("client:%d", clientID)
|
||
}
|
||
|
||
func clientConnectedAt(clientID uint32) time.Time {
|
||
clientStateMu.Lock()
|
||
defer clientStateMu.Unlock()
|
||
if state, ok := clientStates[clientID]; ok {
|
||
return parseClientStateTime(state.ConnectedAt)
|
||
}
|
||
return time.Time{}
|
||
}
|
||
|
||
func clientIdentifiedAt(clientID uint32) time.Time {
|
||
clientStateMu.Lock()
|
||
defer clientStateMu.Unlock()
|
||
if state, ok := clientStates[clientID]; ok {
|
||
return parseClientStateTime(state.IdentifiedAt)
|
||
}
|
||
return time.Time{}
|
||
}
|
||
|
||
func parseClientStateTime(value string) time.Time {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return time.Time{}
|
||
}
|
||
t, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||
if err != nil {
|
||
return time.Time{}
|
||
}
|
||
return t
|
||
}
|
||
|
||
func getUsableClientsMap() map[string]string {
|
||
clientStateMu.Lock()
|
||
defer clientStateMu.Unlock()
|
||
clients := make(map[string]string)
|
||
for clientID, state := range clientStates {
|
||
if !isUsableClientState(state) {
|
||
continue
|
||
}
|
||
userID := strings.TrimSpace(state.UserID)
|
||
if userID == "" {
|
||
userID = fmt.Sprintf("client:%d", clientID)
|
||
}
|
||
clients[fmt.Sprintf("%d", clientID)] = userID
|
||
}
|
||
return clients
|
||
}
|
||
|
||
func getIdentifiedClientsMap() map[string]string {
|
||
clientIdMutex.Lock()
|
||
defer clientIdMutex.Unlock()
|
||
clients := make(map[string]string)
|
||
for clientID, userID := range globalClientMap {
|
||
if strings.TrimSpace(userID) != "" {
|
||
clients[fmt.Sprintf("%d", clientID)] = userID
|
||
}
|
||
}
|
||
return clients
|
||
}
|
||
|
||
func getIdentifiedUserIDSet() map[string]bool {
|
||
clientIdMutex.Lock()
|
||
defer clientIdMutex.Unlock()
|
||
users := make(map[string]bool)
|
||
for _, userID := range globalClientMap {
|
||
userID = strings.TrimSpace(userID)
|
||
if userID != "" {
|
||
users[userID] = true
|
||
}
|
||
}
|
||
return users
|
||
}
|
||
|
||
func getRuntimeAccountRows() []map[string]interface{} {
|
||
clientStateMu.Lock()
|
||
states := make([]ClientRuntimeState, 0, len(clientStates))
|
||
for _, state := range clientStates {
|
||
if isUsableClientState(state) {
|
||
states = append(states, *state)
|
||
}
|
||
}
|
||
clientStateMu.Unlock()
|
||
sort.Slice(states, func(i, j int) bool {
|
||
return states[i].ClientID < states[j].ClientID
|
||
})
|
||
|
||
rows := make([]map[string]interface{}, 0, len(states))
|
||
for _, state := range states {
|
||
userID := strings.TrimSpace(state.UserID)
|
||
username := userID
|
||
runtimeOnly := false
|
||
if userID == "" {
|
||
userID = fmt.Sprintf("client:%d", state.ClientID)
|
||
username = fmt.Sprintf("未识别账号 client %d", state.ClientID)
|
||
runtimeOnly = true
|
||
}
|
||
healthState, healthMessage := clientHealthSnapshot(state)
|
||
rows = append(rows, map[string]interface{}{
|
||
"user_id": userID,
|
||
"username": username,
|
||
"avatar": "",
|
||
"corp_short_name": "",
|
||
"corp_name": "",
|
||
"status": 1,
|
||
"client_id": state.ClientID,
|
||
"pid": state.PID,
|
||
"runtime_status": state.Status,
|
||
"runtime_only": runtimeOnly,
|
||
"health_state": healthState,
|
||
"health_message": healthMessage,
|
||
"first_message_at": state.FirstMessageAt,
|
||
"last_message_at": state.LastMessageAt,
|
||
"message_count": state.MessageCount,
|
||
})
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func getFirstAvailableClientID() uint32 {
|
||
clientStateMu.Lock()
|
||
defer clientStateMu.Unlock()
|
||
var messageReady uint32
|
||
var fallback uint32
|
||
for clientID, state := range clientStates {
|
||
if state.Status == clientStatusIdentified && strings.TrimSpace(state.UserID) != "" {
|
||
return clientID
|
||
}
|
||
if messageReady == 0 && state.Status == clientStatusMessageReady {
|
||
messageReady = clientID
|
||
}
|
||
if fallback == 0 {
|
||
fallback = clientID
|
||
}
|
||
}
|
||
if messageReady != 0 {
|
||
return messageReady
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func clientHealthSnapshot(state ClientRuntimeState) (string, string) {
|
||
if strings.TrimSpace(state.LastError) != "" {
|
||
return "error", state.LastError
|
||
}
|
||
if strings.TrimSpace(state.UserID) == "" {
|
||
return "unidentified", "账号已连接,等待识别或扫码登录"
|
||
}
|
||
lastSeen := parseClientStateTime(state.LastSeenAt)
|
||
if !lastSeen.IsZero() && time.Since(lastSeen) > 5*time.Minute {
|
||
return "stale", "超过5分钟未收到连接或消息事件"
|
||
}
|
||
if state.Status == clientStatusIdentified || state.Status == clientStatusMessageReady {
|
||
return "ok", "运行正常"
|
||
}
|
||
return state.Status, state.Status
|
||
}
|
||
|
||
func getClientDiagnostics() map[string]interface{} {
|
||
clientStateMu.Lock()
|
||
states := make([]ClientRuntimeState, 0, len(clientStates))
|
||
for _, state := range clientStates {
|
||
states = append(states, *state)
|
||
}
|
||
clientStateMu.Unlock()
|
||
sort.Slice(states, func(i, j int) bool {
|
||
return states[i].ClientID < states[j].ClientID
|
||
})
|
||
|
||
injectionMu.Lock()
|
||
injection := map[string]interface{}{
|
||
"status": injectionStatus,
|
||
"lastPid": injectionLastPID,
|
||
"lastError": injectionLastError,
|
||
"lastUpdate": injectionLastUpdate,
|
||
}
|
||
injectionMu.Unlock()
|
||
|
||
clientRows := make([]map[string]interface{}, 0, len(states))
|
||
for _, state := range states {
|
||
healthState, healthMessage := clientHealthSnapshot(state)
|
||
clientRows = append(clientRows, map[string]interface{}{
|
||
"clientId": state.ClientID,
|
||
"pid": state.PID,
|
||
"userId": state.UserID,
|
||
"status": state.Status,
|
||
"lastEvent": state.LastEvent,
|
||
"lastError": state.LastError,
|
||
"connectedAt": state.ConnectedAt,
|
||
"identifiedAt": state.IdentifiedAt,
|
||
"lastSeenAt": state.LastSeenAt,
|
||
"lastIdentifyRequestAt": state.LastIdentifyRequestAt,
|
||
"lastIdentifyResponseType": state.LastIdentifyResponseType,
|
||
"ignoredIdentifyEvents": state.IgnoredIdentifyEvents,
|
||
"lastIgnoredIdentifyReason": state.LastIgnoredIdentifyReason,
|
||
"lastIgnoredIdentifyEvent": state.LastIgnoredIdentifyEvent,
|
||
"firstMessageAt": state.FirstMessageAt,
|
||
"lastMessageAt": state.LastMessageAt,
|
||
"messageCount": state.MessageCount,
|
||
"healthState": healthState,
|
||
"healthMessage": healthMessage,
|
||
})
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"recognizedClientCount": recognizedClientCount(),
|
||
"usableClientCount": usableClientCount(),
|
||
"unidentifiedClientCount": unidentifiedClientCount(),
|
||
"connectionCount": connectedClientCount(),
|
||
"clients": clientRows,
|
||
"injection": injection,
|
||
"version": getWxWorkVersionDiagnostics(),
|
||
}
|
||
}
|
||
|
||
func getAccountDiagnosticMessage() string {
|
||
diagnostics := getClientDiagnostics()
|
||
recognized, _ := diagnostics["recognizedClientCount"].(int)
|
||
usable, _ := diagnostics["usableClientCount"].(int)
|
||
unidentified, _ := diagnostics["unidentifiedClientCount"].(int)
|
||
connectionCount, _ := diagnostics["connectionCount"].(int)
|
||
if recognized > 0 {
|
||
return ""
|
||
}
|
||
if usable > 0 {
|
||
return ""
|
||
}
|
||
if unidentified > 0 {
|
||
return "已连接到企业微信进程,但还未识别到账号信息;请查看 dashboard 的账号识别状态。"
|
||
}
|
||
if connectionCount > 0 {
|
||
return "已收到连接事件,但没有可用的企业微信账号信息。"
|
||
}
|
||
return "未收到企业微信账号连接;请先点击启动企微。"
|
||
}
|
||
|
||
func tryBeginWxWorkInjection() (bool, map[string]interface{}) {
|
||
if connectedClientCount() > 0 {
|
||
return false, wxWorkStartStatusPayload("已有企业微信连接,未重复注入", true, true)
|
||
}
|
||
|
||
injectionMu.Lock()
|
||
defer injectionMu.Unlock()
|
||
if injectionStatus == injectionStatusInjecting || injectionStatus == injectionStatusConnected || injectionStatus == injectionStatusIdentified {
|
||
return false, wxWorkStartStatusPayloadLocked("正在注入企业微信,请稍候", true, true)
|
||
}
|
||
injectionStatus = injectionStatusInjecting
|
||
injectionLastError = ""
|
||
injectionLastUpdate = nowText()
|
||
return true, nil
|
||
}
|
||
|
||
func setInjectionStatus(status string, pid uint32, errMsg string) {
|
||
injectionMu.Lock()
|
||
injectionStatus = status
|
||
if pid != 0 {
|
||
injectionLastPID = pid
|
||
}
|
||
injectionLastError = errMsg
|
||
injectionLastUpdate = nowText()
|
||
injectionMu.Unlock()
|
||
}
|
||
|
||
func watchWxWorkConnectionTimeout(pid uint32) {
|
||
time.Sleep(10 * time.Second)
|
||
if connectedClientCount() > 0 {
|
||
return
|
||
}
|
||
injectionMu.Lock()
|
||
if injectionStatus == injectionStatusConnected && injectionLastPID == pid {
|
||
injectionStatus = injectionStatusFailed
|
||
injectionLastError = "注入后未收到企业微信连接事件"
|
||
injectionLastUpdate = nowText()
|
||
}
|
||
injectionMu.Unlock()
|
||
}
|
||
|
||
func wxWorkStartStatusPayload(message string, success bool, skipped bool) map[string]interface{} {
|
||
injectionMu.Lock()
|
||
defer injectionMu.Unlock()
|
||
return wxWorkStartStatusPayloadLocked(message, success, skipped)
|
||
}
|
||
|
||
func wxWorkStartStatusPayloadLocked(message string, success bool, skipped bool) map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"success": success,
|
||
"skipped": skipped,
|
||
"message": message,
|
||
"injectionStatus": injectionStatus,
|
||
"processId": injectionLastPID,
|
||
"recognizedClientCount": recognizedClientCount(),
|
||
"usableClientCount": usableClientCount(),
|
||
"unidentifiedClientCount": unidentifiedClientCount(),
|
||
"connectionCount": connectedClientCount(),
|
||
"lastError": injectionLastError,
|
||
}
|
||
}
|
||
|
||
func markPIDInjected(pid uint32) bool {
|
||
if pid == 0 {
|
||
return false
|
||
}
|
||
injectionMu.Lock()
|
||
defer injectionMu.Unlock()
|
||
if _, exists := injectedPIDs[pid]; exists {
|
||
return false
|
||
}
|
||
injectedPIDs[pid] = time.Now()
|
||
return true
|
||
}
|
||
|
||
func unmarkPIDInjected(pid uint32) {
|
||
if pid == 0 {
|
||
return
|
||
}
|
||
injectionMu.Lock()
|
||
delete(injectedPIDs, pid)
|
||
injectionMu.Unlock()
|
||
}
|
||
|
||
func ensureClientStateLocked(clientID uint32) *ClientRuntimeState {
|
||
state, exists := clientStates[clientID]
|
||
if !exists {
|
||
now := nowText()
|
||
state = &ClientRuntimeState{
|
||
ClientID: clientID,
|
||
Status: clientStatusConnected,
|
||
ConnectedAt: now,
|
||
LastSeenAt: now,
|
||
}
|
||
clientStates[clientID] = state
|
||
}
|
||
return state
|
||
}
|
||
|
||
func extractAccountIdentity(value map[string]interface{}) (string, map[string]interface{}) {
|
||
if value == nil {
|
||
return "", nil
|
||
}
|
||
if !isAccountIdentityEvent(value) {
|
||
return "", nil
|
||
}
|
||
return extractAccountIdentityFromMap(value)
|
||
}
|
||
|
||
func isAccountIdentityEvent(value map[string]interface{}) bool {
|
||
if value == nil {
|
||
return false
|
||
}
|
||
switch responseTypeString(value) {
|
||
case "11026", "11179":
|
||
return true
|
||
}
|
||
if data, ok := value["data"].(map[string]interface{}); ok {
|
||
switch responseTypeString(data) {
|
||
case "11026", "11179":
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func extractAccountIdentityFromMap(data map[string]interface{}) (string, map[string]interface{}) {
|
||
return extractAccountIdentityFromMapDepth(data, 0)
|
||
}
|
||
|
||
func extractAccountIdentityFromMapDepth(data map[string]interface{}, depth int) (string, map[string]interface{}) {
|
||
if data == nil {
|
||
return "", nil
|
||
}
|
||
userID := strings.TrimSpace(valueToString(firstExisting(data, "user_id", "userId", "robotId", "acctid", "account", "wxid")))
|
||
if userID == "" {
|
||
if depth >= 6 {
|
||
return "", nil
|
||
}
|
||
for _, key := range []string{"data", "user", "accountInfo", "loginUser", "profile"} {
|
||
if nested, ok := data[key].(map[string]interface{}); ok {
|
||
if nestedUserID, nestedData := extractAccountIdentityFromMapDepth(nested, depth+1); nestedUserID != "" {
|
||
return nestedUserID, nestedData
|
||
}
|
||
}
|
||
}
|
||
return "", nil
|
||
}
|
||
accountData := make(map[string]interface{}, len(data)+1)
|
||
for key, value := range data {
|
||
accountData[key] = value
|
||
}
|
||
accountData["user_id"] = userID
|
||
if _, exists := accountData["username"]; !exists {
|
||
if name := valueToString(firstExisting(data, "username", "name", "nickname")); name != "" {
|
||
accountData["username"] = name
|
||
}
|
||
}
|
||
return userID, accountData
|
||
}
|
||
|
||
func firstExisting(data map[string]interface{}, keys ...string) interface{} {
|
||
for _, key := range keys {
|
||
if value, exists := data[key]; exists {
|
||
return value
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func responseTypeString(data map[string]interface{}) string {
|
||
if data == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(valueToString(firstExisting(data, "type", "event")))
|
||
}
|
||
|
||
func summarizeIdentifyEvent(data map[string]interface{}) string {
|
||
if data == nil {
|
||
return ""
|
||
}
|
||
encoded, err := json.Marshal(data)
|
||
if err != nil {
|
||
return fmt.Sprint(data)
|
||
}
|
||
text := string(encoded)
|
||
if len(text) > 260 {
|
||
return text[:260] + "..."
|
||
}
|
||
return text
|
||
}
|
||
|
||
func recordClientProbeEvent(clientID int32, data map[string]interface{}, raw string) {
|
||
clientProbeMu.Lock()
|
||
probes := append([]chan ClientProbeRecord(nil), clientProbes[clientID]...)
|
||
clientProbeMu.Unlock()
|
||
if len(probes) == 0 {
|
||
return
|
||
}
|
||
|
||
record := ClientProbeRecord{
|
||
Time: nowText(),
|
||
ClientID: clientID,
|
||
Type: responseTypeString(data),
|
||
Summary: dashboardMessageSummary(data, raw),
|
||
}
|
||
if data != nil {
|
||
record.Raw = data
|
||
} else if strings.TrimSpace(raw) != "" {
|
||
record.Raw = raw
|
||
}
|
||
for _, ch := range probes {
|
||
select {
|
||
case ch <- record:
|
||
default:
|
||
}
|
||
}
|
||
}
|
||
|
||
func runClientAccountProbe(clientID uint32) ClientProbeResult {
|
||
started := nowText()
|
||
result := ClientProbeResult{
|
||
ClientID: int32(clientID),
|
||
Started: started,
|
||
Duration: 15,
|
||
Records: []ClientProbeRecord{},
|
||
Message: "no account callback received",
|
||
}
|
||
if clientID == 0 {
|
||
result.Message = "invalid clientId"
|
||
return result
|
||
}
|
||
if !supportsAccountInfoRequest() {
|
||
result.Message = "account info request is disabled for this WXWork/DLL version because 11035 can crash WXWork; bind the client manually."
|
||
return result
|
||
}
|
||
|
||
ch := make(chan ClientProbeRecord, 64)
|
||
clientProbeMu.Lock()
|
||
clientProbes[int32(clientID)] = append(clientProbes[int32(clientID)], ch)
|
||
clientProbeMu.Unlock()
|
||
defer func() {
|
||
clientProbeMu.Lock()
|
||
probes := clientProbes[int32(clientID)]
|
||
for i, item := range probes {
|
||
if item == ch {
|
||
clientProbes[int32(clientID)] = append(probes[:i], probes[i+1:]...)
|
||
break
|
||
}
|
||
}
|
||
if len(clientProbes[int32(clientID)]) == 0 {
|
||
delete(clientProbes, int32(clientID))
|
||
}
|
||
clientProbeMu.Unlock()
|
||
close(ch)
|
||
}()
|
||
|
||
markClientIdentifyRequest(clientID)
|
||
_, err := handleSendWxWorkData(map[string]interface{}{
|
||
"data": `{"type":11035,"data":{}}`,
|
||
"clientId": clientID,
|
||
})
|
||
if err != nil {
|
||
result.Message = "probe send 11035 failed: " + err.Error()
|
||
return result
|
||
}
|
||
|
||
timer := time.After(15 * time.Second)
|
||
for {
|
||
select {
|
||
case record := <-ch:
|
||
result.Records = append(result.Records, record)
|
||
if data, ok := record.Raw.(map[string]interface{}); ok {
|
||
markClientIdentifyResponse(clientID, data)
|
||
if userID, accountData := extractAccountIdentity(data); userID != "" {
|
||
markClientIdentified(clientID, userID, accountData)
|
||
result.Message = "account callback received; client upgraded to identified"
|
||
}
|
||
}
|
||
case <-timer:
|
||
if len(result.Records) == 0 {
|
||
result.Message = "15 seconds passed with no callbacks. This DLL/WXWork version may not return account info; use 11041 messages to enter message_ready mode."
|
||
} else if result.Message == "no account callback received" {
|
||
result.Message = "callbacks received, but no account identity fields were found; use 11041 messages to enter message_ready mode."
|
||
}
|
||
return result
|
||
}
|
||
}
|
||
}
|
||
|
||
func valueToString(value interface{}) string {
|
||
switch v := value.(type) {
|
||
case string:
|
||
return strings.TrimSpace(v)
|
||
case fmt.Stringer:
|
||
return strings.TrimSpace(v.String())
|
||
case nil:
|
||
return ""
|
||
default:
|
||
return strings.TrimSpace(fmt.Sprint(v))
|
||
}
|
||
}
|
||
|
||
func supportsAccountInfoRequest() bool {
|
||
bundle := resolveDLLBundle()
|
||
return bundle.HelperVersion != "5.0.8.6009" && bundle.WxWorkVersion != "5.0.8.6009"
|
||
}
|