342 lines
11 KiB
Go
342 lines
11 KiB
Go
package main
|
||
|
||
import (
|
||
"crypto/sha1"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
var (
|
||
afterSalesHistoryHeaderPatterns = []*regexp.Regexp{
|
||
regexp.MustCompile(`^(.{1,40}?)\s+(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}(?:日)?\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
|
||
regexp.MustCompile(`^(.{1,40}?)\s+(\d{1,2}[-/月]\d{1,2}(?:日)?\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
|
||
regexp.MustCompile(`^(.{1,40}?)\s+((?:今天|昨天|前天)\s+\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
|
||
regexp.MustCompile(`^(.{1,40}?)\s+(\d{1,2}:\d{2}(?::\d{2})?)\s*(.*)$`),
|
||
regexp.MustCompile(`^(.{1,40}?)[::]\s*(.*)$`),
|
||
}
|
||
afterSalesHistorySystemLine = regexp.MustCompile(`^(你|您)?(修改群名为|撤回了一条消息|加入群聊|退出群聊|邀请|移出|已读|以下为新消息)`)
|
||
)
|
||
|
||
type afterSalesHistoryParsedLine struct {
|
||
Sender string
|
||
Content string
|
||
SendAt int64
|
||
}
|
||
|
||
type afterSalesHistoryCollectResult struct {
|
||
Imported int
|
||
Segments int
|
||
Added int
|
||
}
|
||
|
||
func (e *AfterSalesIssueEngine) importHistoryAndCollect(req AfterSalesHistoryImportRequest) (int, int, error) {
|
||
result, err := e.importHistoryAndCollectDetailed(req)
|
||
return result.Imported, result.Added, err
|
||
}
|
||
|
||
func (e *AfterSalesIssueEngine) importHistoryAndCollectDetailed(req AfterSalesHistoryImportRequest) (afterSalesHistoryCollectResult, error) {
|
||
conversationID := normalizeAfterSalesCollectConversationID(req.ConversationID)
|
||
roomName := strings.TrimSpace(req.RoomName)
|
||
if conversationID == "" {
|
||
return afterSalesHistoryCollectResult{}, fmt.Errorf("请选择要导入的群聊")
|
||
}
|
||
if roomName == "" {
|
||
roomName = getAutoReplyEngine().ResolveGroupName(conversationID)
|
||
}
|
||
if roomName == "" {
|
||
roomName = conversationID
|
||
}
|
||
rawText := strings.TrimSpace(req.RawText)
|
||
if rawText == "" {
|
||
return afterSalesHistoryCollectResult{}, fmt.Errorf("请先粘贴企微群聊历史消息")
|
||
}
|
||
messages := parseAfterSalesHistoryMessages(conversationID, roomName, rawText, time.Now())
|
||
if len(messages) == 0 {
|
||
return afterSalesHistoryCollectResult{}, fmt.Errorf("未能从粘贴内容中解析出可导入的历史消息")
|
||
}
|
||
|
||
imported := e.mergeHistoryMessages(messages)
|
||
if imported == 0 {
|
||
return afterSalesHistoryCollectResult{}, nil
|
||
}
|
||
added, segments, err := e.collectHistoryMessages(conversationID, messages)
|
||
e.mu.Lock()
|
||
e.state.LastAddedCount = added
|
||
e.state.LastCollectedAt = time.Now().Unix()
|
||
if err != nil {
|
||
e.state.LastError = err.Error()
|
||
} else {
|
||
e.state.LastError = ""
|
||
e.repairIssuesLocked()
|
||
}
|
||
e.updateStateMessageCountLocked()
|
||
_ = e.saveStateLocked()
|
||
e.mu.Unlock()
|
||
return afterSalesHistoryCollectResult{Imported: imported, Segments: segments, Added: added}, err
|
||
}
|
||
|
||
func (e *AfterSalesIssueEngine) mergeHistoryMessages(messages []AfterSalesMessage) int {
|
||
now := time.Now()
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
existing := make(map[string]int, len(e.messages))
|
||
for i, msg := range e.messages {
|
||
if strings.TrimSpace(msg.MessageID) != "" {
|
||
existing[msg.MessageID] = i
|
||
}
|
||
}
|
||
imported := 0
|
||
for _, msg := range messages {
|
||
if msg.MessageID == "" {
|
||
continue
|
||
}
|
||
if idx, ok := existing[msg.MessageID]; ok {
|
||
e.messages[idx] = msg
|
||
continue
|
||
}
|
||
e.messages = append(e.messages, msg)
|
||
existing[msg.MessageID] = len(e.messages) - 1
|
||
imported++
|
||
}
|
||
e.trimMessagesLocked(now)
|
||
e.updateStateMessageCountLocked()
|
||
if imported > 0 {
|
||
_ = e.saveMessagesLocked()
|
||
_ = e.saveStateLocked()
|
||
}
|
||
return imported
|
||
}
|
||
|
||
func (e *AfterSalesIssueEngine) collectHistoryMessages(conversationID string, importedMessages []AfterSalesMessage) (int, int, error) {
|
||
e.mu.Lock()
|
||
issues := append([]AfterSalesIssue(nil), e.issues...)
|
||
e.mu.Unlock()
|
||
cfg := getAutoReplyEngine().getConfig()
|
||
if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" {
|
||
return 0, 0, fmt.Errorf("AI 配置未完整,无法收集售后问题")
|
||
}
|
||
existingFingerprints := make(map[string]AfterSalesIssue)
|
||
for _, issue := range issues {
|
||
if issue.Fingerprint != "" {
|
||
existingFingerprints[issue.Fingerprint] = issue
|
||
}
|
||
}
|
||
segments := segmentAfterSalesHistoryMessages(importedMessages)
|
||
added := 0
|
||
for _, segment := range segments {
|
||
if len(segment) == 0 {
|
||
continue
|
||
}
|
||
candidates, err := afterSalesAICollector(cfg.AI, segment)
|
||
if err != nil {
|
||
return added, len(segments), err
|
||
}
|
||
added += e.mergeAIIssueCandidates(candidates, segment, existingFingerprints)
|
||
}
|
||
return added, len(segments), nil
|
||
}
|
||
|
||
func segmentAfterSalesHistoryMessages(messages []AfterSalesMessage) [][]AfterSalesMessage {
|
||
var segments [][]AfterSalesMessage
|
||
for _, msg := range messages {
|
||
if !historyMessageLooksLikeIssue(msg) {
|
||
continue
|
||
}
|
||
segments = append(segments, []AfterSalesMessage{msg})
|
||
}
|
||
if len(segments) == 0 && len(messages) > 0 {
|
||
return batchAfterSalesMessages(messages, 6)
|
||
}
|
||
return segments
|
||
}
|
||
|
||
func historyMessageLooksLikeIssue(msg AfterSalesMessage) bool {
|
||
content := strings.TrimSpace(msg.Content)
|
||
if content == "" {
|
||
return false
|
||
}
|
||
if msg.SenderIdentity == senderIdentityInternal {
|
||
return false
|
||
}
|
||
lower := strings.ToLower(content)
|
||
noise := []string{"你好", "谢谢", "收到", "ok", "好的", "辛苦", "师傅好"}
|
||
for _, item := range noise {
|
||
if lower == strings.ToLower(item) {
|
||
return false
|
||
}
|
||
}
|
||
keywords := []string{
|
||
"坏", "故障", "报错", "不", "没", "无法", "不能", "失败", "异常", "卡", "掉线", "没电", "电压", "短接", "测试", "维修", "更换", "装不上", "启动", "低于",
|
||
}
|
||
for _, keyword := range keywords {
|
||
if strings.Contains(content, keyword) {
|
||
return true
|
||
}
|
||
}
|
||
return len([]rune(content)) >= 18
|
||
}
|
||
|
||
func parseAfterSalesHistoryMessages(conversationID, roomName, rawText string, importedAt time.Time) []AfterSalesMessage {
|
||
lines := strings.Split(strings.ReplaceAll(rawText, "\r\n", "\n"), "\n")
|
||
parsed := make([]afterSalesHistoryParsedLine, 0)
|
||
var current *afterSalesHistoryParsedLine
|
||
for _, line := range lines {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" || afterSalesHistorySystemLine.MatchString(line) {
|
||
continue
|
||
}
|
||
next, ok := parseAfterSalesHistoryLine(line, importedAt)
|
||
if ok {
|
||
if current != nil {
|
||
parsed = append(parsed, *current)
|
||
}
|
||
current = &next
|
||
continue
|
||
}
|
||
if current == nil {
|
||
current = &afterSalesHistoryParsedLine{Sender: "未知客户", SendAt: importedAt.Unix(), Content: line}
|
||
} else {
|
||
current.Content = strings.TrimSpace(current.Content + "\n" + line)
|
||
}
|
||
}
|
||
if current != nil {
|
||
parsed = append(parsed, *current)
|
||
}
|
||
|
||
messages := make([]AfterSalesMessage, 0, len(parsed))
|
||
for i, item := range parsed {
|
||
content := strings.TrimSpace(item.Content)
|
||
if content == "" {
|
||
continue
|
||
}
|
||
messageType := "text"
|
||
imageRef := ""
|
||
if looksLikeHistoryImageText(content) {
|
||
messageType = "image"
|
||
imageRef = content
|
||
}
|
||
sendAt := item.SendAt
|
||
if sendAt <= 0 {
|
||
sendAt = importedAt.Unix()
|
||
}
|
||
senderName := cleanAfterSalesHistorySender(item.Sender)
|
||
if senderName == "" {
|
||
senderName = "未知客户"
|
||
}
|
||
messages = append(messages, AfterSalesMessage{
|
||
MessageID: stableAfterSalesHistoryMessageID(conversationID, senderName, content, sendAt, i),
|
||
ClientID: 0,
|
||
ConversationID: conversationID,
|
||
RoomName: roomName,
|
||
SenderUserID: "history:" + senderName,
|
||
SenderName: senderName,
|
||
SenderIdentity: inferAfterSalesHistorySenderIdentity(senderName, content),
|
||
Content: content,
|
||
MessageType: messageType,
|
||
ImageRef: imageRef,
|
||
SendTime: sendAt,
|
||
ReceivedAt: importedAt.Unix(),
|
||
})
|
||
}
|
||
return messages
|
||
}
|
||
|
||
func parseAfterSalesHistoryLine(line string, base time.Time) (afterSalesHistoryParsedLine, bool) {
|
||
for idx, pattern := range afterSalesHistoryHeaderPatterns {
|
||
match := pattern.FindStringSubmatch(line)
|
||
if len(match) == 0 {
|
||
continue
|
||
}
|
||
sender := cleanAfterSalesHistorySender(match[1])
|
||
if sender == "" || looksLikeHistoryTime(sender) {
|
||
continue
|
||
}
|
||
if idx == 4 {
|
||
return afterSalesHistoryParsedLine{Sender: sender, Content: strings.TrimSpace(match[2]), SendAt: base.Unix()}, true
|
||
}
|
||
return afterSalesHistoryParsedLine{Sender: sender, SendAt: parseAfterSalesHistoryTime(match[2], base), Content: strings.TrimSpace(match[3])}, true
|
||
}
|
||
return afterSalesHistoryParsedLine{}, false
|
||
}
|
||
|
||
func parseAfterSalesHistoryTime(text string, base time.Time) int64 {
|
||
text = strings.TrimSpace(text)
|
||
replacements := map[string]string{"年": "-", "月": "-", "日": "", "/": "-"}
|
||
for old, next := range replacements {
|
||
text = strings.ReplaceAll(text, old, next)
|
||
}
|
||
if regexp.MustCompile(`^\d{1,2}-\d{1,2}\s+`).MatchString(text) {
|
||
text = fmt.Sprintf("%d-%s", base.Year(), text)
|
||
}
|
||
formats := []string{
|
||
"2006-1-2 15:04:05",
|
||
"2006-1-2 15:04",
|
||
}
|
||
for _, format := range formats {
|
||
if ts, err := time.ParseInLocation(format, text, time.Local); err == nil {
|
||
return ts.Unix()
|
||
}
|
||
}
|
||
day := time.Date(base.Year(), base.Month(), base.Day(), 0, 0, 0, 0, base.Location())
|
||
switch {
|
||
case strings.HasPrefix(text, "昨天"):
|
||
day = day.AddDate(0, 0, -1)
|
||
text = strings.TrimSpace(strings.TrimPrefix(text, "昨天"))
|
||
case strings.HasPrefix(text, "前天"):
|
||
day = day.AddDate(0, 0, -2)
|
||
text = strings.TrimSpace(strings.TrimPrefix(text, "前天"))
|
||
case strings.HasPrefix(text, "今天"):
|
||
text = strings.TrimSpace(strings.TrimPrefix(text, "今天"))
|
||
}
|
||
if clock, err := time.ParseInLocation("15:04:05", text, base.Location()); err == nil {
|
||
return time.Date(day.Year(), day.Month(), day.Day(), clock.Hour(), clock.Minute(), clock.Second(), 0, base.Location()).Unix()
|
||
}
|
||
if clock, err := time.ParseInLocation("15:04", text, base.Location()); err == nil {
|
||
return time.Date(day.Year(), day.Month(), day.Day(), clock.Hour(), clock.Minute(), 0, 0, base.Location()).Unix()
|
||
}
|
||
return base.Unix()
|
||
}
|
||
|
||
func cleanAfterSalesHistorySender(sender string) string {
|
||
sender = strings.TrimSpace(sender)
|
||
sender = strings.TrimPrefix(sender, "@")
|
||
sender = strings.ReplaceAll(sender, "未命名企业", "")
|
||
sender = regexp.MustCompile(`\s+`).ReplaceAllString(sender, " ")
|
||
return strings.TrimSpace(sender)
|
||
}
|
||
|
||
func inferAfterSalesHistorySenderIdentity(senderName string, content string) string {
|
||
text := senderName + " " + content
|
||
internalHints := []string{"郑工", "梁师傅", "师傅", "工程师", "客服", "售后"}
|
||
for _, hint := range internalHints {
|
||
if strings.Contains(text, hint) && strings.Contains(content, "按") {
|
||
return senderIdentityInternal
|
||
}
|
||
}
|
||
return senderIdentityExternal
|
||
}
|
||
|
||
func looksLikeHistoryTime(text string) bool {
|
||
return regexp.MustCompile(`^\d{1,4}[-/:年月日\s]`).MatchString(strings.TrimSpace(text))
|
||
}
|
||
|
||
func looksLikeHistoryImageText(content string) bool {
|
||
content = strings.TrimSpace(strings.Trim(content, "[]【】"))
|
||
return content == "图片" || strings.EqualFold(content, "image")
|
||
}
|
||
|
||
func stableAfterSalesHistoryMessageID(conversationID, senderName, content string, sendAt int64, index int) string {
|
||
sum := sha1.Sum([]byte(fmt.Sprintf("history|%s|%s|%d|%d|%s", conversationID, senderName, sendAt, index, content)))
|
||
return "history:" + hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func afterSalesHistoryImportMessage(imported, segments, added int) string {
|
||
if imported == 0 {
|
||
return "历史消息已存在,没有新增导入"
|
||
}
|
||
return fmt.Sprintf("已同步 %d 条历史消息,分析 %d 段,新增 %d 条售后问题", imported, segments, added)
|
||
}
|