diff --git a/config/config.example.yaml b/config/config.example.yaml index fed6eb4..b538399 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -52,8 +52,8 @@ mcp: headers: {} wxWork: - # 是否启用企业微信登录能力。 - # false 时不会初始化企业微信 SDK,/api/auth/wxwork/* 接口不可用。 + # 是否启用企业微信能力。 + # false 时不会初始化企业微信 SDK,登录、客服回调、应用通知都不可用。 enabled: false # 企业微信公司 ID。 # 例如:wwxxxxxxxxxxxxxxxx,来自企业微信管理后台。 @@ -82,3 +82,19 @@ wxWork: # 企业微信消息加解密 EncodingAESKey。 # 当前登录流程未使用,保留给消息回调等场景。 encodingAESKey: "" + + notify: + # 是否启用企业微信应用消息通知。 + enabled: false + # 默认接收通知的成员ID列表;如业务目标用户已绑定企业微信身份,会优先发给目标用户。 + toUsers: [] + # 默认接收通知的部门ID列表。 + toParties: [] + # 默认接收通知的标签ID列表。 + toTags: [] + # 是否发送保密消息。 + safe: false + # 是否开启重复消息检查。 + enableDuplicateCheck: true + # 重复消息检查时间窗口,单位秒。 + duplicateCheckInterval: 1800 diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 225c84b..b2b2a7e 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -19,6 +19,16 @@ type Config struct { WxWork WxWorkConfig `yaml:"wxWork"` } +type WxWorkNotifyConfig struct { + Enabled bool `yaml:"enabled"` + ToUsers []string `yaml:"toUsers"` + ToParties []string `yaml:"toParties"` + ToTags []string `yaml:"toTags"` + Safe bool `yaml:"safe"` + EnableDuplicateCheck bool `yaml:"enableDuplicateCheck"` + DuplicateCheckInterval int `yaml:"duplicateCheckInterval"` +} + type ServerConfig struct { Port int `yaml:"port"` } @@ -140,6 +150,8 @@ type WxWorkConfig struct { // EncodingAESKey 为企业微信消息加解密密钥。 // 当前登录流程未使用,保留给消息回调等场景。 EncodingAESKey string `yaml:"encodingAESKey"` + // Notify 为企业微信应用消息通知配置。 + Notify WxWorkNotifyConfig `yaml:"notify"` } func Load(path string) (*Config, error) { diff --git a/internal/services/conversation_dispatch_service.go b/internal/services/conversation_dispatch_service.go index 03191fc..44225a0 100644 --- a/internal/services/conversation_dispatch_service.go +++ b/internal/services/conversation_dispatch_service.go @@ -121,6 +121,7 @@ func (s *conversationDispatchService) DispatchPendingConversation(conversation * "requested_team_ids", report.RequestedTeamIDs, ) WsService.PublishConversationChanged(dispatched, enums.IMRealtimeEventConversationAssigned) + WxWorkNotifyService.NotifyConversationAssigned(dispatched.ID, dispatched.CurrentAssigneeID, "自动分配") return dispatched, nil } } diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go index d6eb84f..b57fecc 100644 --- a/internal/services/conversation_service.go +++ b/internal/services/conversation_service.go @@ -199,6 +199,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR if conversation := s.Get(req.ConversationID); conversation != nil { WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationAssigned) } + WxWorkNotifyService.NotifyConversationAssigned(req.ConversationID, req.AssigneeID, req.Reason) return nil } @@ -285,6 +286,7 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6 if conversation := s.Get(conversationID); conversation != nil { WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationTransferred) } + WxWorkNotifyService.NotifyConversationAssigned(conversationID, toUserID, reason) return nil } diff --git a/internal/services/ticket_service.go b/internal/services/ticket_service.go index 1194842..5610dee 100644 --- a/internal/services/ticket_service.go +++ b/internal/services/ticket_service.go @@ -831,7 +831,9 @@ func (s *ticketService) CreateTicket(req request.CreateTicketRequest, operator * }); err != nil { return nil, err } - return s.Get(ticket.ID), nil + current := s.Get(ticket.ID) + WxWorkNotifyService.NotifyTicketCreated(ticket.ID) + return current, nil } func (s *ticketService) CreateFromConversation(req request.CreateTicketFromConversationRequest, operator *dto.AuthPrincipal) (*models.Ticket, error) { @@ -981,9 +983,13 @@ func (s *ticketService) LinkTicketCustomer(ticketID, customerID int64, operator } func (s *ticketService) AssignTicket(req request.AssignTicketRequest, operator *dto.AuthPrincipal) error { - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { return s.assignTicketTx(ctx.Tx, req, operator) - }) + }); err != nil { + return err + } + WxWorkNotifyService.NotifyTicketAssigned(req.TicketID, req.ToUserID, req.Reason) + return nil } func (s *ticketService) assignTicketTx(tx *gorm.DB, req request.AssignTicketRequest, operator *dto.AuthPrincipal) error { diff --git a/internal/services/wxwork_notify_service.go b/internal/services/wxwork_notify_service.go new file mode 100644 index 0000000..e6d449e --- /dev/null +++ b/internal/services/wxwork_notify_service.go @@ -0,0 +1,339 @@ +package services + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/config" + "cs-agent/internal/pkg/enums" + "cs-agent/internal/repositories" + "cs-agent/internal/wxwork" + + "github.com/mlogclub/simple/sqls" + wxmessage "github.com/silenceper/wechat/v2/work/message" +) + +var WxWorkNotifyService = newWxWorkNotifyService() + +type wxWorkMessageSender interface { + SendText(request wxmessage.SendTextRequest) (*wxmessage.SendResponse, error) +} + +type wxWorkNotifyRecipients struct { + ToUsers []string + ToParties []string + ToTags []string +} + +type wxWorkNotifyService struct { + senderFactory func() (wxWorkMessageSender, error) +} + +func newWxWorkNotifyService() *wxWorkNotifyService { + return &wxWorkNotifyService{ + senderFactory: func() (wxWorkMessageSender, error) { + if !wxwork.Enabled() || wxwork.GetWorkCli() == nil { + return nil, fmt.Errorf("wxwork is not enabled") + } + return wxwork.GetWorkCli().GetMessage(), nil + }, + } +} + +func (s *wxWorkNotifyService) NotifyConversationAssigned(conversationID, assigneeID int64, reason string) { + if conversationID <= 0 { + return + } + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return + } + if err := s.sendToAssigneeOrDefault(assigneeID, "会话分配提醒", s.buildConversationAssignedBody(conversation, assigneeID, reason)); err != nil { + slog.Warn("send wxwork conversation assignment notify failed", + "conversation_id", conversationID, + "assignee_id", assigneeID, + "error", err, + ) + } +} + +func (s *wxWorkNotifyService) NotifyTicketCreated(ticketID int64) { + if ticketID <= 0 { + return + } + ticket := TicketService.Get(ticketID) + if ticket == nil { + return + } + if err := s.sendToAssigneeOrDefault(ticket.CurrentAssigneeID, "工单创建提醒", s.buildTicketCreatedBody(ticket)); err != nil { + slog.Warn("send wxwork ticket created notify failed", + "ticket_id", ticketID, + "assignee_id", ticket.CurrentAssigneeID, + "error", err, + ) + } +} + +func (s *wxWorkNotifyService) NotifyTicketAssigned(ticketID, assigneeID int64, reason string) { + if ticketID <= 0 || assigneeID <= 0 { + return + } + ticket := TicketService.Get(ticketID) + if ticket == nil { + return + } + if err := s.sendToAssigneeOrDefault(assigneeID, "工单指派提醒", s.buildTicketAssignedBody(ticket, assigneeID, reason)); err != nil { + slog.Warn("send wxwork ticket assigned notify failed", + "ticket_id", ticketID, + "assignee_id", assigneeID, + "error", err, + ) + } +} + +func (s *wxWorkNotifyService) Enabled() bool { + if !wxwork.Enabled() { + return false + } + return config.Current().WxWork.Notify.Enabled +} + +func (s *wxWorkNotifyService) sendToAssigneeOrDefault(assigneeID int64, title, body string) error { + if !s.Enabled() { + return nil + } + recipients := s.resolveRecipientsByUserIDs([]int64{assigneeID}) + if recipients.empty() { + recipients = s.defaultRecipients() + } + if recipients.empty() { + return nil + } + return s.sendText(title, body, recipients) +} + +func (s *wxWorkNotifyService) sendText(title, body string, recipients wxWorkNotifyRecipients) error { + if !s.Enabled() { + return nil + } + content := s.buildTextContent(title, body) + if content == "" { + return nil + } + sender, err := s.senderFactory() + if err != nil { + return err + } + cfg := config.Current().WxWork + req := wxmessage.SendTextRequest{ + SendRequestCommon: &wxmessage.SendRequestCommon{ + ToUser: strings.Join(recipients.ToUsers, "|"), + ToParty: strings.Join(recipients.ToParties, "|"), + ToTag: strings.Join(recipients.ToTags, "|"), + AgentID: strings.TrimSpace(cfg.AgentID), + Safe: boolToInt(cfg.Notify.Safe), + EnableDuplicateCheck: boolToInt(cfg.Notify.EnableDuplicateCheck), + DuplicateCheckInterval: s.normalizeDuplicateCheckInterval(cfg.Notify.DuplicateCheckInterval), + }, + Text: wxmessage.TextField{Content: content}, + } + _, err = sender.SendText(req) + return err +} + +func (s *wxWorkNotifyService) resolveRecipientsByUserIDs(userIDs []int64) wxWorkNotifyRecipients { + userIDs = uniqueInt64s(userIDs) + if len(userIDs) == 0 { + return wxWorkNotifyRecipients{} + } + cfg := config.Current().WxWork + identities := repositories.UserIdentityRepository.Find(sqls.DB(), sqls.NewCnd(). + Eq("provider", enums.ThirdProviderWxWork). + Eq("provider_corp_id", strings.TrimSpace(cfg.CorpID)). + Eq("status", enums.StatusOk). + In("user_id", userIDs). + Asc("id")) + recipients := wxWorkNotifyRecipients{} + for i := range identities { + if receiver := strings.TrimSpace(identities[i].ProviderUserID); receiver != "" { + recipients.ToUsers = append(recipients.ToUsers, receiver) + } + } + recipients.ToUsers = uniqueStrings(recipients.ToUsers) + return recipients +} + +func (s *wxWorkNotifyService) defaultRecipients() wxWorkNotifyRecipients { + cfg := config.Current().WxWork.Notify + return wxWorkNotifyRecipients{ + ToUsers: uniqueStrings(cfg.ToUsers), + ToParties: uniqueStrings(cfg.ToParties), + ToTags: uniqueStrings(cfg.ToTags), + } +} + +func (s *wxWorkNotifyService) buildConversationAssignedBody(conversation *models.Conversation, assigneeID int64, reason string) string { + if conversation == nil { + return "" + } + lines := []string{ + fmt.Sprintf("会话ID: #%d", conversation.ID), + fmt.Sprintf("会话主题: %s", defaultIfBlank(conversation.Subject, "-")), + fmt.Sprintf("接入渠道: %s", enums.GetExternalSourceLabel(conversation.ExternalSource)), + fmt.Sprintf("当前状态: %s", enums.GetIMConversationStatusLabel(conversation.Status)), + fmt.Sprintf("处理人: %s", s.resolveUserLabel(assigneeID)), + } + if strings.TrimSpace(reason) != "" { + lines = append(lines, fmt.Sprintf("分配原因: %s", strings.TrimSpace(reason))) + } + lines = append(lines, fmt.Sprintf("时间: %s", time.Now().Format("2006-01-02 15:04:05"))) + return strings.Join(lines, "\n") +} + +func (s *wxWorkNotifyService) buildTicketCreatedBody(ticket *models.Ticket) string { + if ticket == nil { + return "" + } + lines := []string{ + fmt.Sprintf("工单号: %s", defaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID))), + fmt.Sprintf("工单标题: %s", defaultIfBlank(ticket.Title, "-")), + fmt.Sprintf("工单来源: %s", defaultIfBlank(string(ticket.Source), "-")), + fmt.Sprintf("当前状态: %s", enums.GetTicketStatusLabel(ticket.Status)), + } + if ticket.CurrentAssigneeID > 0 { + lines = append(lines, fmt.Sprintf("处理人: %s", s.resolveUserLabel(ticket.CurrentAssigneeID))) + } + lines = append(lines, fmt.Sprintf("时间: %s", time.Now().Format("2006-01-02 15:04:05"))) + return strings.Join(lines, "\n") +} + +func (s *wxWorkNotifyService) buildTicketAssignedBody(ticket *models.Ticket, assigneeID int64, reason string) string { + if ticket == nil { + return "" + } + lines := []string{ + fmt.Sprintf("工单号: %s", defaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID))), + fmt.Sprintf("工单标题: %s", defaultIfBlank(ticket.Title, "-")), + fmt.Sprintf("当前状态: %s", enums.GetTicketStatusLabel(ticket.Status)), + fmt.Sprintf("处理人: %s", s.resolveUserLabel(assigneeID)), + } + if strings.TrimSpace(reason) != "" { + lines = append(lines, fmt.Sprintf("指派原因: %s", strings.TrimSpace(reason))) + } + lines = append(lines, fmt.Sprintf("时间: %s", time.Now().Format("2006-01-02 15:04:05"))) + return strings.Join(lines, "\n") +} + +func (s *wxWorkNotifyService) resolveUserLabel(userID int64) string { + if userID <= 0 { + return "-" + } + user := UserService.Get(userID) + if user == nil { + return fmt.Sprintf("用户#%d", userID) + } + if nickname := strings.TrimSpace(user.Nickname); nickname != "" { + return nickname + } + if username := strings.TrimSpace(user.Username); username != "" { + return username + } + return fmt.Sprintf("用户#%d", userID) +} + +func (s *wxWorkNotifyService) buildTextContent(title, body string) string { + title = strings.TrimSpace(title) + body = strings.TrimSpace(body) + switch { + case title == "" && body == "": + return "" + case title == "": + return truncateRunes(body, 1024) + case body == "": + return truncateRunes(title, 1024) + default: + return truncateRunes(title+"\n\n"+body, 1024) + } +} + +func (s *wxWorkNotifyService) normalizeDuplicateCheckInterval(value int) int { + if value <= 0 { + return 1800 + } + if value > 14400 { + return 14400 + } + return value +} + +func (r wxWorkNotifyRecipients) empty() bool { + return len(r.ToUsers) == 0 && len(r.ToParties) == 0 && len(r.ToTags) == 0 +} + +func uniqueStrings(values []string) []string { + if len(values) == 0 { + return nil + } + ret := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + item := strings.TrimSpace(value) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + ret = append(ret, item) + } + return ret +} + +func uniqueInt64s(values []int64) []int64 { + if len(values) == 0 { + return nil + } + ret := make([]int64, 0, len(values)) + seen := make(map[int64]struct{}, len(values)) + for _, value := range values { + if value <= 0 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + ret = append(ret, value) + } + return ret +} + +func truncateRunes(value string, max int) string { + if max <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(value)) + if len(runes) <= max { + return string(runes) + } + return string(runes[:max]) +} + +func defaultIfBlank(value, fallback string) string { + value = strings.TrimSpace(value) + if value != "" { + return value + } + return strings.TrimSpace(fallback) +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/internal/services/wxwork_notify_service_test.go b/internal/services/wxwork_notify_service_test.go new file mode 100644 index 0000000..978cb64 --- /dev/null +++ b/internal/services/wxwork_notify_service_test.go @@ -0,0 +1,53 @@ +package services + +import ( + "testing" + + "cs-agent/internal/pkg/config" +) + +func TestWxWorkNotifyBuildTextContent(t *testing.T) { + svc := newWxWorkNotifyService() + got := svc.buildTextContent("工单提醒", "这是一条测试消息") + if got != "工单提醒\n\n这是一条测试消息" { + t.Fatalf("unexpected content: %q", got) + } +} + +func TestWxWorkNotifyDefaultRecipients(t *testing.T) { + config.SetCurrent(&config.Config{ + WxWork: config.WxWorkConfig{ + Notify: config.WxWorkNotifyConfig{ + Enabled: true, + ToUsers: []string{" user_a ", "user_a", ""}, + ToParties: []string{"2", "2"}, + ToTags: []string{"tag-1"}, + }, + }, + }) + + svc := newWxWorkNotifyService() + recipients := svc.defaultRecipients() + if len(recipients.ToUsers) != 1 || recipients.ToUsers[0] != "user_a" { + t.Fatalf("unexpected users: %#v", recipients.ToUsers) + } + if len(recipients.ToParties) != 1 || recipients.ToParties[0] != "2" { + t.Fatalf("unexpected parties: %#v", recipients.ToParties) + } + if len(recipients.ToTags) != 1 || recipients.ToTags[0] != "tag-1" { + t.Fatalf("unexpected tags: %#v", recipients.ToTags) + } +} + +func TestWxWorkNotifyNormalizeDuplicateCheckInterval(t *testing.T) { + svc := newWxWorkNotifyService() + if got := svc.normalizeDuplicateCheckInterval(0); got != 1800 { + t.Fatalf("expected default interval 1800, got %d", got) + } + if got := svc.normalizeDuplicateCheckInterval(20000); got != 14400 { + t.Fatalf("expected capped interval 14400, got %d", got) + } + if got := svc.normalizeDuplicateCheckInterval(600); got != 600 { + t.Fatalf("expected interval 600, got %d", got) + } +}