Files
qiweimanager-master/helper/auto_reply.go
yuanzhipeng a926ee6b1b chore(build): 更新.gitignore配置和清理Wails临时文件
- 添加dist/目录到.gitignore,用于排除打包输出的绿色免安装版
- 添加Wails打包过程中的临时文件和自动生成文件到.gitignore
- 删除build/windows/installer/wails_tools.nsh自动生成文件
- 添加Windows安装器临时目录和Webview2安装文件到忽略列表

feat(docs): 添加万川平台对接文档和产品素材

- 创建万川平台登录到获取模型信息的流程说明文档
- 添加万川平台对接实施计划文档
- 新增产品图片、公司简介图、宣传海报、教程截图、案例展示等素材文件

refactor(runtime): 扩展通知功能类型定义

- 添加NotificationOptions接口定义
- 添加NotificationAction接口定义
- 添加NotificationCategory接口定义
- 扩展通知相关的运行时API类型声明,包括初始化、发送、注册分类等功能
2026-06-25 18:13:11 +08:00

1676 lines
53 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/json"
"fmt"
"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 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模拟事件或老版本 DLL2=文本 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]) + "..."
}