Files
qiweimanager-master/helper/auto_reply_materials.go
yuanzhipeng 849090a627 feat(auto-reply): 优化自动回复逻辑和知识库功能
- 将默认回复详细程度从"detailed"调整为"medium",前后端保持一致
- 新增话题切换检测逻辑,当用户主动要求换话题时提供引导回复
- 优化上下文处理机制,仅在指代型追问时注入历史对话,避免模型复读旧内容
- 改进知识库检索逻辑,区分自包含问题和指代型问题的上下文需求
- 完善知识库完整性指令,确保回复详细程度与知识展开程度一致
- 重构知识库重建逻辑,支持递归扫描子目录中的文件,修复索引为空的问题
- 增强素材匹配算法,引入强信号检测机制,避免仅凭模糊匹配误发素材
- 新增素材开场白AI生成功能,支持图片、视频、文档等类型智能描述
- 改进知识库重建通知,显示具体的文件数、分片数及失败统计信息
2026-06-26 14:25:35 +08:00

826 lines
26 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 (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"unicode"
)
type AutoReplyMaterial struct {
ID string `json:"id"`
Title string `json:"title"`
Keywords []string `json:"keywords"`
QuestionPatterns []string `json:"questionPatterns"`
MaterialType string `json:"materialType"`
Path string `json:"path"`
Caption string `json:"caption"`
// CaptionSource 标记 caption 的来源:
// "ai" —— 同步时由模型自动生成;重新同步可被再次刷新。
// "manual" —— 运营手工编写;重新同步绝不覆盖。
// "" —— 未知/历史数据;按需生成。
CaptionSource string `json:"captionSource,omitempty"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
}
type autoReplyMaterialsFile struct {
Materials []AutoReplyMaterial `json:"materials"`
}
type autoReplyMaterialMatch struct {
Material AutoReplyMaterial
Path string
Score int
}
type autoReplyMaterialSyncResult struct {
Added int `json:"added"`
Removed int `json:"removed"`
Total int `json:"total"`
Materials []AutoReplyMaterial `json:"materials"`
IndexPath string `json:"indexPath"`
Directory string `json:"directory"`
AddedPaths []string `json:"addedPaths"`
RemovedPaths []string `json:"removedPaths"`
}
func (e *AutoReplyEngine) matchMaterials(userQuery string, searchContext string, hits []KnowledgeChunk) []autoReplyMaterialMatch {
cfg := e.getConfig()
if !cfg.Materials.AutoSendEnabled {
return nil
}
if isBroadAllMaterialRequest(userQuery) {
return nil
}
materials, err := loadAutoReplyMaterials(cfg.Materials.IndexPath)
if err != nil {
if !os.IsNotExist(err) {
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, "load materials failed: "+err.Error())
}
}
if len(materials) == 0 {
materials = discoverAutoReplyMaterials(cfg.Materials.Directory)
}
if len(materials) == 0 {
return nil
}
requestedTypes := requestedMaterialTypes(userQuery)
hasSendIntent := hasMaterialSendIntent(userQuery)
if hasSendIntent && isGenericMaterialRequest(userQuery) && !materialQueryHasSpecificSignal(userQuery, materials) && strings.TrimSpace(searchContext) == strings.TrimSpace(userQuery) {
return nil
}
queryText := buildMaterialSearchText(userQuery, "", nil, false)
matches := e.collectMaterialMatches(materials, cfg.Materials.Directory, requestedTypes, queryText, hasSendIntent)
if len(matches) > 0 {
return limitMaterialMatches(matches, cfg.Materials.MaxPerReply)
}
if !hasSendIntent {
return nil
}
searchText := buildMaterialSearchText(userQuery, "", hits, true)
matches = e.collectMaterialMatches(materials, cfg.Materials.Directory, requestedTypes, searchText, hasSendIntent)
return limitMaterialMatches(matches, cfg.Materials.MaxPerReply)
}
func (e *AutoReplyEngine) collectMaterialMatches(materials []AutoReplyMaterial, root string, requestedTypes map[string]bool, searchText string, hasSendIntent bool) []autoReplyMaterialMatch {
matches := make([]autoReplyMaterialMatch, 0, 4)
for _, material := range materials {
if len(requestedTypes) > 0 && !requestedTypes[material.MaterialType] {
continue
}
path := resolveAutoReplyMaterialPath(root, material.Path)
score, strong := materialMatchScoreDetailed(searchText, material, hasSendIntent)
// 必须命中过强信号(整词关键词/问句模板,或整串标题/文件名)才算候选;
// 仅靠 2-gram 模糊片段凑分的弱命中直接丢弃,避免误发。
if score <= 0 || !strong {
continue
}
if _, err := os.Stat(path); err != nil {
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, fmt.Sprintf("material file missing: %s", path))
continue
}
matches = append(matches, autoReplyMaterialMatch{Material: material, Path: path, Score: score})
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].Score != matches[j].Score {
return matches[i].Score > matches[j].Score
}
if matches[i].Material.Priority != matches[j].Material.Priority {
return matches[i].Material.Priority > matches[j].Material.Priority
}
return matches[i].Material.Title < matches[j].Material.Title
})
return matches
}
func limitMaterialMatches(matches []autoReplyMaterialMatch, maxPerReply int) []autoReplyMaterialMatch {
limit := maxPerReply
if limit <= 0 {
limit = 2
}
if len(matches) > limit {
matches = matches[:limit]
}
return matches
}
func buildMaterialSearchText(userQuery string, searchContext string, hits []KnowledgeChunk, includeContext bool) string {
parts := []string{userQuery}
if includeContext {
parts = append(parts, searchContext)
for _, hit := range hits {
parts = append(parts, hit.Source, hit.Title, hit.Content)
}
}
return strings.ToLower(strings.Join(parts, "\n"))
}
func hasMaterialSendIntent(query string) bool {
text := normalizeGreetingText(query)
if text == "" {
return false
}
return containsAnyMaterialIntent(text, []string{
"发我", "发给我", "发一下", "发下", "发来", "发送", "传给我", "给我发",
"给我", "我要", "我想要", "需要", "有吗", "有没有", "资料", "素材",
"手册", "文档", "文件", "附件", "说明书", "宣传册", "ppt", "pdf",
"视频", "图片", "表格", "清单", "案例", "模板",
})
}
func requestedMaterialTypes(query string) map[string]bool {
text := strings.ToLower(strings.TrimSpace(query))
if text == "" {
return nil
}
result := map[string]bool{}
if containsAnyMaterialIntent(text, []string{
"\u56fe\u7247", "\u7167\u7247", "\u76f8\u7247", "\u56fe\u50cf", "\u622a\u56fe", "\u914d\u56fe",
"image", "photo", "jpg", "jpeg", "png", "webp",
}) {
result["image"] = true
}
if containsAnyMaterialIntent(text, []string{
"\u89c6\u9891", "\u5f55\u50cf", "\u5f71\u7247", "\u77ed\u89c6\u9891", "video", "movie", "mp4", "mov",
}) {
result["video"] = true
}
if containsAnyMaterialIntent(text, []string{
"\u52a8\u56fe", "\u8868\u60c5\u5305", "gif",
}) {
result["gif"] = true
}
if containsAnyMaterialIntent(text, []string{
"\u6587\u4ef6", "\u6587\u6863", "\u6587\u7a3f", "\u9644\u4ef6", "\u8868\u683c",
"\u624b\u518c", "\u8d44\u6599", "\u65b9\u6848", "\u8bf4\u660e\u4e66",
"file", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
}) {
result["file"] = true
}
if len(result) == 0 {
return nil
}
return result
}
func containsAnyMaterialIntent(text string, keywords []string) bool {
for _, keyword := range keywords {
if strings.Contains(text, keyword) {
return true
}
}
return false
}
func loadAutoReplyMaterials(indexPath string) ([]AutoReplyMaterial, error) {
path := resolveAutoReplyPath(indexPath)
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var wrapped autoReplyMaterialsFile
if err := json.Unmarshal(data, &wrapped); err == nil {
return normalizeAutoReplyMaterials(wrapped.Materials), nil
}
var list []AutoReplyMaterial
if err := json.Unmarshal(data, &list); err != nil {
return nil, err
}
return normalizeAutoReplyMaterials(list), nil
}
// materialCaptionGenerator 根据素材本身(含已解析的绝对路径)生成一句开场白。
// 返回 ok=false 表示本条生成失败/跳过,调用方应保留原 caption 不动。
type materialCaptionGenerator func(material AutoReplyMaterial, absPath string) (caption string, ok bool)
func (e *AutoReplyEngine) syncAutoReplyMaterials() (autoReplyMaterialSyncResult, error) {
cfg := e.getConfig()
return syncAutoReplyMaterials(cfg.Materials.Directory, cfg.Materials.IndexPath, e.materialCaptionGenerator())
}
func syncAutoReplyMaterials(root string, indexPath string, generateCaption materialCaptionGenerator) (autoReplyMaterialSyncResult, error) {
result := autoReplyMaterialSyncResult{
Directory: resolveAutoReplyPath(root),
IndexPath: resolveAutoReplyPath(indexPath),
}
if err := os.MkdirAll(result.Directory, 0755); err != nil {
return result, err
}
existing, err := loadAutoReplyMaterials(indexPath)
if err != nil && !os.IsNotExist(err) {
return result, err
}
discovered := discoverAutoReplyMaterials(root)
discoveredByPath := make(map[string]AutoReplyMaterial, len(discovered))
for _, item := range discovered {
discoveredByPath[materialPathKey(item.Path)] = item
}
synced := make([]AutoReplyMaterial, 0, len(discovered))
seen := make(map[string]bool, len(discovered))
for _, item := range existing {
key := materialPathKey(item.Path)
if key == "" || seen[key] {
continue
}
if _, ok := discoveredByPath[key]; !ok {
result.Removed++
result.RemovedPaths = append(result.RemovedPaths, item.Path)
continue
}
synced = append(synced, item)
seen[key] = true
}
for _, item := range discovered {
key := materialPathKey(item.Path)
if key == "" || seen[key] {
continue
}
synced = append(synced, item)
seen[key] = true
result.Added++
result.AddedPaths = append(result.AddedPaths, item.Path)
}
sort.SliceStable(synced, func(i, j int) bool {
li := strings.ToLower(synced[i].Path)
lj := strings.ToLower(synced[j].Path)
if li != lj {
return li < lj
}
return strings.ToLower(synced[i].Title) < strings.ToLower(synced[j].Title)
})
// 在写盘前为需要的素材生成开场白generateCaption 为 nil如未配置 AI 或单测)时整体跳过。
if generateCaption != nil {
applyMaterialCaptions(synced, result.Directory, generateCaption)
}
if err := os.MkdirAll(filepath.Dir(result.IndexPath), 0755); err != nil {
return result, err
}
data, err := json.MarshalIndent(autoReplyMaterialsFile{Materials: synced}, "", " ")
if err != nil {
return result, err
}
if err := os.WriteFile(result.IndexPath, data, 0644); err != nil {
return result, err
}
result.Materials = synced
result.Total = len(synced)
return result, nil
}
func discoverAutoReplyMaterials(root string) []AutoReplyMaterial {
dir := resolveAutoReplyPath(root)
items := make([]AutoReplyMaterial, 0, 8)
// 递归遍历子目录filepath.WalkDir支持 config/materials 下任意层级嵌套。
// Path 存相对 root 的子路径并统一为 / 分隔;顶层文件相对路径即文件名,向后兼容旧索引。
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // 单个条目出错跳过,不中断整体扫描
}
if d.IsDir() {
return nil
}
name := d.Name()
if strings.EqualFold(name, "materials.json") {
return nil
}
materialType := inferMaterialType(name)
if materialType == "" {
return nil
}
rel, relErr := filepath.Rel(dir, path)
if relErr != nil {
rel = name
}
rel = filepath.ToSlash(rel)
title := strings.TrimSuffix(name, filepath.Ext(name))
keywords := defaultMaterialKeywords(title, materialType)
// 把子目录名也并入关键词,便于"发我<目录名>的图/文件"命中。
// 只添加目录名本身,不再分词,避免关键词过度膨胀。
if dirPart := filepath.ToSlash(filepath.Dir(rel)); dirPart != "." && dirPart != "" {
for _, seg := range strings.Split(dirPart, "/") {
if seg = strings.TrimSpace(seg); seg != "" {
keywords = append(keywords, seg)
}
}
keywords = dedupeNonEmptyStrings(keywords)
}
items = append(items, AutoReplyMaterial{
ID: materialIDFromTitle(strings.TrimSuffix(rel, filepath.Ext(rel))),
Title: title,
Keywords: keywords,
QuestionPatterns: defaultMaterialQuestionPatterns(title),
MaterialType: materialType,
Path: rel,
Caption: defaultMaterialCaption(materialType),
Priority: 1,
Enabled: true,
})
return nil
})
return normalizeAutoReplyMaterials(items)
}
func materialIDFromTitle(title string) string {
base := strings.TrimSpace(strings.ToLower(title))
var builder strings.Builder
lastDash := false
for _, r := range base {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
builder.WriteRune(r)
lastDash = false
case r == '-' || r == '_':
if builder.Len() > 0 {
builder.WriteRune(r)
lastDash = false
}
default:
if builder.Len() > 0 && !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
}
id := strings.Trim(builder.String(), "-_")
if id == "" {
sum := sha1.Sum([]byte(base))
id = "material-" + hex.EncodeToString(sum[:])[:12]
}
return id
}
func defaultMaterialQuestionPatterns(title string) []string {
title = strings.TrimSpace(title)
if title == "" {
return nil
}
return []string{"我要" + title, "发我" + title, "看" + title, "有没有" + title, "把" + title + "发我", "需要" + title}
}
func defaultMaterialKeywords(title string, materialType string) []string {
keywords := []string{strings.TrimSpace(title)}
keywords = append(keywords, materialSearchTokens(title)...)
switch materialType {
case "image":
keywords = append(keywords, specificMaterialTokensForType(materialType)...)
case "video":
keywords = append(keywords, specificMaterialTokensForType(materialType)...)
case "gif":
keywords = append(keywords, specificMaterialTokensForType(materialType)...)
default:
keywords = append(keywords, specificMaterialTokensForType(materialType)...)
}
return dedupeNonEmptyStrings(keywords)
}
func specificMaterialTokensForType(materialType string) []string {
switch materialType {
case "video":
return []string{"安装视频", "演示视频", "教程视频"}
case "image":
return []string{"示意图", "效果图", "截图"}
case "gif":
return []string{"动图"}
default:
return nil
}
}
func materialPathKey(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
return strings.ToLower(filepath.ToSlash(filepath.Clean(path)))
}
func normalizeAutoReplyMaterials(items []AutoReplyMaterial) []AutoReplyMaterial {
result := make([]AutoReplyMaterial, 0, len(items))
for _, item := range items {
item.ID = strings.TrimSpace(item.ID)
item.Title = strings.TrimSpace(item.Title)
item.MaterialType = strings.ToLower(strings.TrimSpace(item.MaterialType))
item.Path = strings.TrimSpace(item.Path)
item.Caption = strings.TrimSpace(item.Caption)
if item.MaterialType == "" {
item.MaterialType = inferMaterialType(item.Path)
}
if item.Path == "" || item.MaterialType == "" {
continue
}
if !item.Enabled && strings.TrimSpace(item.ID+item.Title) == "" {
continue
}
result = append(result, item)
}
return result
}
func materialMatchScore(searchText string, material AutoReplyMaterial, hasSendIntent bool) int {
score, _ := materialMatchScoreDetailed(searchText, material, hasSendIntent)
return score
}
// materialMatchScoreDetailed 在打分之外额外返回 strong是否命中过“强信号”。
// 强信号 = 整词关键词/问句模板命中,或整串标题/文件名命中。
// 仅靠 2-gram 模糊片段fuzzyMaterialTokenScore凑出的分数不算强信号——
// 这类弱命中只用于在多个强匹配之间排序,不能单独触发发送,避免误发。
func materialMatchScoreDetailed(searchText string, material AutoReplyMaterial, hasSendIntent bool) (int, bool) {
score := 0
strong := false
for _, keyword := range append(material.Keywords, material.QuestionPatterns...) {
keyword = strings.ToLower(strings.TrimSpace(keyword))
if keyword == "" || isGenericMaterialIntentToken(keyword) {
continue
}
if strings.Contains(searchText, keyword) {
score += 10
strong = true
}
}
for _, field := range []string{material.Title, filepath.Base(material.Path), strings.TrimSuffix(filepath.Base(material.Path), filepath.Ext(material.Path))} {
field = strings.ToLower(strings.TrimSpace(field))
if field != "" && len([]rune(field)) >= 2 && strings.Contains(searchText, field) {
score += 4
strong = true
}
score += fuzzyMaterialTokenScore(searchText, field)
}
if hasSendIntent && score > 0 {
score += 3
}
return score, strong
}
func isBroadAllMaterialRequest(query string) bool {
text := normalizeGreetingText(query)
if text == "" {
return false
}
phrases := []string{
"全部资料", "所有资料", "全部文件", "所有文件", "全部素材", "所有素材", "全部发", "全都发",
"都发我", "都发给我", "资料都发", "文件都发", "全套资料", "所有手册", "全部手册",
}
for _, phrase := range phrases {
if strings.Contains(text, normalizeGreetingText(phrase)) {
return true
}
}
return false
}
func isGenericMaterialRequest(query string) bool {
text := normalizeGreetingText(query)
if text == "" || !hasMaterialSendIntent(query) {
return false
}
generic := []string{
"资料", "文件", "文档", "附件", "素材", "手册", "说明书", "宣传册", "方案",
"模板", "案例", "清单", "表格", "图片", "照片", "截图", "视频", "ppt", "pdf", "doc", "docx", "xls", "xlsx",
"发我", "发给我", "发一个", "发下", "发来", "发送", "传给我", "给我发", "给我", "我要", "我想要", "需要", "有吗", "有没有",
}
remaining := text
for _, token := range generic {
remaining = strings.ReplaceAll(remaining, normalizeGreetingText(token), "")
}
remaining = strings.Trim(remaining, " \t\r\n,,。.!?;::、()()[]【】")
return len([]rune(remaining)) == 0
}
func materialQueryHasSpecificSignal(query string, materials []AutoReplyMaterial) bool {
text := strings.ToLower(normalizeGreetingText(query))
if text == "" {
return false
}
for _, material := range materials {
fields := []string{
material.Title,
filepath.Base(material.Path),
strings.TrimSuffix(filepath.Base(material.Path), filepath.Ext(material.Path)),
}
for _, keyword := range append(material.Keywords, material.QuestionPatterns...) {
if !isGenericMaterialIntentToken(keyword) {
fields = append(fields, keyword)
}
}
for _, field := range fields {
field = strings.ToLower(normalizeGreetingText(field))
if len([]rune(field)) >= 3 && strings.Contains(text, field) {
return true
}
}
}
return false
}
func isGenericMaterialIntentToken(token string) bool {
token = normalizeGreetingText(token)
if token == "" {
return true
}
switch token {
case "资料", "文件", "文档", "附件", "素材", "手册", "说明书", "宣传册",
"方案", "模板", "案例", "清单", "表格", "图片", "照片", "截图",
"视频", "录像", "ppt", "pptx", "pdf", "doc", "docx", "xls", "xlsx",
"发我", "给我", "需要", "有没有", "我要", "发一下":
return true
default:
return false
}
}
func fuzzyMaterialTokenScore(searchText string, field string) int {
tokens := materialSearchTokens(field)
if len(tokens) == 0 {
return 0
}
score := 0
for _, token := range tokens {
if len([]rune(token)) < 2 {
continue
}
if isGenericMaterialIntentToken(token) {
continue
}
if strings.Contains(searchText, token) {
score += 2
}
}
return score
}
func materialSearchTokens(text string) []string {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" {
return nil
}
separators := func(r rune) bool {
return !(unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r))
}
parts := strings.FieldsFunc(text, separators)
result := make([]string, 0, len(parts)*2)
for _, part := range parts {
part = strings.TrimSpace(part)
if len([]rune(part)) < 2 {
continue
}
result = append(result, part)
runes := []rune(part)
if len(runes) > 4 {
for i := 0; i+2 <= len(runes); i++ {
result = append(result, string(runes[i:i+2]))
}
}
}
return dedupeNonEmptyStrings(result)
}
func resolveAutoReplyMaterialPath(root string, materialPath string) string {
materialPath = strings.TrimSpace(materialPath)
if filepath.IsAbs(materialPath) {
return filepath.Clean(materialPath)
}
return filepath.Join(resolveAutoReplyPath(root), filepath.Clean(materialPath))
}
func inferMaterialType(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".jpg", ".jpeg", ".png", ".bmp", ".webp":
return "image"
case ".gif":
return "gif"
case ".mp4", ".mov", ".avi", ".mkv", ".wmv":
return "video"
case ".json":
return ""
default:
return "file"
}
}
func (e *AutoReplyEngine) sendMaterials(msg autoReplyMessage, matches []autoReplyMaterialMatch, reason string, timings autoReplyTimings) error {
if len(matches) == 0 {
return nil
}
captions := make([]string, 0, len(matches))
for _, match := range matches {
if caption := customMaterialCaptionForSend(match.Material); caption != "" {
captions = append(captions, caption)
}
}
if len(captions) == 0 {
captions = append(captions, combinedMaterialCaption(matches))
}
caption := strings.Join(uniqueMaterialStrings(captions), "\n")
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, caption); err != nil {
return err
}
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, caption)
sent := make([]string, 0, len(matches))
for _, match := range matches {
if err := sendAutoReplyMaterial(uint32(msg.ClientID), msg.ConversationID, match.Material.MaterialType, match.Path); err != nil {
return fmt.Errorf("send material %s failed: %w", match.Path, err)
}
sent = append(sent, fmt.Sprintf("%s:%s", match.Material.MaterialType, match.Path))
}
e.markCooldown(msg)
e.incStatus("replied")
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: "replied",
Reason: reason,
Answer: strings.Join(sent, "\n"),
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 nil
}
func materialCaptionForSend(material AutoReplyMaterial) string {
if caption := customMaterialCaptionForSend(material); caption != "" {
return caption
}
return defaultMaterialCaption(material.MaterialType)
}
func customMaterialCaptionForSend(material AutoReplyMaterial) string {
caption := strings.TrimSpace(material.Caption)
if caption != "" && !isLegacyGenericMaterialCaption(caption) {
return caption
}
return ""
}
func isLegacyGenericMaterialCaption(caption string) bool {
text := normalizeGreetingText(caption)
switch text {
case normalizeGreetingText("我把相关资料直接发你。"),
normalizeGreetingText("我把相关资料发你。"):
return true
default:
return false
}
}
func defaultMaterialCaption(materialType string) string {
switch strings.ToLower(strings.TrimSpace(materialType)) {
case "image":
return "我把图片发你。"
case "video":
return "我把视频发你。"
case "gif":
return "我把动图发你。"
default:
return "我把文件发你。"
}
}
func combinedMaterialCaption(matches []autoReplyMaterialMatch) string {
if len(matches) == 0 {
return "我把文件发你。"
}
seen := map[string]bool{}
labels := make([]string, 0, 4)
add := func(materialType string, label string) {
if !seen[materialType] {
seen[materialType] = true
labels = append(labels, label)
}
}
for _, match := range matches {
switch strings.ToLower(strings.TrimSpace(match.Material.MaterialType)) {
case "image":
add("image", "图片")
case "video":
add("video", "视频")
case "gif":
add("gif", "动图")
default:
add("file", "文件")
}
}
if len(labels) == 1 {
return defaultMaterialCaption(matches[0].Material.MaterialType)
}
return "我把" + strings.Join(labels, "和") + "发你。"
}
func uniqueMaterialStrings(items []string) []string {
seen := make(map[string]bool, len(items))
result := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
result = append(result, item)
}
return result
}
var sendAutoReplyMaterialSender = sendAutoReplyMaterialRequest
func sendAutoReplyMaterial(clientID uint32, conversationID string, materialType string, path string) error {
return sendAutoReplyMaterialSender(clientID, conversationID, materialType, path)
}
func sendAutoReplyMaterialRequest(clientID uint32, conversationID string, materialType string, path string) error {
if strings.TrimSpace(conversationID) == "" {
return fmt.Errorf("conversationId is empty")
}
if strings.TrimSpace(path) == "" {
return fmt.Errorf("material path is empty")
}
messageType := 11031
switch strings.ToLower(strings.TrimSpace(materialType)) {
case "image":
messageType = 11030
case "video":
messageType = 11067
case "gif":
messageType = 11070
case "file":
messageType = 11031
default:
messageType = 11031
}
request := map[string]interface{}{
"type": messageType,
"data": map[string]interface{}{
"conversation_id": conversationID,
"file": path,
},
}
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
}