package services import ( "encoding/json" "log/slog" "strings" "time" "code.tczkiot.com/wlw/ai-agent/internal/models" "code.tczkiot.com/wlw/ai-agent/internal/pkg/enums" "code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx" "code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity" "code.tczkiot.com/wlw/ai-agent/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.UploadConversationBytes(data, "", "", conversationID, 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") } 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 }