- 将默认回复详细程度从"detailed"调整为"medium",前后端保持一致 - 新增话题切换检测逻辑,当用户主动要求换话题时提供引导回复 - 优化上下文处理机制,仅在指代型追问时注入历史对话,避免模型复读旧内容 - 改进知识库检索逻辑,区分自包含问题和指代型问题的上下文需求 - 完善知识库完整性指令,确保回复详细程度与知识展开程度一致 - 重构知识库重建逻辑,支持递归扫描子目录中的文件,修复索引为空的问题 - 增强素材匹配算法,引入强信号检测机制,避免仅凭模糊匹配误发素材 - 新增素材开场白AI生成功能,支持图片、视频、文档等类型智能描述 - 改进知识库重建通知,显示具体的文件数、分片数及失败统计信息
1682 lines
53 KiB
Go
1682 lines
53 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"qiweimanager/config"
|
||
)
|
||
|
||
type autoReplyMessage struct {
|
||
ClientID int32
|
||
RobotID string
|
||
ConversationID string
|
||
GroupName string
|
||
FromWxID string
|
||
ToWxID string
|
||
FromNickName string
|
||
Content string
|
||
LocalID string
|
||
ServerID string
|
||
SendTime string
|
||
MessageTime string
|
||
AtList []string
|
||
IsGroup bool
|
||
MessageType string
|
||
MediaURL string
|
||
MediaLocalPath string
|
||
MediaKind string
|
||
MediaFileID string
|
||
MediaAESKey string
|
||
MediaAuthKey string
|
||
MediaFileName string
|
||
MediaFileType int
|
||
MediaSize int64
|
||
VoiceText string
|
||
ContextText string
|
||
RawType int
|
||
SenderIdentity string
|
||
IdentitySource string
|
||
}
|
||
|
||
func enqueueAutoReplyEvent(clientID int32, responseData map[string]interface{}) {
|
||
engine := getAutoReplyEngine()
|
||
engine.observeGroupNames(clientID, responseData)
|
||
if engine.observeIdentityContacts(clientID, responseData) {
|
||
return
|
||
}
|
||
cfg := engine.getConfig()
|
||
if !cfg.Enabled {
|
||
return
|
||
}
|
||
select {
|
||
case engine.queue <- AutoReplyJob{ClientID: clientID, RawData: responseData, ReceivedAt: time.Now()}:
|
||
default:
|
||
engine.incStatus("ignored")
|
||
engine.setLastErrorWithScope(autoReplyErrorScopeRecords, "自动客服队列已满,消息已忽略")
|
||
}
|
||
}
|
||
|
||
func (e *AutoReplyEngine) worker() {
|
||
for job := range e.queue {
|
||
func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, fmt.Sprintf("自动客服处理panic: %v", r))
|
||
}
|
||
}()
|
||
e.processJob(job)
|
||
}()
|
||
}
|
||
}
|
||
|
||
func (e *AutoReplyEngine) processJob(job AutoReplyJob) {
|
||
started := time.Now()
|
||
timings := autoReplyTimings{}
|
||
defer func() {
|
||
timings.TotalDurationMS = time.Since(started).Milliseconds()
|
||
e.setLastDurations(timings)
|
||
}()
|
||
currentTimings := func() autoReplyTimings {
|
||
current := timings
|
||
current.TotalDurationMS = time.Since(started).Milliseconds()
|
||
return current
|
||
}
|
||
cfg := e.getConfig()
|
||
if !cfg.Enabled {
|
||
return
|
||
}
|
||
msg := extractAutoReplyMessage(job.ClientID, job.RawData)
|
||
e.enrichAutoReplyMessage(&msg, job.ReceivedAt)
|
||
if !isAutoReplyMessageEvent(msg) {
|
||
e.ignoreMessage(msg, "non_message_event")
|
||
return
|
||
}
|
||
if msg.ConversationID == "" {
|
||
e.ignoreMessage(msg, "missing_conversation_id")
|
||
return
|
||
}
|
||
e.incStatus("received")
|
||
if e.isStartupStaleMessage(msg, job.ReceivedAt) {
|
||
e.ignoreMessage(msg, "startup_stale_message")
|
||
return
|
||
}
|
||
if e.isHandoffConversation(msg, cfg) {
|
||
e.ignoreMessage(msg, "handoff_conversation")
|
||
return
|
||
}
|
||
if msg.isSelfMessage() {
|
||
if e.observeCollaborationHumanReply(msg) {
|
||
e.rememberAssistantMessage(msg, msg.Content)
|
||
e.ignoreMessage(msg, "collaboration_human_reply_observed")
|
||
return
|
||
}
|
||
e.ignoreMessage(msg, "self_message_echo")
|
||
return
|
||
}
|
||
e.observeMessageIdentity(msg)
|
||
identity := e.classifySenderIdentity(msg)
|
||
msg.SenderIdentity = identity.Kind
|
||
msg.IdentitySource = identity.Source
|
||
if name := e.displayNameForMessage(msg); name != "" {
|
||
msg.FromNickName = name
|
||
}
|
||
if identity.Source == identitySourceUnknownAsCustomer {
|
||
e.noteReason(identitySourceUnknownAsCustomer)
|
||
if !msg.IsGroup {
|
||
e.startUnknownIdentityLookup(msg, "identity_unknown_background")
|
||
}
|
||
}
|
||
if identity.Source == identitySourceUnknownIgnored {
|
||
e.ignoreMessage(msg, identitySourceUnknownIgnored)
|
||
return
|
||
}
|
||
if msg.IsGroup {
|
||
if !cfg.Listen.EnableGroupChat {
|
||
e.ignoreMessage(msg, "group_disabled")
|
||
return
|
||
}
|
||
if cfg.Listen.GroupTriggerMode == "mention_only" && !e.messageMentionsRobot(msg) {
|
||
e.ignoreMessage(msg, "group_without_mention")
|
||
return
|
||
}
|
||
} else if !cfg.Listen.EnablePrivateChat {
|
||
e.ignoreMessage(msg, "private_disabled")
|
||
return
|
||
}
|
||
if !job.SkipHumanAssist && e.isDuplicate(msg) {
|
||
e.ignoreMessage(msg, "duplicate")
|
||
return
|
||
}
|
||
if e.maybeHandleCollaborationCustomer(job, msg) {
|
||
e.ignoreMessage(msg, "collaboration_waiting_human")
|
||
return
|
||
}
|
||
collaborationTakeover := e.isCollaborationTakeoverMessage(msg)
|
||
mediaPrepared := false
|
||
if msg.RawType != 11041 {
|
||
mediaStarted := time.Now()
|
||
if err := e.prepareMediaMessage(&msg); err != nil {
|
||
timings.AIDurationMS += time.Since(mediaStarted).Milliseconds()
|
||
if strings.TrimSpace(msg.Content) == "" {
|
||
msg.Content = nonTextMessageDescription(msg)
|
||
}
|
||
e.rememberUserMessage(msg)
|
||
e.setLastErrorWithScope(autoReplyErrorScopeAI, "media recognition failed: "+err.Error())
|
||
if err := e.replyTextWithTimings(msg, mediaRecognitionFallbackAnswer(msg), "media_recognition_failed: "+err.Error(), nil, currentTimings()); err != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "media fallback reply failed: "+err.Error())
|
||
}
|
||
return
|
||
}
|
||
timings.AIDurationMS += time.Since(mediaStarted).Milliseconds()
|
||
if strings.TrimSpace(msg.Content) == "" {
|
||
msg.Content = nonTextMessageDescription(msg)
|
||
}
|
||
mediaPrepared = true
|
||
}
|
||
if strings.TrimSpace(msg.Content) == "" {
|
||
e.ignoreMessage(msg, "empty_message")
|
||
return
|
||
}
|
||
if isPreviousQuestionQuery(msg.Content) {
|
||
answer := previousQuestionAnswer(e.previousUserQuestion(msg))
|
||
e.rememberUserMessage(msg)
|
||
if err := e.replyTextWithTimings(msg, answer, "context_previous_question_replied", nil, currentTimings()); err != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "context reply send failed: "+err.Error())
|
||
}
|
||
return
|
||
}
|
||
e.rememberUserMessage(msg)
|
||
if isPureTopicSwitchMessage(msg.Content) {
|
||
if err := e.replyTextWithTimings(msg, topicSwitchGuidanceAnswer(), "topic_switch_guidance", nil, currentTimings()); err != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "topic switch reply failed: "+err.Error())
|
||
}
|
||
return
|
||
}
|
||
if answer, ok := greetingAnswer(msg.Content); ok {
|
||
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, answer); err != nil {
|
||
e.handoffWithTimings(msg, "send_greeting_failed: "+err.Error(), nil, currentTimings())
|
||
return
|
||
}
|
||
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, answer)
|
||
e.markCooldown(msg)
|
||
e.rememberAssistantMessage(msg, answer)
|
||
e.incStatus("replied")
|
||
e.noteReason("greeting_replied")
|
||
e.addRecord(AutoReplyRecord{
|
||
RobotID: msg.RobotID,
|
||
ClientID: msg.ClientID,
|
||
UserID: msg.RobotID,
|
||
ConversationID: msg.ConversationID,
|
||
Source: msg.sourceLabel(),
|
||
FromWxID: msg.FromWxID,
|
||
FromNickName: msg.FromNickName,
|
||
Question: msg.Content,
|
||
Action: "replied",
|
||
Reason: "greeting_replied",
|
||
Answer: answer,
|
||
SenderIdentity: msg.SenderIdentity,
|
||
IdentitySource: msg.IdentitySource,
|
||
TotalDurationMS: time.Since(started).Milliseconds(),
|
||
})
|
||
return
|
||
}
|
||
if answer, ok := companyIdentityAnswer(msg.Content, cfg); ok {
|
||
if err := e.replyTextWithTimings(msg, answer, "company_identity_replied", nil, currentTimings()); err != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "company identity reply failed: "+err.Error())
|
||
}
|
||
return
|
||
}
|
||
if answer, ok := e.quickConversationAnswerForMessage(msg); ok {
|
||
if err := e.replyTextWithTimings(msg, answer, "quick_conversation_replied", nil, currentTimings()); err != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "快捷回复发送失败: "+err.Error())
|
||
}
|
||
return
|
||
}
|
||
if cfg.ReplyPolicy.MaxQuestionLength > 0 && len([]rune(msg.Content)) > cfg.ReplyPolicy.MaxQuestionLength {
|
||
if err := e.replyTextWithTimings(msg, "您这条问题有点长,麻烦拆成一两个具体问题发我,我会逐条帮您看。", "question_too_long_no_handoff", nil, currentTimings()); err != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "长问题提示发送失败: "+err.Error())
|
||
}
|
||
return
|
||
}
|
||
if reason := e.manualHandoffReason(msg.Content); reason != "" {
|
||
e.handoffWithTimings(msg, reason, nil, currentTimings())
|
||
return
|
||
}
|
||
if reason := trivialAutoReplyMessageReason(msg.Content); reason != "" {
|
||
e.ignoreMessage(msg, reason)
|
||
return
|
||
}
|
||
if !job.ForceNoCooldown && !collaborationTakeover && !mediaPrepared && e.inCooldown(msg) {
|
||
e.ignoreMessage(msg, "cooldown")
|
||
return
|
||
}
|
||
knowledgeStarted := time.Now()
|
||
searchText := e.contextualSearchText(msg.Content, msg)
|
||
searchResult := e.searchKnowledgeDetailed(searchText)
|
||
hits := searchResult.Hits
|
||
materialMatches := e.matchMaterials(msg.Content, searchText, hits)
|
||
timings.KeywordDurationMS = searchResult.Timings.KeywordDurationMS
|
||
timings.VectorDurationMS = searchResult.Timings.VectorDurationMS
|
||
timings.RerankDurationMS = searchResult.Timings.RerankDurationMS
|
||
timings.KnowledgeDurationMS = searchResult.Timings.KnowledgeDurationMS
|
||
if timings.KnowledgeDurationMS <= 0 {
|
||
timings.KnowledgeDurationMS = time.Since(knowledgeStarted).Milliseconds()
|
||
}
|
||
e.setLastRetrievalScores(searchResult.KeywordScore, searchResult.VectorScore, searchResult.RerankScore)
|
||
if len(materialMatches) > 0 {
|
||
if err := e.sendMaterials(msg, materialMatches, "materials_replied", withSearchMetadata(currentTimings(), searchResult)); err != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "material send failed: "+err.Error())
|
||
}
|
||
return
|
||
}
|
||
if isBroadAllMaterialRequest(msg.Content) || isGenericMaterialRequest(msg.Content) {
|
||
if err := e.replyTextWithTimings(msg, materialClarificationAnswer(), "material_request_needs_clarification", hits, withSearchMetadata(currentTimings(), searchResult)); err != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "material clarification reply failed: "+err.Error())
|
||
}
|
||
return
|
||
}
|
||
if len(hits) == 0 || hits[0].Score < cfg.Knowledge.MinScore {
|
||
if isKnowledgeScopedQuestion(searchText) {
|
||
e.replyKnowledgeNoAnswerWithTimings(msg, "knowledge_no_answer_low_score", hits, withSearchMetadata(currentTimings(), searchResult))
|
||
return
|
||
}
|
||
e.replyGeneralWithTimings(msg, "general_reply_low_knowledge", hits, withSearchMetadata(currentTimings(), searchResult))
|
||
return
|
||
}
|
||
aiStarted := time.Now()
|
||
aiResult, err := e.askAI(msg.Content, hits, msg)
|
||
timings.AIDurationMS = time.Since(aiStarted).Milliseconds()
|
||
if aiResult != nil && aiResult.DurationMS > 0 {
|
||
timings.AIDurationMS = aiResult.DurationMS
|
||
}
|
||
if err != nil {
|
||
e.incStatus("ai_failed")
|
||
e.setLastErrorWithScope(autoReplyErrorScopeAI, "AI请求失败: "+err.Error())
|
||
if isKnowledgeScopedQuestion(searchText) {
|
||
e.replyKnowledgeNoAnswerWithTimings(msg, "knowledge_no_answer_ai_error", hits, withSearchMetadata(currentTimings(), searchResult))
|
||
return
|
||
}
|
||
e.replyGeneralWithTimings(msg, "general_reply_after_knowledge_ai_error", hits, withSearchMetadata(currentTimings(), searchResult))
|
||
return
|
||
}
|
||
answer := strings.TrimSpace(aiResult.Answer)
|
||
if answer == "" || isNoAnswer(answer, cfg.ReplyPolicy.UnknownAnswerToken) {
|
||
e.incStatus("ai_failed")
|
||
if isKnowledgeScopedQuestion(searchText) {
|
||
e.replyKnowledgeNoAnswerWithTimings(msg, "knowledge_no_answer_ai_no_answer", hits, withSearchMetadata(currentTimings(), searchResult))
|
||
return
|
||
}
|
||
e.replyGeneralWithTimings(msg, "general_reply_after_knowledge_no_answer", hits, withSearchMetadata(currentTimings(), searchResult))
|
||
return
|
||
}
|
||
if err := e.replyTextWithTimings(msg, answer, "ok", hits, withSearchMetadata(currentTimings(), searchResult)); err != nil {
|
||
e.handoffWithTimings(msg, "send_reply_failed: "+err.Error(), hits, withSearchMetadata(currentTimings(), searchResult))
|
||
return
|
||
}
|
||
}
|
||
|
||
func extractAutoReplyMessage(clientID int32, raw map[string]interface{}) autoReplyMessage {
|
||
msg := autoReplyMessage{ClientID: clientID}
|
||
msg.RobotID = runtimeRobotID(uint32(clientID))
|
||
if typeVal, ok := raw["type"]; ok {
|
||
msg.RawType = intFromAny(typeVal)
|
||
}
|
||
if msg.RawType == 0 {
|
||
msg.RawType = rawTypeFromEvent(raw)
|
||
}
|
||
if data, ok := raw["data"].(map[string]interface{}); ok {
|
||
msg.ConversationID = firstNonEmptyString(data["conversation_id"], data["conversationId"], data["room_conversation_id"])
|
||
msg.GroupName = firstNonEmptyString(data["room_name"], data["roomName"], data["group_name"], data["groupName"], data["conversation_name"], data["conversationName"])
|
||
msg.Content = firstNonEmptyString(data["content"], data["message"], data["text"], data["title"], data["desc"], data["address"])
|
||
msg.FromWxID = firstNonEmptyString(data["sender"], data["fromWxId"])
|
||
msg.ToWxID = firstNonEmptyString(data["receiver"], data["toWxId"])
|
||
msg.FromNickName = firstNonEmptyString(data["sender_name"], data["fromNickName"])
|
||
msg.LocalID = firstNonEmptyString(data["local_id"], data["localId"])
|
||
msg.ServerID = firstNonEmptyString(data["server_id"], data["serverId"])
|
||
msg.SendTime = firstNonEmptyString(data["send_time"], data["sendTime"], data["create_time"])
|
||
msg.MessageTime = formatAutoReplyMessageTime(msg.SendTime)
|
||
msg.AtList = stringSliceFromAny(firstNonNil(data["at_list"], data["atWxIdList"]))
|
||
msg.MediaURL = firstMediaURLFromValue(data)
|
||
msg.MediaLocalPath = firstLocalMediaPathFromValue(data)
|
||
msg.MediaKind = firstNonEmptyString(data["media_kind"], data["mediaKind"])
|
||
if msg.MediaKind == "" {
|
||
msg.MediaKind = mediaKindForRawType(msg.RawType)
|
||
}
|
||
fillMediaFieldsFromValue(&msg, data)
|
||
msg.VoiceText = firstVoiceTextFromValue(data)
|
||
msg.RobotID = inferAutoReplyRobotID(clientID, msg.RobotID, msg.FromWxID, msg.ToWxID)
|
||
}
|
||
msg.IsGroup = strings.HasPrefix(msg.ConversationID, "R:") || len(msg.AtList) > 0
|
||
if msg.RawType == 11041 {
|
||
msg.MessageType = "text"
|
||
} else {
|
||
msg.MessageType = "non_text"
|
||
}
|
||
logEmptyMediaDiagnostics(msg, raw)
|
||
return msg
|
||
}
|
||
|
||
// logEmptyMediaDiagnostics 仅在"图片/视频/表情等非文本媒体消息,且 URL/本地路径/FileID 三个来源全空"
|
||
// 时触发,把媒体相关字段(已脱敏)打到日志,用于定位企微 DLL 回调实际下发了哪些字段。
|
||
// 文本消息(11041)与字段已成功填充的消息都不会触发,避免刷屏与泄露正常聊天内容。
|
||
func logEmptyMediaDiagnostics(msg autoReplyMessage, raw map[string]interface{}) {
|
||
if globalLogger == nil {
|
||
return
|
||
}
|
||
// 只关心需要拉取媒体文件来识别的类型;纯文本/位置等不在此列。
|
||
switch msg.RawType {
|
||
case 11042, 11043, 11047: // image / video / link(图片/表情)
|
||
default:
|
||
return
|
||
}
|
||
hasMediaSource := strings.TrimSpace(msg.MediaURL) != "" ||
|
||
strings.TrimSpace(msg.MediaLocalPath) != "" ||
|
||
strings.TrimSpace(msg.MediaFileID) != ""
|
||
if hasMediaSource {
|
||
return // 字段已填到,下载链路可以走,无需诊断
|
||
}
|
||
|
||
// 只提取诊断相关的字段,避免泄露用户昵称、群名、会话内容等敏感信息
|
||
diagnosticFields := make(map[string]interface{})
|
||
diagnosticFields["event"] = raw["event"]
|
||
diagnosticFields["type"] = raw["type"]
|
||
if data, ok := raw["data"].(map[string]interface{}); ok {
|
||
// 只记录媒体相关字段和类型标识
|
||
for _, key := range []string{"event", "content_type", "contentType", "messageType", "message_type",
|
||
"image_url", "imageUrl", "preview_img_url", "previewImgUrl",
|
||
"md_url", "mdUrl", "ld_url", "ldUrl", "url",
|
||
"file_id", "fileId", "local_path", "localPath",
|
||
"media_kind", "mediaKind", "aes_key", "aesKey", "auth_key", "authKey"} {
|
||
if val, exists := data[key]; exists && val != nil {
|
||
diagnosticFields[key] = val
|
||
}
|
||
}
|
||
}
|
||
|
||
diagJSON, err := json.Marshal(diagnosticFields)
|
||
if err != nil {
|
||
globalLogger.Warn("[媒体诊断] rawType=%d 媒体字段全空,且诊断数据序列化失败: %v", msg.RawType, err)
|
||
return
|
||
}
|
||
globalLogger.Warn("[媒体诊断] rawType=%d mediaKind=%s 媒体字段(URL/本地路径/FileID)全空,无法识别。诊断字段: %s",
|
||
msg.RawType, msg.MediaKind, string(diagJSON))
|
||
}
|
||
|
||
func rawTypeFromEvent(raw map[string]interface{}) int {
|
||
event := strings.TrimSpace(stringFromAny(raw["event"]))
|
||
if event == "" {
|
||
if data, ok := raw["data"].(map[string]interface{}); ok {
|
||
event = strings.TrimSpace(stringFromAny(data["event"]))
|
||
}
|
||
}
|
||
// 优先使用 event 字段(DLL 真实事件):20002=文本 20003=图片 20004=视频 20012=语音 20005=文件 20014=链接
|
||
switch event {
|
||
case "20002":
|
||
return 11041
|
||
case "20003":
|
||
return 11042
|
||
case "20004":
|
||
return 11043
|
||
case "20012":
|
||
return 11044
|
||
case "20005":
|
||
return 11045
|
||
case "20014":
|
||
return 11047
|
||
}
|
||
// event 为空时才用 content_type(模拟事件或老版本 DLL):2=文本 101=图片 103=视频 16=语音 102=文件 6=位置 13=链接
|
||
// 注意不能把文本(2)误判成图片,否则会触发图片识别并回退"无法识别"话术。
|
||
if data, ok := raw["data"].(map[string]interface{}); ok {
|
||
switch intFromAny(firstNonNil(data["content_type"], data["contentType"])) {
|
||
case 2:
|
||
return 11041
|
||
case 101:
|
||
return 11042
|
||
case 103:
|
||
return 11043
|
||
case 16:
|
||
return 11044
|
||
case 102:
|
||
return 11045
|
||
case 6:
|
||
return 11046
|
||
case 13:
|
||
return 11047
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func inferAutoReplyRobotID(clientID int32, currentRobotID string, fromWxID string, toWxID string) string {
|
||
currentRobotID = strings.TrimSpace(currentRobotID)
|
||
fromWxID = strings.TrimSpace(fromWxID)
|
||
toWxID = strings.TrimSpace(toWxID)
|
||
if currentRobotID != "" && !strings.HasPrefix(currentRobotID, "client:") {
|
||
return currentRobotID
|
||
}
|
||
knownAccounts := getIdentifiedUserIDSet()
|
||
if fromWxID != "" && knownAccounts[fromWxID] {
|
||
return fromWxID
|
||
}
|
||
if toWxID != "" && knownAccounts[toWxID] {
|
||
return toWxID
|
||
}
|
||
if currentRobotID != "" {
|
||
return currentRobotID
|
||
}
|
||
if clientID != 0 {
|
||
return fmt.Sprintf("client:%d", clientID)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func isAutoReplyMessageEvent(msg autoReplyMessage) bool {
|
||
switch msg.RawType {
|
||
case 11041, 11042, 11043, 11044, 11045, 11046, 11047:
|
||
return strings.TrimSpace(msg.SendTime) != "" ||
|
||
strings.TrimSpace(msg.ServerID) != "" ||
|
||
strings.TrimSpace(msg.LocalID) != ""
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (e *AutoReplyEngine) isStartupStaleMessage(msg autoReplyMessage, receivedAt time.Time) bool {
|
||
sendAt, ok := autoReplyMessageSendTime(msg)
|
||
if !ok {
|
||
return false
|
||
}
|
||
cutoff := e.autoReplyMessageFreshCutoff(msg.ClientID, receivedAt)
|
||
return !cutoff.IsZero() && sendAt.Before(cutoff)
|
||
}
|
||
|
||
func (e *AutoReplyEngine) autoReplyMessageFreshCutoff(clientID int32, receivedAt time.Time) time.Time {
|
||
const grace = 10 * time.Second
|
||
e.mu.Lock()
|
||
startedAt := e.startedAt
|
||
enabledAt := e.enabledAt
|
||
e.mu.Unlock()
|
||
cutoff := maxTime(startedAt, enabledAt, clientIdentifiedAt(uint32(clientID)), clientConnectedAt(uint32(clientID)))
|
||
if cutoff.IsZero() {
|
||
cutoff = receivedAt
|
||
}
|
||
if cutoff.IsZero() {
|
||
return time.Time{}
|
||
}
|
||
return cutoff.Add(-grace)
|
||
}
|
||
|
||
func autoReplyMessageSendTime(msg autoReplyMessage) (time.Time, bool) {
|
||
raw := strings.TrimSpace(msg.SendTime)
|
||
if raw == "" {
|
||
return time.Time{}, false
|
||
}
|
||
n, err := strconv.ParseInt(raw, 10, 64)
|
||
if err != nil || n <= 0 {
|
||
return time.Time{}, false
|
||
}
|
||
if n > 1000000000000 {
|
||
n = n / 1000
|
||
}
|
||
return time.Unix(n, 0), true
|
||
}
|
||
|
||
func maxTime(values ...time.Time) time.Time {
|
||
var result time.Time
|
||
for _, value := range values {
|
||
if value.IsZero() {
|
||
continue
|
||
}
|
||
if result.IsZero() || value.After(result) {
|
||
result = value
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func withSearchMetadata(timings autoReplyTimings, searchResult KnowledgeSearchResult) autoReplyTimings {
|
||
timings.KeywordScore = searchResult.KeywordScore
|
||
timings.VectorScore = searchResult.VectorScore
|
||
timings.RerankScore = searchResult.RerankScore
|
||
timings.RetrievalMode = searchResult.RetrievalMode
|
||
timings.UsedKnowledgeSources = append([]string(nil), searchResult.UsedKnowledgeSources...)
|
||
return timings
|
||
}
|
||
|
||
func (e *AutoReplyEngine) enrichAutoReplyMessage(msg *autoReplyMessage, receivedAt time.Time) {
|
||
if msg == nil {
|
||
return
|
||
}
|
||
if strings.TrimSpace(msg.MessageTime) == "" {
|
||
if receivedAt.IsZero() {
|
||
receivedAt = time.Now()
|
||
}
|
||
msg.MessageTime = receivedAt.Local().Format("2006-01-02 15:04:05")
|
||
}
|
||
if msg.ConversationID == "" {
|
||
return
|
||
}
|
||
if msg.GroupName != "" {
|
||
e.rememberGroupName(msg.ClientID, msg.ConversationID, msg.GroupName, 0)
|
||
return
|
||
}
|
||
if msg.IsGroup {
|
||
if cached := e.groupNameForConversation(msg.ConversationID); cached != "" {
|
||
msg.GroupName = cached
|
||
return
|
||
}
|
||
msg.GroupName = msg.ConversationID
|
||
e.noteReason("missing_group_name")
|
||
}
|
||
}
|
||
|
||
func (m autoReplyMessage) isSelfMessage() bool {
|
||
robotID := strings.TrimSpace(m.RobotID)
|
||
fromWxID := strings.TrimSpace(m.FromWxID)
|
||
if fromWxID == "" {
|
||
return false
|
||
}
|
||
if robotID != "" && !strings.HasPrefix(robotID, "client:") && fromWxID == robotID {
|
||
return true
|
||
}
|
||
return getIdentifiedUserIDSet()[fromWxID]
|
||
}
|
||
|
||
func (e *AutoReplyEngine) isHandoffConversation(msg autoReplyMessage, cfg config.AutoReplyConfig) bool {
|
||
conversationID := strings.TrimSpace(msg.ConversationID)
|
||
if conversationID == "" {
|
||
return false
|
||
}
|
||
humanConversationID := strings.TrimSpace(cfg.Handoff.HumanConversationID)
|
||
if humanConversationID != "" && sameConversationID(conversationID, humanConversationID) {
|
||
return true
|
||
}
|
||
humanID := strings.TrimSpace(cfg.Handoff.HumanUserID)
|
||
if humanID == "" {
|
||
return false
|
||
}
|
||
if conversationMatchesPair(conversationID, msg.RobotID, humanID) {
|
||
return true
|
||
}
|
||
if humanConversationID != "" && msg.RobotID != "" && conversationMatchesPair(humanConversationID, msg.RobotID, humanID) && sameConversationID(conversationID, humanConversationID) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func sameConversationID(a string, b string) bool {
|
||
return strings.TrimSpace(a) == strings.TrimSpace(b)
|
||
}
|
||
|
||
func conversationMatchesPair(conversationID string, leftID string, rightID string) bool {
|
||
conversationID = strings.TrimSpace(conversationID)
|
||
leftID = strings.TrimSpace(leftID)
|
||
rightID = strings.TrimSpace(rightID)
|
||
if conversationID == "" || leftID == "" || rightID == "" || strings.HasPrefix(leftID, "client:") {
|
||
return false
|
||
}
|
||
if !strings.HasPrefix(conversationID, "S:") {
|
||
return false
|
||
}
|
||
parts := strings.Split(strings.TrimPrefix(conversationID, "S:"), "_")
|
||
if len(parts) != 2 {
|
||
return false
|
||
}
|
||
return (parts[0] == leftID && parts[1] == rightID) || (parts[0] == rightID && parts[1] == leftID)
|
||
}
|
||
|
||
func (e *AutoReplyEngine) observeGroupNames(clientID int32, raw map[string]interface{}) {
|
||
if len(raw) == 0 {
|
||
return
|
||
}
|
||
e.observeGroupNamesFromValue(clientID, raw)
|
||
}
|
||
|
||
func (e *AutoReplyEngine) observeGroupNamesFromValue(clientID int32, value interface{}) {
|
||
switch v := value.(type) {
|
||
case map[string]interface{}:
|
||
conversationID := stringFromAny(firstNonNil(v["conversation_id"], v["conversationId"], v["room_conversation_id"], v["roomConversationId"], v["room_id"], v["roomId"]))
|
||
groupName := stringFromAny(firstNonNil(v["room_name"], v["roomName"], v["group_name"], v["groupName"], v["conversation_name"], v["conversationName"]))
|
||
if conversationID != "" && groupName != "" {
|
||
e.rememberGroupName(clientID, conversationID, groupName, identityGroupMemberCountFromMap(v))
|
||
}
|
||
for _, item := range v {
|
||
e.observeGroupNamesFromValue(clientID, item)
|
||
}
|
||
case []interface{}:
|
||
for _, item := range v {
|
||
e.observeGroupNamesFromValue(clientID, item)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (e *AutoReplyEngine) rememberGroupName(clientID int32, conversationID string, groupName string, memberCount int) {
|
||
conversationID = strings.TrimSpace(conversationID)
|
||
groupName = strings.TrimSpace(groupName)
|
||
if conversationID == "" || groupName == "" {
|
||
return
|
||
}
|
||
changed := false
|
||
e.mu.Lock()
|
||
if e.groupNames == nil {
|
||
e.groupNames = make(map[string]string)
|
||
}
|
||
if e.groupNames[conversationID] != groupName {
|
||
e.groupNames[conversationID] = groupName
|
||
changed = true
|
||
}
|
||
if strings.HasPrefix(conversationID, "R:") {
|
||
if e.identityGroups == nil {
|
||
e.identityGroups = make(map[int32]map[string]autoReplyGroupOption)
|
||
}
|
||
target := e.identityGroups[clientID]
|
||
if target == nil {
|
||
target = make(map[string]autoReplyGroupOption)
|
||
e.identityGroups[clientID] = target
|
||
}
|
||
current := target[conversationID]
|
||
if memberCount <= 0 {
|
||
memberCount = current.MemberCount
|
||
}
|
||
next := current
|
||
next.ConversationID = conversationID
|
||
next.Name = fallbackString(groupName, current.Name)
|
||
next.Source = fallbackString(current.Source, "observed_group_message")
|
||
next.ClientID = clientID
|
||
next.MemberCount = memberCount
|
||
if current.LastSeenAt <= 0 {
|
||
next.LastSeenAt = time.Now().Unix()
|
||
}
|
||
if current != next {
|
||
target[conversationID] = next
|
||
changed = true
|
||
}
|
||
e.status.IdentityGroupOptionCount = e.identityGroupOptionCountLocked()
|
||
}
|
||
e.mu.Unlock()
|
||
if changed {
|
||
e.saveIdentityCache()
|
||
}
|
||
}
|
||
|
||
func (e *AutoReplyEngine) ResolveGroupName(conversationID string) string {
|
||
return e.groupNameForConversation(conversationID)
|
||
}
|
||
|
||
func (e *AutoReplyEngine) groupNameForConversation(conversationID string) string {
|
||
conversationID = strings.TrimSpace(conversationID)
|
||
if conversationID == "" {
|
||
return ""
|
||
}
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
if name := strings.TrimSpace(e.groupNames[conversationID]); name != "" {
|
||
return name
|
||
}
|
||
for _, groups := range e.identityGroups {
|
||
if group, ok := groups[conversationID]; ok {
|
||
if name := strings.TrimSpace(group.Name); name != "" {
|
||
return name
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (e *AutoReplyEngine) messageMentionsRobot(m autoReplyMessage) bool {
|
||
for _, item := range m.AtList {
|
||
if item != "" && (item == m.RobotID || item == m.ToWxID) {
|
||
return true
|
||
}
|
||
}
|
||
for _, name := range e.robotMentionNames(m) {
|
||
if mentionTextContainsName(m.Content, name) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (e *AutoReplyEngine) robotMentionNames(m autoReplyMessage) []string {
|
||
names := []string{m.RobotID, m.ToWxID}
|
||
clientID := uint32(m.ClientID)
|
||
if clientID != 0 {
|
||
names = append(names, getClientUserID(clientID))
|
||
}
|
||
if e != nil {
|
||
e.mu.Lock()
|
||
names = append(names, e.accountNames[m.ClientID]...)
|
||
if cache := e.identityCaches[m.ClientID]; cache != nil {
|
||
ensureIdentityCacheMaps(cache)
|
||
scope := e.identityScopeForClient(m.ClientID)
|
||
for _, candidateID := range names {
|
||
candidateID = strings.TrimSpace(candidateID)
|
||
if candidateID == "" {
|
||
continue
|
||
}
|
||
if contact, ok := cache.Internal[candidateID]; ok && contactMatchesIdentityScope(contact, scope) {
|
||
names = append(names, contact.Name)
|
||
}
|
||
if contact, ok := cache.External[candidateID]; ok && contactMatchesIdentityScope(contact, scope) {
|
||
names = append(names, contact.Name)
|
||
}
|
||
if contact, ok := cache.Observed[candidateID]; ok && contactMatchesIdentityScope(contact, scope) {
|
||
names = append(names, contact.Name)
|
||
}
|
||
}
|
||
}
|
||
e.mu.Unlock()
|
||
}
|
||
return dedupeNonEmptyStrings(names)
|
||
}
|
||
|
||
func mentionTextContainsName(content string, name string) bool {
|
||
content = strings.TrimSpace(content)
|
||
name = strings.TrimSpace(name)
|
||
if content == "" || name == "" {
|
||
return false
|
||
}
|
||
if strings.Contains(content, "@"+name) {
|
||
return true
|
||
}
|
||
if strings.Contains(content, "@ "+name) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (m autoReplyMessage) sourceLabel() string {
|
||
if m.IsGroup {
|
||
return "group"
|
||
}
|
||
return "private"
|
||
}
|
||
|
||
func (m autoReplyMessage) sourceDisplayLabel() string {
|
||
if m.IsGroup {
|
||
return "群聊"
|
||
}
|
||
return "私聊"
|
||
}
|
||
|
||
func (e *AutoReplyEngine) isDuplicate(msg autoReplyMessage) bool {
|
||
key := msg.dedupeKey()
|
||
if key == "" {
|
||
return false
|
||
}
|
||
cfg := e.getConfig()
|
||
ttl := time.Duration(cfg.Listen.DeduplicateSeconds) * time.Second
|
||
if ttl <= 0 {
|
||
ttl = 300 * time.Second
|
||
}
|
||
now := time.Now()
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
for k, ts := range e.dedupe {
|
||
if now.Sub(ts) > ttl {
|
||
delete(e.dedupe, k)
|
||
}
|
||
}
|
||
if ts, exists := e.dedupe[key]; exists && now.Sub(ts) <= ttl {
|
||
return true
|
||
}
|
||
e.dedupe[key] = now
|
||
return false
|
||
}
|
||
|
||
func (m autoReplyMessage) dedupeKey() string {
|
||
robotID := m.stableRobotID()
|
||
if m.ServerID != "" {
|
||
return strings.Join([]string{robotID, m.ConversationID, m.ServerID}, "|")
|
||
}
|
||
return strings.Join([]string{robotID, m.ConversationID, m.LocalID, m.SendTime, m.FromWxID, strings.TrimSpace(m.Content)}, "|")
|
||
}
|
||
|
||
func (m autoReplyMessage) stableRobotID() string {
|
||
robotID := strings.TrimSpace(m.RobotID)
|
||
if robotID != "" && !strings.HasPrefix(robotID, "client:") {
|
||
return robotID
|
||
}
|
||
if m.ClientID != 0 {
|
||
return fmt.Sprintf("client:%d", m.ClientID)
|
||
}
|
||
return robotID
|
||
}
|
||
|
||
func (e *AutoReplyEngine) inCooldown(msg autoReplyMessage) bool {
|
||
cfg := e.getConfig()
|
||
if cfg.ReplyPolicy.CooldownSeconds <= 0 {
|
||
return false
|
||
}
|
||
key := msg.stableRobotID() + "|" + msg.ConversationID
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
last, exists := e.cooldowns[key]
|
||
return exists && time.Since(last) < time.Duration(cfg.ReplyPolicy.CooldownSeconds)*time.Second
|
||
}
|
||
|
||
func (e *AutoReplyEngine) markCooldown(msg autoReplyMessage) {
|
||
key := msg.stableRobotID() + "|" + msg.ConversationID
|
||
e.mu.Lock()
|
||
e.cooldowns[key] = time.Now()
|
||
e.mu.Unlock()
|
||
}
|
||
|
||
func trivialAutoReplyMessageReason(content string) string {
|
||
text := strings.TrimSpace(content)
|
||
text = strings.Trim(text, " \t\r\n,,.。!!??~~;;::、")
|
||
if text == "" {
|
||
return "trivial_message"
|
||
}
|
||
lower := strings.ToLower(text)
|
||
switch lower {
|
||
case "ok", "okay", "k", "收到", "好的", "好", "嗯", "恩", "哦", "噢", "啊", "是", "对", "行", "可以", "谢谢", "谢了":
|
||
return "short_ack_message"
|
||
}
|
||
if len([]rune(text)) <= 3 && isAllASCIIDigits(text) {
|
||
return "short_numeric_message"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func isAllASCIIDigits(text string) bool {
|
||
if text == "" {
|
||
return false
|
||
}
|
||
for _, r := range text {
|
||
if r < '0' || r > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (e *AutoReplyEngine) manualHandoffReason(content string) string {
|
||
normalized := normalizeGreetingText(content)
|
||
for _, keyword := range e.manualHandoffKeywords() {
|
||
keyword = strings.TrimSpace(keyword)
|
||
if keyword != "" && strings.Contains(normalized, normalizeGreetingText(keyword)) {
|
||
return "manual_keyword: " + keyword
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (e *AutoReplyEngine) manualHandoffKeywords() []string {
|
||
cfg := e.getConfig()
|
||
items := []string{
|
||
"转人工", "人工客服", "真人客服", "人工接", "人工回复", "人工处理",
|
||
"找人工", "接人工", "换人工", "人工来", "人工在吗", "要人工",
|
||
"转客服", "找客服", "联系客服", "客服人工", "真人接待",
|
||
}
|
||
items = append(items, cfg.Handoff.ManualTriggerKeywords...)
|
||
for _, item := range cfg.ReplyPolicy.SensitiveKeywords {
|
||
if isExplicitManualHandoffKeyword(item) {
|
||
items = append(items, item)
|
||
}
|
||
}
|
||
return dedupeNonEmptyStrings(items)
|
||
}
|
||
|
||
func isExplicitManualHandoffKeyword(keyword string) bool {
|
||
normalized := normalizeGreetingText(keyword)
|
||
return normalized != "" && normalized != "客服" && strings.Contains(normalized, "人工")
|
||
}
|
||
|
||
func dedupeNonEmptyStrings(items []string) []string {
|
||
seen := make(map[string]bool, len(items))
|
||
result := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
item = strings.TrimSpace(item)
|
||
key := normalizeGreetingText(item)
|
||
if key == "" || seen[key] {
|
||
continue
|
||
}
|
||
seen[key] = true
|
||
result = append(result, item)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (e *AutoReplyEngine) ignoreMessage(msg autoReplyMessage, reason string) {
|
||
e.incStatus("ignored")
|
||
e.noteReason(reason)
|
||
e.addRecord(AutoReplyRecord{
|
||
RobotID: msg.RobotID,
|
||
ClientID: msg.ClientID,
|
||
UserID: msg.RobotID,
|
||
ConversationID: msg.ConversationID,
|
||
Source: msg.sourceLabel(),
|
||
FromWxID: msg.FromWxID,
|
||
FromNickName: msg.FromNickName,
|
||
Question: msg.Content,
|
||
Action: "ignored",
|
||
Reason: reason,
|
||
SenderIdentity: msg.SenderIdentity,
|
||
IdentitySource: msg.IdentitySource,
|
||
})
|
||
}
|
||
|
||
func (e *AutoReplyEngine) replyGeneralWithTimings(msg autoReplyMessage, reason string, hits []KnowledgeChunk, timings autoReplyTimings) {
|
||
cfg := e.getConfig()
|
||
aiStarted := time.Now()
|
||
result, err := e.askGeneralAI(msg.Content, msg)
|
||
if timings.AIDurationMS <= 0 {
|
||
timings.AIDurationMS = time.Since(aiStarted).Milliseconds()
|
||
}
|
||
if result != nil && result.DurationMS > 0 {
|
||
timings.AIDurationMS = result.DurationMS
|
||
}
|
||
answer := ""
|
||
if err == nil && result != nil {
|
||
answer = strings.TrimSpace(result.Answer)
|
||
if isNoAnswer(answer, cfg.ReplyPolicy.UnknownAnswerToken) {
|
||
answer = ""
|
||
}
|
||
}
|
||
if answer == "" {
|
||
answer = generalFallbackAnswer(msg.Content)
|
||
if err != nil {
|
||
reason += "; general_ai_error: " + err.Error()
|
||
} else {
|
||
reason += "; general_fallback"
|
||
}
|
||
}
|
||
if sendErr := e.replyTextWithTimings(msg, answer, reason, hits, timings); sendErr != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "通用回复发送失败: "+sendErr.Error())
|
||
}
|
||
}
|
||
|
||
func (e *AutoReplyEngine) replyKnowledgeNoAnswerWithTimings(msg autoReplyMessage, reason string, hits []KnowledgeChunk, timings autoReplyTimings) {
|
||
answer := "\u77e5\u8bc6\u5e93\u4e2d\u6ca1\u6709\u627e\u5230\u660e\u786e\u5185\u5bb9\uff0c\u5efa\u8bae\u67e5\u770b\u539f\u6587\u4ef6\u6216\u8054\u7cfb\u76f8\u5173\u8d1f\u8d23\u4eba\u786e\u8ba4\u3002"
|
||
if sendErr := e.replyTextWithTimings(msg, answer, reason, hits, timings); sendErr != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "knowledge no-answer reply failed: "+sendErr.Error())
|
||
}
|
||
}
|
||
|
||
func isKnowledgeScopedQuestion(content string) bool {
|
||
content = strings.ToLower(strings.TrimSpace(content))
|
||
if content == "" {
|
||
return false
|
||
}
|
||
if len(extractKnowledgeReferenceTokens(content)) > 0 {
|
||
return true
|
||
}
|
||
for _, ext := range []string{".xlsx", ".xls", ".docx", ".doc", ".pdf", ".md", ".txt", ".csv"} {
|
||
if strings.Contains(content, ext) {
|
||
return true
|
||
}
|
||
}
|
||
keywords := []string{
|
||
"\u77e5\u8bc6\u5e93", "\u6587\u4ef6", "\u6587\u6863", "\u8868\u683c", "\u901a\u77e5", "\u4f1a\u8bae",
|
||
"\u90e8\u95e8", "\u65f6\u95f4", "\u6807\u51c6", "\u89c4\u5b9a", "\u5b89\u6392", "\u5305\u542b",
|
||
"\u6709\u54ea\u4e9b", "\u5185\u5bb9", "\u6761\u6b3e", "\u5f00\u4f1a", "\u6708\u5ea6", "\u56fa\u5b9a",
|
||
}
|
||
for _, keyword := range keywords {
|
||
if strings.Contains(content, keyword) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func companyIdentityAnswer(content string, cfg config.AutoReplyConfig) (string, bool) {
|
||
text := normalizeGreetingText(content)
|
||
if text == "" {
|
||
return "", false
|
||
}
|
||
triggers := []string{
|
||
"你是什么公司", "你们是什么公司", "你们公司是做什么", "你们公司做什么", "你是哪个公司",
|
||
"你们是哪家公司", "你是谁", "你是做什么的", "介绍一下你们公司", "公司介绍",
|
||
}
|
||
for _, trigger := range triggers {
|
||
if strings.Contains(text, normalizeGreetingText(trigger)) {
|
||
return companyIdentitySafeAnswer(cfg), true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func companyIdentitySafeAnswer(cfg config.AutoReplyConfig) string {
|
||
return configuredIdentityAnswer(cfg)
|
||
}
|
||
|
||
func materialClarificationAnswer() string {
|
||
return "资料比较多,您要哪一类?比如企业级 AI 数字员工宣传手册、包装机接线标准,或具体产品资料。"
|
||
}
|
||
|
||
func sanitizeAutoReplyAnswer(question string, answer string, hits []KnowledgeChunk, cfg config.AutoReplyConfig) (string, bool) {
|
||
answer = strings.TrimSpace(answer)
|
||
if answer == "" {
|
||
return answer, false
|
||
}
|
||
if !looksLikePromptLeakage(answer) && !looksLikeBrokenKnowledgeAnswer(answer) {
|
||
return answer, false
|
||
}
|
||
if isGenericProductQuery(question) {
|
||
if summary := productOverviewFallbackFromHits(hits); summary != "" {
|
||
return summary, true
|
||
}
|
||
return productOverviewClarificationAnswer(), true
|
||
}
|
||
if safe, ok := companyIdentityAnswer(question, cfg); ok {
|
||
return safe, true
|
||
}
|
||
return "这个我帮您确认一下,您方便再说一下具体想了解哪方面内容吗?", true
|
||
}
|
||
|
||
func looksLikeBrokenKnowledgeAnswer(answer string) bool {
|
||
text := strings.TrimSpace(answer)
|
||
if text == "" {
|
||
return false
|
||
}
|
||
normalized := normalizeGreetingText(text)
|
||
signals := []string{
|
||
"knowledge库", "knowledge库内容无法确定", "知识库内容无法确定具体产品", "无法确定具体产品",
|
||
"知识库无法确定", "根据提供的知识库内容无法", "没有足够信息确定具体产品",
|
||
}
|
||
for _, signal := range signals {
|
||
if strings.Contains(normalized, normalizeGreetingText(signal)) {
|
||
return true
|
||
}
|
||
}
|
||
if strings.Count(text, "**") >= 4 {
|
||
trimmed := strings.TrimSpace(strings.ReplaceAll(text, "**", ""))
|
||
trimmed = strings.Trim(trimmed, "\r\n\t -_*。,.,、")
|
||
if len([]rune(trimmed)) < 24 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func productOverviewClarificationAnswer() string {
|
||
return "我这边没有检索到足够明确的产品清单。您可以告诉我想了解 AI 数字员工、知识库问答、业务自动化,还是具体某个产品资料?"
|
||
}
|
||
|
||
func productOverviewFallbackFromHits(hits []KnowledgeChunk) string {
|
||
names := productCandidateNamesFromHits(hits)
|
||
if len(names) == 0 {
|
||
return ""
|
||
}
|
||
if len(names) > 8 {
|
||
names = names[:8]
|
||
}
|
||
return "我们目前可介绍的产品/方向包括:" + strings.Join(names, "、") + "。您想先了解哪一类?"
|
||
}
|
||
|
||
func productCandidateNamesFromHits(hits []KnowledgeChunk) []string {
|
||
result := make([]string, 0, 8)
|
||
seen := map[string]bool{}
|
||
add := func(value string) {
|
||
value = strings.TrimSpace(value)
|
||
value = strings.TrimSuffix(value, ".md")
|
||
value = strings.TrimSuffix(value, ".txt")
|
||
value = strings.TrimSuffix(value, ".pdf")
|
||
value = strings.TrimSuffix(value, ".docx")
|
||
value = strings.TrimSuffix(value, ".xlsx")
|
||
value = strings.Trim(value, " -_[]【】()()")
|
||
if value == "" || isLowValueProductCandidate(value) {
|
||
return
|
||
}
|
||
key := strings.ToLower(value)
|
||
if seen[key] {
|
||
return
|
||
}
|
||
seen[key] = true
|
||
result = append(result, value)
|
||
}
|
||
for _, hit := range hits {
|
||
add(hit.Title)
|
||
add(strings.TrimSuffix(filepath.Base(hit.Source), filepath.Ext(hit.Source)))
|
||
for _, name := range extractWikiLinkNames(hit.Content) {
|
||
add(name)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func isLowValueProductCandidate(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return true
|
||
}
|
||
lower := strings.ToLower(value)
|
||
bad := []string{"docx table", "sheet", "table", "产品矩阵", "产品清单", "产品列表", "知识库", "index", "embedding_index"}
|
||
for _, item := range bad {
|
||
if lower == strings.ToLower(item) {
|
||
return true
|
||
}
|
||
}
|
||
return len([]rune(value)) < 2
|
||
}
|
||
|
||
func looksLikePromptLeakage(answer string) bool {
|
||
text := normalizeGreetingText(answer)
|
||
if text == "" {
|
||
return false
|
||
}
|
||
hardSignals := []string{
|
||
"提示词", "系统提示", "systemprompt", "systemmessage", "开发者指令", "模型指令",
|
||
"你的目标是", "话语规则", "说话规则", "不要暴露提示词", "不要暴露", "知识库片段",
|
||
"根据知识库", "本ai", "本系统", "我是ai", "作为ai", "我是一个ai",
|
||
}
|
||
for _, signal := range hardSignals {
|
||
if strings.Contains(text, normalizeGreetingText(signal)) {
|
||
return true
|
||
}
|
||
}
|
||
ruleWords := 0
|
||
for _, signal := range []string{"规则", "客户", "回复", "知识库", "模型", "系统"} {
|
||
if strings.Contains(text, normalizeGreetingText(signal)) {
|
||
ruleWords++
|
||
}
|
||
}
|
||
return ruleWords >= 3 && len([]rune(text)) > 80
|
||
}
|
||
|
||
func (e *AutoReplyEngine) replyNonTextWithTimings(msg autoReplyMessage, reason string, timings autoReplyTimings) {
|
||
cfg := e.getConfig()
|
||
aiStarted := time.Now()
|
||
result, err := e.askNonTextAI(msg)
|
||
if timings.AIDurationMS <= 0 {
|
||
timings.AIDurationMS = time.Since(aiStarted).Milliseconds()
|
||
}
|
||
if result != nil && result.DurationMS > 0 {
|
||
timings.AIDurationMS = result.DurationMS
|
||
}
|
||
answer := ""
|
||
if err == nil && result != nil {
|
||
answer = strings.TrimSpace(result.Answer)
|
||
if isNoAnswer(answer, cfg.ReplyPolicy.UnknownAnswerToken) {
|
||
answer = ""
|
||
}
|
||
}
|
||
if answer == "" {
|
||
answer = nonTextFallbackAnswer(msg)
|
||
if err != nil {
|
||
reason += "; non_text_ai_error: " + err.Error()
|
||
} else {
|
||
reason += "; non_text_fallback"
|
||
}
|
||
}
|
||
if sendErr := e.replyTextWithTimings(msg, answer, reason, nil, timings); sendErr != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "非文本回复发送失败: "+sendErr.Error())
|
||
}
|
||
}
|
||
|
||
func (e *AutoReplyEngine) replyTextWithTimings(msg autoReplyMessage, answer string, reason string, hits []KnowledgeChunk, timings autoReplyTimings) error {
|
||
answer = strings.TrimSpace(answer)
|
||
if answer == "" {
|
||
answer = generalFallbackAnswer(msg.Content)
|
||
}
|
||
if sanitized, changed := sanitizeAutoReplyAnswer(msg.Content, answer, hits, e.getConfig()); changed {
|
||
answer = sanitized
|
||
reason += "; prompt_leakage_guard"
|
||
}
|
||
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, answer); err != nil {
|
||
e.addRecord(AutoReplyRecord{
|
||
RobotID: msg.RobotID,
|
||
ClientID: msg.ClientID,
|
||
UserID: msg.RobotID,
|
||
ConversationID: msg.ConversationID,
|
||
Source: msg.sourceLabel(),
|
||
FromWxID: msg.FromWxID,
|
||
FromNickName: msg.FromNickName,
|
||
Question: msg.Content,
|
||
Action: "failed",
|
||
Reason: reason + "; send_reply_failed: " + err.Error(),
|
||
SenderIdentity: msg.SenderIdentity,
|
||
IdentitySource: msg.IdentitySource,
|
||
KeywordScore: timings.KeywordScore,
|
||
VectorScore: timings.VectorScore,
|
||
RerankScore: timings.RerankScore,
|
||
RetrievalMode: timings.RetrievalMode,
|
||
UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "),
|
||
KnowledgeDurationMS: timings.KnowledgeDurationMS,
|
||
KeywordDurationMS: timings.KeywordDurationMS,
|
||
VectorDurationMS: timings.VectorDurationMS,
|
||
RerankDurationMS: timings.RerankDurationMS,
|
||
AIDurationMS: timings.AIDurationMS,
|
||
TotalDurationMS: timings.TotalDurationMS,
|
||
})
|
||
return err
|
||
}
|
||
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, answer)
|
||
e.markCooldown(msg)
|
||
e.rememberAssistantMessage(msg, answer)
|
||
e.incStatus("replied")
|
||
score := 0.0
|
||
if len(hits) > 0 {
|
||
score = hits[0].Score
|
||
}
|
||
e.addRecord(AutoReplyRecord{
|
||
RobotID: msg.RobotID,
|
||
ClientID: msg.ClientID,
|
||
UserID: msg.RobotID,
|
||
ConversationID: msg.ConversationID,
|
||
Source: msg.sourceLabel(),
|
||
FromWxID: msg.FromWxID,
|
||
FromNickName: msg.FromNickName,
|
||
Question: msg.Content,
|
||
Action: "replied",
|
||
Reason: reason,
|
||
Answer: answer,
|
||
SenderIdentity: msg.SenderIdentity,
|
||
IdentitySource: msg.IdentitySource,
|
||
Score: score,
|
||
KeywordScore: timings.KeywordScore,
|
||
VectorScore: timings.VectorScore,
|
||
RerankScore: timings.RerankScore,
|
||
RetrievalMode: timings.RetrievalMode,
|
||
UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "),
|
||
KnowledgeDurationMS: timings.KnowledgeDurationMS,
|
||
KeywordDurationMS: timings.KeywordDurationMS,
|
||
VectorDurationMS: timings.VectorDurationMS,
|
||
RerankDurationMS: timings.RerankDurationMS,
|
||
AIDurationMS: timings.AIDurationMS,
|
||
TotalDurationMS: timings.TotalDurationMS,
|
||
})
|
||
return nil
|
||
}
|
||
|
||
var (
|
||
sendAutoReplyTextSender = sendAutoReplyTextRequest
|
||
sendAutoReplyCardSender = sendAutoReplyCardRequest
|
||
)
|
||
|
||
func sendAutoReplyText(clientID uint32, conversationID string, content string) error {
|
||
return sendAutoReplyTextSender(clientID, conversationID, content)
|
||
}
|
||
|
||
func sendAutoReplyTextRequest(clientID uint32, conversationID string, content string) error {
|
||
if strings.TrimSpace(conversationID) == "" {
|
||
return fmt.Errorf("conversationId为空")
|
||
}
|
||
request := map[string]interface{}{
|
||
"type": 11029,
|
||
"data": map[string]interface{}{
|
||
"conversation_id": conversationID,
|
||
"content": content,
|
||
},
|
||
}
|
||
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("%v", resultMap["error"])
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func sendAutoReplyCard(clientID uint32, conversationID string, shareUserID string) error {
|
||
return sendAutoReplyCardSender(clientID, conversationID, shareUserID)
|
||
}
|
||
|
||
func sendAutoReplyCardRequest(clientID uint32, conversationID string, shareUserID string) error {
|
||
if strings.TrimSpace(conversationID) == "" {
|
||
return fmt.Errorf("conversationId为空")
|
||
}
|
||
if strings.TrimSpace(shareUserID) == "" {
|
||
return fmt.Errorf("shareUserId为空")
|
||
}
|
||
request := map[string]interface{}{
|
||
"type": 11161,
|
||
"data": map[string]interface{}{
|
||
"conversation_id": conversationID,
|
||
"share_user_id": shareUserID,
|
||
},
|
||
}
|
||
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("%v", resultMap["error"])
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func isNoAnswer(answer string, token string) bool {
|
||
token = strings.TrimSpace(token)
|
||
if token == "" {
|
||
token = "NO_ANSWER"
|
||
}
|
||
normalized := strings.TrimSpace(strings.ToUpper(answer))
|
||
return normalized == strings.ToUpper(token) || strings.Contains(normalized, strings.ToUpper(token))
|
||
}
|
||
|
||
func firstNonNil(values ...interface{}) interface{} {
|
||
for _, value := range values {
|
||
if value != nil {
|
||
return value
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func stringFromAny(value interface{}) string {
|
||
switch v := value.(type) {
|
||
case string:
|
||
return strings.TrimSpace(v)
|
||
case float64:
|
||
return fmt.Sprintf("%.0f", v)
|
||
case int:
|
||
return fmt.Sprintf("%d", v)
|
||
case json.Number:
|
||
return v.String()
|
||
default:
|
||
if value == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(fmt.Sprint(value))
|
||
}
|
||
}
|
||
|
||
func intFromAny(value interface{}) int {
|
||
switch v := value.(type) {
|
||
case int:
|
||
return v
|
||
case int32:
|
||
return int(v)
|
||
case int64:
|
||
return int(v)
|
||
case float64:
|
||
return int(v)
|
||
case string:
|
||
var n int
|
||
_, _ = fmt.Sscanf(v, "%d", &n)
|
||
return n
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func firstMediaURLFromValue(value interface{}) string {
|
||
switch v := value.(type) {
|
||
case map[string]interface{}:
|
||
for _, key := range []string{"image_url", "imageUrl", "preview_img_url", "previewImgUrl", "md_url", "mdUrl", "ld_url", "ldUrl", "url"} {
|
||
if url := mediaURLFromString(stringFromAny(v[key])); url != "" {
|
||
return url
|
||
}
|
||
}
|
||
for _, item := range v {
|
||
if url := firstMediaURLFromValue(item); url != "" {
|
||
return url
|
||
}
|
||
}
|
||
case []interface{}:
|
||
for _, item := range v {
|
||
if url := firstMediaURLFromValue(item); url != "" {
|
||
return url
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func mediaURLFromString(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
lower := strings.ToLower(value)
|
||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "data:image/") {
|
||
return value
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func formatAutoReplyMessageTime(raw string) string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
n, err := strconv.ParseInt(raw, 10, 64)
|
||
if err != nil || n <= 0 {
|
||
return ""
|
||
}
|
||
if n > 1000000000000 {
|
||
n = n / 1000
|
||
}
|
||
return time.Unix(n, 0).Local().Format("2006-01-02 15:04:05")
|
||
}
|
||
|
||
func greetingAnswer(content string) (string, bool) {
|
||
normalized := normalizeGreetingText(content)
|
||
switch normalized {
|
||
case "你好", "您好", "在吗", "在嘛", "在不在", "有人吗", "有人嘛", "hello", "hi", "hey", "哈喽", "哈罗", "嗨":
|
||
return "您好,我在的,请问有什么可以帮您?", true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func quickConversationAnswer(content string, cfg config.AutoReplyConfig) (string, bool) {
|
||
normalized := normalizeGreetingText(content)
|
||
if isCompanyIdentityQuestion(normalized) {
|
||
return configuredIdentityAnswer(cfg), true
|
||
}
|
||
switch normalized {
|
||
case "你是谁", "你是什么", "你是机器人吗", "你是客服吗", "你叫什么", "你能做什么", "你能干什么":
|
||
return configuredIdentityAnswer(cfg), true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func configuredIdentityAnswer(cfg config.AutoReplyConfig) string {
|
||
identity := safeIdentityFromSystemPrompt(cfg.AI.SystemPrompt)
|
||
if identity == "" {
|
||
identity = "我是企业微信智能客服助手"
|
||
}
|
||
if strings.HasPrefix(identity, "我") {
|
||
return "您好," + identity + "。普通问题我会尽量帮您解答;涉及产品、方案或售后资料时,我会优先按已有资料回复。"
|
||
}
|
||
return "您好,我是" + identity + "。普通问题我会尽量帮您解答;涉及产品、方案或售后资料时,我会优先按已有资料回复。"
|
||
}
|
||
|
||
func safeIdentityFromSystemPrompt(prompt string) string {
|
||
text := strings.TrimSpace(prompt)
|
||
if text == "" {
|
||
return ""
|
||
}
|
||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||
text = strings.ReplaceAll(text, "\r", "\n")
|
||
parts := strings.FieldsFunc(text, func(r rune) bool {
|
||
return r == '\n' || r == '。' || r == '!' || r == '!' || r == ';' || r == ';'
|
||
})
|
||
for _, part := range parts {
|
||
candidate := cleanIdentityPromptSegment(part)
|
||
if candidate == "" || unsafeIdentityPromptSegment(candidate) {
|
||
continue
|
||
}
|
||
if looksLikeCompanyIdentity(candidate) || len([]rune(candidate)) <= 40 {
|
||
return candidate
|
||
}
|
||
}
|
||
candidate := cleanIdentityPromptSegment(text)
|
||
if candidate == "" || unsafeIdentityPromptSegment(candidate) {
|
||
return ""
|
||
}
|
||
if len([]rune(candidate)) > 40 && !looksLikeCompanyIdentity(candidate) {
|
||
return ""
|
||
}
|
||
return candidate
|
||
}
|
||
|
||
func cleanIdentityPromptSegment(value string) string {
|
||
text := strings.TrimRight(strings.TrimSpace(value), "。.!!")
|
||
if text == "" {
|
||
return ""
|
||
}
|
||
prefixes := []string{"你是一名", "你是一个", "你作为", "请你扮演", "请扮演", "作为", "你是"}
|
||
for _, prefix := range prefixes {
|
||
text = strings.TrimPrefix(text, prefix)
|
||
}
|
||
text = truncateIdentityInstructions(text)
|
||
return strings.TrimSpace(strings.TrimRight(text, ",,、::"))
|
||
}
|
||
|
||
func unsafeIdentityPromptSegment(value string) bool {
|
||
text := strings.TrimSpace(value)
|
||
if text == "" {
|
||
return true
|
||
}
|
||
for _, keyword := range []string{"规则", "必须", "禁止", "知识库", "系统", "提示词", "模型", "指令", "不要", "不能", "根据"} {
|
||
if strings.Contains(text, keyword) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func truncateIdentityInstructions(value string) string {
|
||
text := strings.TrimSpace(value)
|
||
cut := -1
|
||
for _, keyword := range []string{"规则", "必须", "禁止", "知识库", "系统", "提示词", "模型", "指令", "不要", "不能", "根据"} {
|
||
if idx := strings.Index(text, keyword); idx >= 0 && (cut < 0 || idx < cut) {
|
||
cut = idx
|
||
}
|
||
}
|
||
if cut <= 0 {
|
||
return text
|
||
}
|
||
return text[:cut]
|
||
}
|
||
|
||
func looksLikeCompanyIdentity(value string) bool {
|
||
for _, keyword := range []string{"公司", "有限公司", "集团", "工厂", "品牌"} {
|
||
if strings.Contains(value, keyword) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isCompanyIdentityQuestion(normalized string) bool {
|
||
if strings.Contains(normalized, "什么公司") || strings.Contains(normalized, "哪个公司") || strings.Contains(normalized, "哪家公司") || strings.Contains(normalized, "哪个公司") {
|
||
return true
|
||
}
|
||
return strings.Contains(normalized, "不是大铁") || strings.Contains(normalized, "不是大铁的吗") || strings.Contains(normalized, "你不是大铁")
|
||
}
|
||
|
||
func (e *AutoReplyEngine) quickConversationAnswerForMessage(msg autoReplyMessage) (string, bool) {
|
||
if answer, ok := quickConversationAnswer(msg.Content, e.getConfig()); ok {
|
||
return answer, true
|
||
}
|
||
normalized := normalizeGreetingText(msg.Content)
|
||
switch normalized {
|
||
case "我是谁", "我这边是谁", "你知道我是谁吗", "知道我是谁吗":
|
||
if name := e.displayNameForMessage(msg); name != "" {
|
||
return "您这边显示是 " + name + "。", true
|
||
}
|
||
e.startUnknownIdentityLookup(msg, "who_am_i")
|
||
return "我这边正在核验您的联系人信息,您也可以补充姓名或公司方便我确认。", true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func generalFallbackAnswer(content string) string {
|
||
if answer, ok := quickConversationAnswer(content, config.AutoReplyConfig{}); ok {
|
||
return answer
|
||
}
|
||
return "我在的。您可以把具体问题发给我,我会尽量帮您解答;涉及产品、方案或售后资料时,我会优先按知识库内容回复。"
|
||
}
|
||
|
||
func nonTextMessageDescription(msg autoReplyMessage) string {
|
||
switch msg.RawType {
|
||
case 11047:
|
||
return "[图片/表情]"
|
||
default:
|
||
if msg.MessageType != "" {
|
||
return "[" + msg.MessageType + "]"
|
||
}
|
||
return "[非文本消息]"
|
||
}
|
||
}
|
||
|
||
func nonTextFallbackAnswer(msg autoReplyMessage) string {
|
||
content := strings.TrimSpace(msg.Content)
|
||
if content == "" {
|
||
content = nonTextMessageDescription(msg)
|
||
}
|
||
if strings.Contains(content, "产品") || strings.Contains(content, "订单") || strings.Contains(content, "售后") || strings.Contains(content, "客服") || strings.Contains(content, "问题") {
|
||
return "我看到了您发来的内容。如果是产品、订单或售后相关问题,麻烦您再补充一句文字说明,我好准确帮您处理。"
|
||
}
|
||
return "抱歉,你这问题超出我的岗位认知了,回答不了。"
|
||
}
|
||
|
||
func normalizeGreetingText(content string) string {
|
||
content = strings.ToLower(strings.TrimSpace(content))
|
||
replacer := strings.NewReplacer(
|
||
" ", "", "\t", "", "\r", "", "\n", "",
|
||
",", "", "。", "", "?", "", "?", "", "!", "", "!", "",
|
||
"~", "", "~", "", ".", "", ",", "", ";", "", ";", "", ":", "", ":", "",
|
||
)
|
||
return replacer.Replace(content)
|
||
}
|
||
|
||
func stringSliceFromAny(value interface{}) []string {
|
||
switch v := value.(type) {
|
||
case []string:
|
||
return v
|
||
case []interface{}:
|
||
result := make([]string, 0, len(v))
|
||
for _, item := range v {
|
||
text := stringFromAny(item)
|
||
if text != "" {
|
||
result = append(result, text)
|
||
}
|
||
}
|
||
return result
|
||
case string:
|
||
if strings.TrimSpace(v) == "" {
|
||
return nil
|
||
}
|
||
parts := strings.FieldsFunc(v, func(r rune) bool {
|
||
return r == ',' || r == ';' || r == '|' || r == ' '
|
||
})
|
||
result := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
part = strings.TrimSpace(part)
|
||
if part != "" {
|
||
result = append(result, part)
|
||
}
|
||
}
|
||
return result
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func truncateText(text string, max int) string {
|
||
runes := []rune(text)
|
||
if len(runes) <= max {
|
||
return text
|
||
}
|
||
return string(runes[:max]) + "..."
|
||
}
|