Files
ai-agent/internal/services/wxwork_kf_inbound_service.go
T
mlogclub 0b4a1b4594 Refactor error handling in services to use internationalized error messages
- Updated OSSStorage validation errors to use internationalized messages.
- Changed error messages in provider.go for unsupported file storage types.
- Refactored tag_service.go to replace hardcoded error messages with internationalized versions.
- Updated ticket_service.go to use internationalized error messages for various validation checks.
- Refactored ticket_tag_service.go to use internationalized error messages for tag validation.
- Changed ticket_view_service.go to use internationalized error messages for view validation.
- Updated tool_catalog_service.go to use internationalized error messages for tool code validation.
- Refactored user_service.go to replace error messages with internationalized versions.
- Updated ws_service.go to use internationalized error messages for WebSocket handling.
- Refactored wxwork_kf_inbound_service.go to use internationalized error messages for message handling.
- Updated wxwork_kf_outbound_service.go to use internationalized error messages for outbound message handling.
- Refactored wxwork_login_service.go to use internationalized error messages for login handling.
- Updated login.go in wxwork package to use internationalized error messages for login state and ticket validation.
2026-06-02 20:51:13 +08:00

622 lines
19 KiB
Go

package services
import (
"encoding/json"
"log/slog"
"strings"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/wxwork"
"github.com/mlogclub/simple/common/strs"
"github.com/silenceper/wechat/v2/work/kf"
"github.com/silenceper/wechat/v2/work/kf/syncmsg"
)
const (
wxWorkKFSystemOperatorName = "wxwork_kf"
wxWorkKFSyncMsgLimit = 1000
)
var WxWorkKFInboundService = newWxWorkKFInboundService()
func newWxWorkKFInboundService() *wxWorkKFInboundService {
return &wxWorkKFInboundService{}
}
type wxWorkKFInboundService struct {
}
func (s *wxWorkKFInboundService) SyncCallbackMessages(message kf.CallbackMessage) error {
cli, err := wxwork.GetWorkCli().GetKF()
if err != nil {
return err
}
cursor := ""
if state := WxWorkKFSyncStateService.GetByOpenKfID(message.OpenKfID); state != nil {
cursor = strings.TrimSpace(state.NextCursor)
}
for {
result, syncErr := cli.SyncMsg(kf.SyncMsgOptions{
Cursor: cursor,
Token: message.Token,
Limit: wxWorkKFSyncMsgLimit,
OpenKfID: message.OpenKfID,
})
if syncErr != nil {
return syncErr
}
for _, item := range result.MsgList {
if err := s.consumeSyncMessage(item); err != nil {
slog.Error("consume wxwork kf sync message failed",
"open_kfid", item.OpenKFID,
"external_userid", item.ExternalUserID,
"msg_id", item.MsgID,
"msg_type", item.MsgType,
"event", item.EventType,
"error", err,
)
}
}
if err := s.saveNextCursor(message.OpenKfID, result.NextCursor); err != nil {
return err
}
if result.HasMore != 1 || strings.TrimSpace(result.NextCursor) == "" {
return nil
}
cursor = result.NextCursor
}
}
func (s *wxWorkKFInboundService) consumeSyncMessage(item syncmsg.Message) error {
msgID := strings.TrimSpace(item.MsgID)
if msgID == "" {
return errorsx.InvalidParamI18n("error.e0101")
}
if WxWorkKFMessageRefService.Take("wx_msg_id = ?", msgID) != nil {
return nil
}
switch strings.TrimSpace(item.MsgType) {
case "text":
return s.handleTextMessage(item)
case "image":
return s.handleImageMessage(item)
case "file":
return s.handleFileMessage(item)
case "event":
return s.handleEventMessage(item)
default:
return s.handleUnsupportedMessage(item)
}
}
func (s *wxWorkKFInboundService) handleTextMessage(item syncmsg.Message) error {
payload := syncmsg.Text{}
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
return err
}
conversation, err := s.ensureConversation(payload.BaseMessage, map[string]any{
"msgType": payload.MsgType,
"menuId": payload.Text.MenuID,
})
if err != nil {
return err
}
message, err := MessageService.SendCustomerMessage(
conversation.ID,
s.buildInboundClientMsgID(item.MsgID),
enums.IMMessageTypeText,
strings.TrimSpace(payload.Text.Content),
"",
s.buildExternalUser(payload.ExternalUserID),
)
if err != nil {
return err
}
return s.createMessageRef(conversation.ID, message.ID, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived)
}
func (s *wxWorkKFInboundService) handleImageMessage(item syncmsg.Message) error {
payload := syncmsg.Image{}
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
return err
}
conversation, err := s.ensureConversation(payload.BaseMessage, map[string]any{
"msgType": payload.MsgType,
"mediaId": payload.Image.MediaID,
})
if err != nil {
return err
}
canonicalPayload, content, err := s.buildInboundAssetPayload(conversation.ID, strings.TrimSpace(payload.Image.MediaID))
if err != nil {
return err
}
message, err := MessageService.SendCustomerMessage(
conversation.ID,
s.buildInboundClientMsgID(item.MsgID),
enums.IMMessageTypeImage,
content,
canonicalPayload,
s.buildExternalUser(payload.ExternalUserID),
)
if err != nil {
return err
}
return s.createMessageRef(conversation.ID, message.ID, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived)
}
func (s *wxWorkKFInboundService) handleFileMessage(item syncmsg.Message) error {
payload := syncmsg.File{}
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
return err
}
conversation, err := s.ensureConversation(payload.BaseMessage, map[string]any{
"msgType": payload.MsgType,
"mediaId": payload.File.MediaID,
})
if err != nil {
return err
}
canonicalPayload, content, err := s.buildInboundAssetPayload(conversation.ID, strings.TrimSpace(payload.File.MediaID))
if err != nil {
return err
}
message, err := MessageService.SendCustomerMessage(
conversation.ID,
s.buildInboundClientMsgID(item.MsgID),
enums.IMMessageTypeAttachment,
content,
canonicalPayload,
s.buildExternalUser(payload.ExternalUserID),
)
if err != nil {
return err
}
return s.createMessageRef(conversation.ID, message.ID, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived)
}
func (s *wxWorkKFInboundService) handleUnsupportedMessage(item syncmsg.Message) error {
base, err := s.parseBaseMessage(item.OriginData)
if err != nil {
return err
}
conversation, convErr := s.ensureConversation(base, map[string]any{"msgType": item.MsgType})
if convErr != nil {
return convErr
}
content := s.buildUnsupportedContent(item.MsgType)
message, err := MessageService.SendCustomerMessage(
conversation.ID,
s.buildInboundClientMsgID(item.MsgID),
enums.IMMessageTypeText,
content,
string(item.OriginData),
s.buildExternalUser(base.ExternalUserID),
)
if err != nil {
return err
}
return s.createMessageRef(conversation.ID, message.ID, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived)
}
func (s *wxWorkKFInboundService) handleEventMessage(item syncmsg.Message) error {
switch strings.TrimSpace(item.EventType) {
case enums.WxWorkKFEventTypeEnterSession:
return s.handleEnterSessionEvent(item)
case enums.WxWorkKFEventTypeSessionStatusChange:
return s.handleSessionStatusChangeEvent(item)
case enums.WxWorkKFEventTypeMsgSendFail:
return s.handleMsgSendFailEvent(item)
default:
return s.recordOrphanEvent(item, "收到未处理的企业微信事件")
}
}
func (s *wxWorkKFInboundService) handleEnterSessionEvent(item syncmsg.Message) error {
payload := syncmsg.EnterSessionEvent{}
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
return err
}
base := s.normalizeEventBaseMessage(payload.BaseMessage, payload.Event.OpenKFID, payload.Event.ExternalUserID)
conversation, err := s.ensureConversation(base, map[string]any{
"scene": payload.Event.Scene,
"sceneParam": payload.Event.SceneParam,
"welcomeCode": payload.Event.WelcomeCode,
})
if err != nil {
return err
}
if err := s.createMessageRef(conversation.ID, 0, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived); err != nil {
return err
}
return s.appendConversationEvent(conversation.ID, "微信客户进入会话", string(item.OriginData))
}
func (s *wxWorkKFInboundService) handleSessionStatusChangeEvent(item syncmsg.Message) error {
payload := syncmsg.SessionStatusChangeEvent{}
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
return err
}
base := s.normalizeEventBaseMessage(payload.BaseMessage, payload.Event.OpenKFID, payload.Event.ExternalUserID)
base.ReceptionistUserID = payload.Event.NewReceptionistUserID
conversation, err := s.ensureConversation(base, map[string]any{
"changeType": payload.Event.ChangeType,
"msgCode": payload.Event.MsgCode,
})
if err != nil {
return err
}
sessionStatus := enums.WxWorkKFSessionStatusActive
switch payload.Event.ChangeType {
case 2:
sessionStatus = enums.WxWorkKFSessionStatusTransfer
case 3:
sessionStatus = enums.WxWorkKFSessionStatusClosed
}
channel, channelErr := s.getChannelByOpenKfID(payload.Event.OpenKFID)
if channelErr != nil {
return channelErr
}
if err := s.upsertConversationMapping(conversation.ID, channel.ID, payload.Event.OpenKFID, payload.Event.ExternalUserID, payload.Event.NewReceptionistUserID, sessionStatus, payload.SendTime, payload.MsgID, string(item.OriginData)); err != nil {
return err
}
if err := s.createMessageRef(conversation.ID, 0, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived); err != nil {
return err
}
return s.appendConversationEvent(conversation.ID, "微信会话状态变更", string(item.OriginData))
}
func (s *wxWorkKFInboundService) handleMsgSendFailEvent(item syncmsg.Message) error {
payload := syncmsg.MsgSendFailEvent{}
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
return err
}
slog.Warn("received wxwork msg_send_fail event",
slog.String("msg", string(item.OriginData)),
)
base := s.normalizeEventBaseMessage(payload.BaseMessage, payload.Event.OpenKFID, payload.Event.ExternalUserID)
conversation, err := s.ensureConversation(base, map[string]any{
"failMsgId": payload.Event.FailMsgID,
"failType": payload.Event.FailType,
})
if err != nil {
return err
}
if ref := WxWorkKFMessageRefService.GetByWxMsgID(payload.Event.FailMsgID); ref != nil {
targetMessageID := ref.MessageID
slog.Warn("mark wxwork message ref failed",
"conversation_id", ref.ConversationID,
"message_id", ref.MessageID,
"wx_msg_id", ref.WxMsgID,
"fail_type", payload.Event.FailType,
"open_kfid", payload.Event.OpenKFID,
"external_userid", payload.Event.ExternalUserID,
)
_ = WxWorkKFMessageRefService.Updates(ref.ID, map[string]any{
"send_status": enums.WxWorkKFMessageSendStatusFailed,
"fail_reason": string(item.OriginData),
"updated_at": time.Now(),
})
if outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeWxWorkKF, targetMessageID); outbox != nil {
if err := WxWorkKFOutboundService.markOutboxFailed(outbox, string(item.OriginData)); err != nil {
slog.Warn("mark wxwork outbox failed from callback event failed",
"outbox_id", outbox.ID,
"conversation_id", outbox.ConversationID,
"message_id", outbox.MessageID,
"fail_msg_id", payload.Event.FailMsgID,
"fail_type", payload.Event.FailType,
"error", err,
)
} else {
slog.Warn("mark wxwork outbox failed from callback event",
"outbox_id", outbox.ID,
"conversation_id", outbox.ConversationID,
"message_id", outbox.MessageID,
"fail_msg_id", payload.Event.FailMsgID,
"fail_type", payload.Event.FailType,
)
}
} else {
slog.Warn("wxwork msg_send_fail event missing outbox",
"message_id", targetMessageID,
"fail_msg_id", payload.Event.FailMsgID,
"fail_type", payload.Event.FailType,
)
}
} else {
slog.Warn("wxwork msg_send_fail event missing message ref",
"fail_msg_id", payload.Event.FailMsgID,
"fail_type", payload.Event.FailType,
"open_kfid", payload.Event.OpenKFID,
"external_userid", payload.Event.ExternalUserID,
)
}
if err := s.createMessageRef(conversation.ID, 0, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived); err != nil {
return err
}
return s.appendConversationEvent(conversation.ID, "微信消息发送失败事件", string(item.OriginData))
}
func (s *wxWorkKFInboundService) recordOrphanEvent(item syncmsg.Message, content string) error {
base, err := s.parseBaseMessage(item.OriginData)
if err != nil {
return err
}
conversation, convErr := s.ensureConversation(base, map[string]any{"eventType": item.EventType})
if convErr != nil {
return convErr
}
if err := s.createMessageRef(conversation.ID, 0, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived); err != nil {
return err
}
return s.appendConversationEvent(conversation.ID, content, string(item.OriginData))
}
func (s *wxWorkKFInboundService) ensureConversation(base syncmsg.BaseMessage, profile map[string]any) (*models.Conversation, error) {
externalID := strings.TrimSpace(base.ExternalUserID)
if externalID == "" {
return nil, errorsx.InvalidParamI18n("error.e0096")
}
channel, err := s.getChannelByOpenKfID(base.OpenKFID)
if err != nil {
return nil, err
}
external := s.buildExternalUser(externalID)
conversation, err := ConversationService.Create(external, channel.ID, channel.AIAgentID)
if err != nil {
return nil, err
}
if err := s.upsertConversationMapping(
conversation.ID,
channel.ID,
base.OpenKFID,
base.ExternalUserID,
base.ReceptionistUserID,
enums.WxWorkKFSessionStatusActive,
base.SendTime,
base.MsgID,
s.mustMarshal(profile),
); err != nil {
return nil, err
}
return conversation, nil
}
func (s *wxWorkKFInboundService) upsertConversationMapping(conversationID, channelID int64, openKfID, externalUserID, servicerUserID string, sessionStatus enums.WxWorkKFSessionStatus, sendTime uint64, lastMsgID, rawProfile string) error {
now := time.Now()
lastMsgTime := s.parseSendTime(sendTime)
existing := WxWorkKFConversationService.Take("conversation_id = ?", conversationID)
if existing != nil {
updates := map[string]any{
"channel_id": channelID,
"open_kf_id": strings.TrimSpace(openKfID),
"external_user_id": strings.TrimSpace(externalUserID),
"servicer_user_id": strings.TrimSpace(servicerUserID),
"session_status": string(sessionStatus),
"last_wx_msg_id": strings.TrimSpace(lastMsgID),
"updated_at": now,
"status": enums.StatusOk,
}
if lastMsgTime != nil {
updates["last_wx_msg_time"] = *lastMsgTime
}
if strings.TrimSpace(rawProfile) != "" {
updates["raw_profile"] = rawProfile
}
return WxWorkKFConversationService.Updates(existing.ID, updates)
}
return WxWorkKFConversationService.Create(&models.WxWorkKFConversation{
ConversationID: conversationID,
ChannelID: channelID,
OpenKfID: strings.TrimSpace(openKfID),
ExternalUserID: strings.TrimSpace(externalUserID),
ServicerUserID: strings.TrimSpace(servicerUserID),
SessionStatus: string(sessionStatus),
LastWxMsgID: strings.TrimSpace(lastMsgID),
LastWxMsgTime: lastMsgTime,
RawProfile: strings.TrimSpace(rawProfile),
Status: enums.StatusOk,
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: 0,
CreateUserName: wxWorkKFSystemOperatorName,
UpdatedAt: now,
UpdateUserID: 0,
UpdateUserName: wxWorkKFSystemOperatorName,
},
})
}
func (s *wxWorkKFInboundService) createMessageRef(conversationID, messageID int64, item syncmsg.Message, direction enums.WxWorkKFMessageDirection, sendStatus enums.WxWorkKFMessageSendStatus) error {
if WxWorkKFMessageRefService.Take("wx_msg_id = ?", item.MsgID) != nil {
return nil
}
now := time.Now()
return WxWorkKFMessageRefService.Create(&models.WxWorkKFMessageRef{
ConversationID: conversationID,
MessageID: messageID,
WxMsgID: strings.TrimSpace(item.MsgID),
Direction: string(direction),
Origin: int(item.Origin),
OpenKfID: strings.TrimSpace(item.OpenKFID),
ExternalUserID: strings.TrimSpace(item.ExternalUserID),
SendStatus: string(sendStatus),
RawPayload: string(item.OriginData),
Status: enums.StatusOk,
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: 0,
CreateUserName: wxWorkKFSystemOperatorName,
UpdatedAt: now,
UpdateUserID: 0,
UpdateUserName: wxWorkKFSystemOperatorName,
},
})
}
func (s *wxWorkKFInboundService) saveNextCursor(openKfID, nextCursor string) error {
openKfID = strings.TrimSpace(openKfID)
if openKfID == "" {
return errorsx.InvalidParamI18n("error.e0068")
}
now := time.Now()
state := WxWorkKFSyncStateService.Take("open_kf_id = ?", openKfID)
if state != nil {
return WxWorkKFSyncStateService.Updates(state.ID, map[string]any{
"next_cursor": strings.TrimSpace(nextCursor),
"last_sync_at": now,
"updated_at": now,
"status": enums.StatusOk,
})
}
return WxWorkKFSyncStateService.Create(&models.WxWorkKFSyncState{
OpenKfID: openKfID,
NextCursor: strings.TrimSpace(nextCursor),
LastSyncAt: &now,
Status: enums.StatusOk,
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: 0,
CreateUserName: wxWorkKFSystemOperatorName,
UpdatedAt: now,
UpdateUserID: 0,
UpdateUserName: wxWorkKFSystemOperatorName,
},
})
}
func (s *wxWorkKFInboundService) appendConversationEvent(conversationID int64, content, payload string) error {
return ConversationEventLogService.Create(&models.ConversationEventLog{
ConversationID: conversationID,
EventType: enums.IMEventTypeWxWorkKFEvent,
OperatorType: enums.IMSenderTypeSystem,
OperatorID: 0,
Content: strings.TrimSpace(content),
Payload: strings.TrimSpace(payload),
CreatedAt: time.Now(),
})
}
func (s *wxWorkKFInboundService) buildInboundAssetPayload(conversationID int64, mediaID string) (string, string, error) {
mediaID = strings.TrimSpace(mediaID)
if mediaID == "" {
return "", "", errorsx.InvalidParamI18n("error.e0095")
}
materialCli := wxwork.GetWorkCli().GetMaterial()
data, err := materialCli.GetTempFile(mediaID)
if err != nil {
return "", "", err
}
asset, err := AssetService.UploadBytes(data, "", "", nil)
if err != nil {
return "", "", err
}
canonicalPayload, err := buildIMMessageAssetPayload(asset)
if err != nil {
return "", "", err
}
content := strings.TrimSpace(asset.Filename)
if strs.IsBlank(content) {
content = "[文件]"
}
return canonicalPayload, content, nil
}
func (s *wxWorkKFInboundService) getChannelByOpenKfID(openKfID string) (*models.Channel, error) {
openKfID = strings.TrimSpace(openKfID)
if openKfID == "" {
return nil, errorsx.InvalidParamI18n("error.e0094")
}
channel := ChannelService.GetEnabledWxWorkKFChannelByOpenKfID(openKfID)
if channel == nil {
return nil, errorsx.InvalidParamI18n("error.e0231")
}
if channel.AIAgentID <= 0 {
return nil, errorsx.InvalidParamI18n("error.e0098")
}
agent := AIAgentService.Get(channel.AIAgentID)
if agent == nil || agent.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0099")
}
return channel, nil
}
func (s *wxWorkKFInboundService) buildExternalUser(externalUserID string) openidentity.ExternalUser {
return openidentity.ExternalUser{
ExternalSource: enums.ExternalSourceWxWorkKF,
ExternalID: strings.TrimSpace(externalUserID),
ExternalName: strings.TrimSpace(externalUserID),
}
}
func (s *wxWorkKFInboundService) buildInboundClientMsgID(wxMsgID string) string {
return "wxwork_kf:" + strings.TrimSpace(wxMsgID)
}
func (s *wxWorkKFInboundService) buildUnsupportedContent(msgType string) string {
switch strings.TrimSpace(msgType) {
case "voice":
return "[语音]"
case "video":
return "[视频]"
case "location":
return "[位置]"
case "link":
return "[链接]"
case "business_card":
return "[名片]"
case "miniprogram":
return "[小程序]"
default:
return "[" + strings.TrimSpace(msgType) + "]"
}
}
func (s *wxWorkKFInboundService) parseSendTime(sendTime uint64) *time.Time {
if sendTime == 0 {
return nil
}
t := time.Unix(int64(sendTime), 0)
return &t
}
func (s *wxWorkKFInboundService) mustMarshal(value any) string {
if value == nil {
return ""
}
raw, err := json.Marshal(value)
if err != nil {
return ""
}
return string(raw)
}
func (s *wxWorkKFInboundService) parseBaseMessage(raw []byte) (syncmsg.BaseMessage, error) {
base := syncmsg.BaseMessage{}
err := json.Unmarshal(raw, &base)
return base, err
}
func (s *wxWorkKFInboundService) normalizeEventBaseMessage(base syncmsg.BaseMessage, openKfID, externalUserID string) syncmsg.BaseMessage {
base.OpenKFID = strings.TrimSpace(openKfID)
base.ExternalUserID = strings.TrimSpace(externalUserID)
return base
}