Initial qiwei secondary development handoff
This commit is contained in:
585
helper/after_sales_engine.go
Normal file
585
helper/after_sales_engine.go
Normal file
@@ -0,0 +1,585 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var afterSalesAICollector = callAfterSalesAI
|
||||
|
||||
func observeAfterSalesEvent(clientID int32, raw map[string]interface{}) {
|
||||
getAfterSalesIssueEngine().observeEvent(clientID, raw)
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) observeEvent(clientID int32, raw map[string]interface{}) {
|
||||
message, ok := e.extractMessage(clientID, raw)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for i := range e.messages {
|
||||
if e.messages[i].MessageID == message.MessageID {
|
||||
e.messages[i] = message
|
||||
e.trimMessagesLocked(time.Now())
|
||||
e.updateStateMessageCountLocked()
|
||||
_ = e.saveMessagesLocked()
|
||||
_ = e.saveStateLocked()
|
||||
return
|
||||
}
|
||||
}
|
||||
e.messages = append(e.messages, message)
|
||||
e.trimMessagesLocked(time.Now())
|
||||
e.updateStateMessageCountLocked()
|
||||
if err := e.saveMessagesLocked(); err != nil && globalLogger != nil {
|
||||
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞垮劗濡插牓鏌涢銈呮瀺缂佽鲸鐗滅槐鎾诲磼濞戞瑥纰嶉梺瀹︽澘濡界€垫澘瀚蹇涱敃閵? %v", err)
|
||||
}
|
||||
if err := e.saveStateLocked(); err != nil && globalLogger != nil {
|
||||
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閻撳倿鏌涢妷顔煎闁艰尙濞€閺岋絽螣閸喚鍘梺閫炲苯鍘哥紒鈧笟鈧俊鐢稿箣閻樺啿顏? %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) extractMessage(clientID int32, raw map[string]interface{}) (AfterSalesMessage, bool) {
|
||||
autoEngine := getAutoReplyEngine()
|
||||
autoEngine.observeGroupNames(clientID, raw)
|
||||
msg := extractAutoReplyMessage(clientID, raw)
|
||||
autoEngine.enrichAutoReplyMessage(&msg, time.Now())
|
||||
if !msg.IsGroup || strings.TrimSpace(msg.ConversationID) == "" {
|
||||
return AfterSalesMessage{}, false
|
||||
}
|
||||
identity := autoEngine.classifySenderIdentity(msg)
|
||||
imagePath, imageRef := extractAfterSalesImageFromMessage(msg, raw)
|
||||
messageID := strings.TrimSpace(firstNonEmpty(msg.ServerID, msg.LocalID))
|
||||
if messageID == "" {
|
||||
messageID = stableAfterSalesMessageID(clientID, msg, imagePath, imageRef)
|
||||
}
|
||||
fileAttachment := extractAfterSalesFileFromMessage(msg, raw, messageID)
|
||||
messageType := msg.MessageType
|
||||
if imagePath != "" || imageRef != "" || looksLikeImageMessage(raw) {
|
||||
messageType = "image"
|
||||
}
|
||||
if fileAttachment.Path != "" || fileAttachment.Ref != "" || looksLikeAfterSalesFileMessage(msg, raw) {
|
||||
messageType = "file"
|
||||
}
|
||||
if messageType != "text" && messageType != "image" && messageType != "file" {
|
||||
return AfterSalesMessage{}, false
|
||||
}
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if content == "" && messageType == "image" {
|
||||
content = "image"
|
||||
}
|
||||
if content == "" && messageType == "file" {
|
||||
content = "file"
|
||||
if fileAttachment.Name != "" {
|
||||
content += ": " + fileAttachment.Name
|
||||
}
|
||||
}
|
||||
if content == "" && imagePath == "" && imageRef == "" && fileAttachment.Path == "" && fileAttachment.Ref == "" {
|
||||
return AfterSalesMessage{}, false
|
||||
}
|
||||
sendAt := parseAfterSalesSendTime(msg.SendTime)
|
||||
if sendAt <= 0 {
|
||||
sendAt = time.Now().Unix()
|
||||
}
|
||||
senderName := strings.TrimSpace(firstNonEmpty(identity.Name, msg.FromNickName))
|
||||
if senderName == "" {
|
||||
senderName = "unknown customer"
|
||||
}
|
||||
roomName := strings.TrimSpace(msg.GroupName)
|
||||
if roomName == "" || roomName == msg.ConversationID {
|
||||
if resolved := autoEngine.ResolveGroupName(msg.ConversationID); resolved != "" {
|
||||
roomName = resolved
|
||||
}
|
||||
}
|
||||
if roomName == "" {
|
||||
roomName = msg.ConversationID
|
||||
}
|
||||
return AfterSalesMessage{
|
||||
MessageID: messageID,
|
||||
ClientID: clientID,
|
||||
ConversationID: msg.ConversationID,
|
||||
RoomName: roomName,
|
||||
SenderUserID: msg.FromWxID,
|
||||
SenderName: senderName,
|
||||
SenderIdentity: identity.Kind,
|
||||
Content: content,
|
||||
MessageType: messageType,
|
||||
ImagePath: imagePath,
|
||||
ImageRef: imageRef,
|
||||
FilePath: fileAttachment.Path,
|
||||
FileRef: fileAttachment.Ref,
|
||||
FileName: fileAttachment.Name,
|
||||
FileContent: fileAttachment.Content,
|
||||
FileExtractStatus: fileAttachment.ExtractStatus,
|
||||
SendTime: sendAt,
|
||||
ReceivedAt: time.Now().Unix(),
|
||||
}, true
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) triggerCollectAsync(conversationID string, manual bool) (bool, string) {
|
||||
conversationID = normalizeAfterSalesCollectConversationID(conversationID)
|
||||
if manual {
|
||||
e.mu.Lock()
|
||||
messages := append([]AfterSalesMessage(nil), e.messages...)
|
||||
e.mu.Unlock()
|
||||
if len(filterAfterSalesMessages(messages, 0, time.Now().Unix(), conversationID)) == 0 {
|
||||
return true, afterSalesCollectEmptyMessage(conversationID, manual, 0)
|
||||
}
|
||||
}
|
||||
e.mu.Lock()
|
||||
if e.state.Collecting {
|
||||
e.mu.Unlock()
|
||||
return false, "after-sales collection is already running"
|
||||
}
|
||||
e.state.Collecting = true
|
||||
e.state.LastError = ""
|
||||
e.updateStateMessageCountLocked()
|
||||
_ = e.saveStateLocked()
|
||||
e.mu.Unlock()
|
||||
|
||||
go e.collectLockedAsync(conversationID, manual)
|
||||
if manual && conversationID != "" {
|
||||
if name := getAutoReplyEngine().ResolveGroupName(conversationID); name != "" {
|
||||
return true, fmt.Sprintf("started after-sales collection for group %s", name)
|
||||
}
|
||||
return true, "started after-sales collection for selected group"
|
||||
}
|
||||
return true, "started after-sales collection"
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) collectLockedAsync(conversationID string, manual bool) {
|
||||
prewarmAfterSalesGroupNames()
|
||||
added, scanned, err := e.collectNow(conversationID, manual)
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.state.Collecting = false
|
||||
e.state.LastAddedCount = added
|
||||
e.state.LastCollectedAt = time.Now().Unix()
|
||||
if err != nil {
|
||||
e.state.LastError = err.Error()
|
||||
} else {
|
||||
e.state.LastError = afterSalesCollectEmptyMessage(conversationID, manual, scanned)
|
||||
if !manual {
|
||||
e.state.LastCollectAt = time.Now().Unix()
|
||||
}
|
||||
e.repairIssuesLocked()
|
||||
}
|
||||
e.updateStateMessageCountLocked()
|
||||
if saveErr := e.saveStateLocked(); saveErr != nil && globalLogger != nil {
|
||||
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閻撳倿鏌涢妷顔煎闁艰尙濞€閺岋絽螣閸喚鍘梺閫炲苯鍘哥紒鈧笟鈧俊鐢稿箣閻樺啿顏? %v", saveErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) collectNow(conversationID string, manual bool) (int, int, error) {
|
||||
conversationID = normalizeAfterSalesCollectConversationID(conversationID)
|
||||
e.mu.Lock()
|
||||
state := e.state
|
||||
messages := append([]AfterSalesMessage(nil), e.messages...)
|
||||
issues := append([]AfterSalesIssue(nil), e.issues...)
|
||||
e.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
from := int64(0)
|
||||
if !manual {
|
||||
from = state.LastCollectAt
|
||||
if from <= 0 {
|
||||
from = now.Add(-afterSalesFirstCollectWindow).Unix()
|
||||
}
|
||||
}
|
||||
targets := filterAfterSalesMessages(messages, from, now.Unix(), conversationID)
|
||||
if len(targets) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
cfg := getAutoReplyEngine().getConfig()
|
||||
if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" {
|
||||
return 0, len(targets), fmt.Errorf("AI config is incomplete, cannot collect after-sales issues")
|
||||
}
|
||||
existingFingerprints := make(map[string]AfterSalesIssue)
|
||||
for _, issue := range issues {
|
||||
if issue.Fingerprint != "" {
|
||||
existingFingerprints[issue.Fingerprint] = issue
|
||||
}
|
||||
}
|
||||
added := 0
|
||||
for _, batch := range batchAfterSalesMessages(targets, afterSalesBatchSize) {
|
||||
candidates, err := afterSalesAICollector(cfg.AI, batch)
|
||||
if err != nil {
|
||||
return added, len(targets), err
|
||||
}
|
||||
added += e.mergeAIIssueCandidates(candidates, batch, existingFingerprints)
|
||||
}
|
||||
return added, len(targets), nil
|
||||
}
|
||||
|
||||
func prewarmAfterSalesGroupNames() {
|
||||
engine := getAutoReplyEngine()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = engine.refreshIdentityGroups("after_sales_collect")
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) mergeAIIssueCandidates(candidates []afterSalesAIIssueCandidate, batch []AfterSalesMessage, existing map[string]AfterSalesIssue) int {
|
||||
if len(candidates) == 0 {
|
||||
return 0
|
||||
}
|
||||
messageByID := make(map[string]AfterSalesMessage)
|
||||
for _, msg := range batch {
|
||||
messageByID[msg.MessageID] = msg
|
||||
}
|
||||
batchID := newAfterSalesID()
|
||||
now := time.Now().Local().Format(time.RFC3339)
|
||||
added := 0
|
||||
e.mu.Lock()
|
||||
for _, candidate := range candidates {
|
||||
issueContent := strings.TrimSpace(candidate.IssueContent)
|
||||
if issueContent == "" {
|
||||
continue
|
||||
}
|
||||
sourceIDs := uniqueNonEmptyStrings(candidate.SourceMessageIDs)
|
||||
seed := firstCandidateMessage(sourceIDs, batch, messageByID)
|
||||
customerUserID := strings.TrimSpace(candidate.CustomerUserID)
|
||||
if customerUserID == "" {
|
||||
customerUserID = seed.SenderUserID
|
||||
}
|
||||
customerName := normalizeAfterSalesDisplayName(firstNonEmpty(candidate.CustomerName, seed.SenderName))
|
||||
roomName := strings.TrimSpace(firstNonEmpty(candidate.RoomName, seed.RoomName))
|
||||
conversationID := seed.ConversationID
|
||||
if conversationID == "" && len(batch) > 0 {
|
||||
conversationID = batch[0].ConversationID
|
||||
}
|
||||
if roomName == "" || roomName == conversationID || strings.HasPrefix(roomName, "R:") {
|
||||
if resolved := getAutoReplyEngine().ResolveGroupName(conversationID); resolved != "" {
|
||||
roomName = resolved
|
||||
}
|
||||
}
|
||||
if roomName == "" {
|
||||
roomName = conversationID
|
||||
}
|
||||
sourceClientID := seed.ClientID
|
||||
sourceAccountUserID, sourceAccountName := getAutoReplyEngine().sourceAccountForClient(sourceClientID)
|
||||
fingerprint := afterSalesFingerprint(conversationID, customerUserID, issueContent)
|
||||
if existingIssue, ok := existing[fingerprint]; ok {
|
||||
if existingIssue.Status == afterSalesIssueStatusResolved || existingIssue.Status == afterSalesIssueStatusIgnored || existingIssue.ID != "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
imagePaths, imageRefs := collectCandidateImages(candidate, sourceIDs, batch, messageByID)
|
||||
fileAttachments := collectCandidateFileAttachments(sourceIDs, batch, messageByID)
|
||||
issue := AfterSalesIssue{
|
||||
ID: newAfterSalesID(),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ConversationID: conversationID,
|
||||
RoomName: roomName,
|
||||
SourceClientID: sourceClientID,
|
||||
SourceAccountUserID: sourceAccountUserID,
|
||||
SourceAccountName: sourceAccountName,
|
||||
CustomerUserID: customerUserID,
|
||||
CustomerName: customerName,
|
||||
IssueContent: issueContent,
|
||||
ImagePaths: imagePaths,
|
||||
ImageRefs: imageRefs,
|
||||
FileAttachments: fileAttachments,
|
||||
AISuggestion: strings.TrimSpace(candidate.AISuggestion),
|
||||
Status: afterSalesIssueStatusPending,
|
||||
SourceMessageIDs: sourceIDs,
|
||||
Fingerprint: fingerprint,
|
||||
CollectBatchID: batchID,
|
||||
AIConfidence: candidate.Confidence,
|
||||
AISuggestionEdited: false,
|
||||
}
|
||||
if len(issue.SourceMessageIDs) == 0 {
|
||||
issue.SourceMessageIDs = messageIDsForBatch(batch)
|
||||
}
|
||||
e.issues = append(e.issues, issue)
|
||||
existing[fingerprint] = issue
|
||||
added++
|
||||
}
|
||||
if added > 0 {
|
||||
if err := e.saveIssuesLocked(); err != nil && globalLogger != nil {
|
||||
globalLogger.Warn("[闂備礁鎽滈崕鎰板窗閺嶎厼绠栨俊銈呮噺閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈?濠电儑绲藉ú锔炬崲閸岀偞鍋ら柕濞炬櫆閳锋帗銇勯弴妤€浜惧銈忕秬婵倝骞嗛弮鍫濈闁绘劖鍨濋弶顓㈡煟? %v", err)
|
||||
}
|
||||
}
|
||||
shouldRefreshDispatch := added > 0
|
||||
e.mu.Unlock()
|
||||
if shouldRefreshDispatch {
|
||||
e.refreshDispatchAssignmentsAsync()
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) autoCollectLoop() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
e.mu.Lock()
|
||||
enabled := e.state.AutoCollectEnabled
|
||||
collecting := e.state.Collecting
|
||||
last := e.state.LastCollectedAt
|
||||
e.mu.Unlock()
|
||||
if !enabled || collecting {
|
||||
continue
|
||||
}
|
||||
if last > 0 && time.Since(time.Unix(last, 0)) < afterSalesAutoCollectEvery {
|
||||
continue
|
||||
}
|
||||
e.triggerCollectAsync("", false)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AfterSalesIssueEngine) trimMessagesLocked(now time.Time) {
|
||||
cutoff := now.Add(-afterSalesMessageBufferHours * time.Hour).Unix()
|
||||
next := e.messages[:0]
|
||||
for _, msg := range e.messages {
|
||||
ts := msg.SendTime
|
||||
if ts <= 0 {
|
||||
ts = msg.ReceivedAt
|
||||
}
|
||||
if ts >= cutoff {
|
||||
next = append(next, msg)
|
||||
}
|
||||
}
|
||||
e.messages = next
|
||||
}
|
||||
|
||||
func filterAfterSalesMessages(messages []AfterSalesMessage, from int64, to int64, conversationID string) []AfterSalesMessage {
|
||||
conversationID = normalizeAfterSalesCollectConversationID(conversationID)
|
||||
result := make([]AfterSalesMessage, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if conversationID != "" && strings.TrimSpace(msg.ConversationID) != conversationID {
|
||||
continue
|
||||
}
|
||||
ts := msg.SendTime
|
||||
if ts <= 0 {
|
||||
ts = msg.ReceivedAt
|
||||
}
|
||||
if ts > from && ts <= to {
|
||||
result = append(result, msg)
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].ConversationID != result[j].ConversationID {
|
||||
return result[i].ConversationID < result[j].ConversationID
|
||||
}
|
||||
return result[i].SendTime < result[j].SendTime
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeAfterSalesCollectConversationID(conversationID string) string {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if strings.EqualFold(conversationID, afterSalesManualCollectAll) {
|
||||
return ""
|
||||
}
|
||||
return conversationID
|
||||
}
|
||||
|
||||
func afterSalesCollectEmptyMessage(conversationID string, manual bool, scanned int) string {
|
||||
if !manual || scanned > 0 {
|
||||
return ""
|
||||
}
|
||||
if normalizeAfterSalesCollectConversationID(conversationID) != "" {
|
||||
return "selected group has no cached messages to analyze"
|
||||
}
|
||||
return "no cached messages to analyze"
|
||||
}
|
||||
|
||||
func batchAfterSalesMessages(messages []AfterSalesMessage, size int) [][]AfterSalesMessage {
|
||||
if size <= 0 {
|
||||
size = afterSalesBatchSize
|
||||
}
|
||||
grouped := make(map[string][]AfterSalesMessage)
|
||||
keys := make([]string, 0)
|
||||
for _, msg := range messages {
|
||||
key := msg.ConversationID
|
||||
if _, exists := grouped[key]; !exists {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
grouped[key] = append(grouped[key], msg)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var batches [][]AfterSalesMessage
|
||||
for _, key := range keys {
|
||||
items := grouped[key]
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].SendTime < items[j].SendTime })
|
||||
for start := 0; start < len(items); start += size {
|
||||
end := start + size
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
batches = append(batches, append([]AfterSalesMessage(nil), items[start:end]...))
|
||||
}
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
func firstCandidateMessage(sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) AfterSalesMessage {
|
||||
for _, id := range sourceIDs {
|
||||
if msg, ok := byID[id]; ok && msg.SenderIdentity != senderIdentityInternal {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
for _, id := range sourceIDs {
|
||||
if msg, ok := byID[id]; ok {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
for _, msg := range batch {
|
||||
if msg.SenderIdentity != senderIdentityInternal {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
return batch[0]
|
||||
}
|
||||
return AfterSalesMessage{}
|
||||
}
|
||||
|
||||
func collectCandidateImages(candidate afterSalesAIIssueCandidate, sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) ([]string, []string) {
|
||||
paths := append([]string(nil), candidate.ImagePaths...)
|
||||
refs := append([]string(nil), candidate.ImageRefs...)
|
||||
addMessageImage := func(msg AfterSalesMessage) {
|
||||
if msg.ImagePath != "" {
|
||||
paths = append(paths, msg.ImagePath)
|
||||
}
|
||||
if msg.ImageRef != "" {
|
||||
refs = append(refs, msg.ImageRef)
|
||||
}
|
||||
}
|
||||
if len(sourceIDs) > 0 {
|
||||
for _, id := range sourceIDs {
|
||||
if msg, ok := byID[id]; ok {
|
||||
addMessageImage(msg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, msg := range batch {
|
||||
addMessageImage(msg)
|
||||
}
|
||||
}
|
||||
return uniqueExistingImagePaths(paths), uniqueNonEmptyStrings(refs)
|
||||
}
|
||||
|
||||
func collectCandidateFileAttachments(sourceIDs []string, batch []AfterSalesMessage, byID map[string]AfterSalesMessage) []AfterSalesFileAttachment {
|
||||
files := make([]AfterSalesFileAttachment, 0)
|
||||
addMessageFile := func(msg AfterSalesMessage) {
|
||||
file := AfterSalesFileAttachment{
|
||||
Name: msg.FileName,
|
||||
Path: msg.FilePath,
|
||||
Ref: msg.FileRef,
|
||||
Content: msg.FileContent,
|
||||
ExtractStatus: msg.FileExtractStatus,
|
||||
SourceMessageID: msg.MessageID,
|
||||
}
|
||||
file = normalizeAfterSalesFileAttachment(file)
|
||||
if file.Path != "" || file.Ref != "" || file.Content != "" {
|
||||
files = append(files, file)
|
||||
}
|
||||
}
|
||||
if len(sourceIDs) > 0 {
|
||||
for _, id := range sourceIDs {
|
||||
if msg, ok := byID[id]; ok {
|
||||
addMessageFile(msg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, msg := range batch {
|
||||
addMessageFile(msg)
|
||||
}
|
||||
}
|
||||
return normalizeAfterSalesFileAttachments(files)
|
||||
}
|
||||
|
||||
func messageIDsForBatch(batch []AfterSalesMessage) []string {
|
||||
result := make([]string, 0, len(batch))
|
||||
for _, msg := range batch {
|
||||
result = append(result, msg.MessageID)
|
||||
}
|
||||
return uniqueNonEmptyStrings(result)
|
||||
}
|
||||
|
||||
func uniqueExistingImagePaths(items []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[item]; exists {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(item); err == nil {
|
||||
seen[item] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func afterSalesFingerprint(conversationID string, customerUserID string, content string) string {
|
||||
normalized := normalizeAfterSalesIssueText(content)
|
||||
raw := strings.Join([]string{strings.TrimSpace(conversationID), strings.TrimSpace(customerUserID), normalized}, "|")
|
||||
sum := sha1.Sum([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func normalizeAfterSalesIssueText(text string) string {
|
||||
text = strings.ToLower(strings.TrimSpace(text))
|
||||
replacer := strings.NewReplacer("\r", "", "\n", "", "\t", "", " ", "", ",", ",", "。", ".", "?", "?", "!", "!")
|
||||
return replacer.Replace(text)
|
||||
}
|
||||
|
||||
func stableAfterSalesMessageID(clientID int32, msg autoReplyMessage, imagePath string, imageRef string) string {
|
||||
raw := fmt.Sprintf("%d|%s|%s|%s|%s|%s|%s|%s|%s", clientID, msg.ConversationID, msg.SendTime, msg.FromWxID, msg.Content, imagePath, imageRef, msg.MediaFileName, msg.MediaFileID)
|
||||
sum := sha1.Sum([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func newAfterSalesID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
func parseAfterSalesSendTime(raw string) int64 {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
var n int64
|
||||
if err := json.Unmarshal([]byte(raw), &n); err == nil {
|
||||
if n > 1000000000000 {
|
||||
n = n / 1000
|
||||
}
|
||||
return n
|
||||
}
|
||||
if _, err := fmt.Sscanf(raw, "%d", &n); err == nil {
|
||||
if n > 1000000000000 {
|
||||
n = n / 1000
|
||||
}
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user