4ec529e860
- Implemented user token secret generation and retrieval in channel service. - Added ResetUserTokenSecret method to reset the user token secret for channels. - Updated channel configuration parsing to include user token secret. - Enhanced WebSocket service to utilize user token secret for external info retrieval. - Modified dashboard channel edit component to support user token secret display and reset functionality. - Introduced API endpoint for resetting user token secret. - Updated IM and SDK configurations to include user token. - Added tests for user token verification logic.
698 lines
20 KiB
Go
698 lines
20 KiB
Go
package services
|
|
|
|
import (
|
|
"cs-agent/internal/models"
|
|
"cs-agent/internal/pkg/dto"
|
|
"cs-agent/internal/pkg/dto/response"
|
|
"cs-agent/internal/pkg/enums"
|
|
"cs-agent/internal/pkg/errorsx"
|
|
"cs-agent/internal/pkg/openidentity"
|
|
"cs-agent/internal/pkg/utils"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/kataras/iris/v12"
|
|
"github.com/mlogclub/simple/web"
|
|
)
|
|
|
|
var WsService = newWsService()
|
|
|
|
type wsService struct {
|
|
upgrader websocket.Upgrader
|
|
seq atomic.Uint64
|
|
manager *WsConnectionManager
|
|
}
|
|
|
|
func newWsService() *wsService {
|
|
return &wsService{
|
|
upgrader: websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool {
|
|
return true
|
|
},
|
|
},
|
|
manager: newWsConnectionManager(),
|
|
}
|
|
}
|
|
|
|
func (s *wsService) HandleDashboardWS(ctx iris.Context) {
|
|
principal := AuthService.GetAuthPrincipal(ctx)
|
|
if principal == nil {
|
|
_ = ctx.StopWithJSON(iris.StatusUnauthorized, web.JsonError(errorsx.Unauthorized("未登录或登录已过期")))
|
|
return
|
|
}
|
|
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin); err != nil {
|
|
slog.Error("upgrade admin websocket failed", "error", err, "path", ctx.Path())
|
|
ctx.StopExecution()
|
|
return
|
|
}
|
|
}
|
|
|
|
func (s *wsService) HandleOpenWS(ctx iris.Context) {
|
|
channel := ChannelService.GetEnabledChannel(ctx)
|
|
if channel == nil {
|
|
_ = ctx.StopWithJSON(iris.StatusBadRequest, web.JsonErrorMsg("接入渠道不存在或已停用"))
|
|
return
|
|
}
|
|
|
|
var (
|
|
principal = AuthService.GetAuthPrincipal(ctx)
|
|
external *openidentity.ExternalInfo
|
|
)
|
|
if principal == nil {
|
|
ext, err := openidentity.GetExternalInfoWithUserTokenSecret(ctx, ChannelService.GetUserTokenSecret(channel))
|
|
if err != nil {
|
|
_ = ctx.StopWithJSON(iris.StatusUnauthorized, web.JsonError(err))
|
|
return
|
|
}
|
|
external = ext
|
|
}
|
|
if err := s.upgradeConnection(ctx, principal, external, realtimeRoleUser); err != nil {
|
|
slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Path(), "channelId", channel.ChannelID, "channel_id", channel.ID)
|
|
ctx.StopExecution()
|
|
return
|
|
}
|
|
}
|
|
|
|
func (s *wsService) upgradeConnection(ctx iris.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalInfo, role string) error {
|
|
conn, err := s.upgrader.Upgrade(ctx.ResponseWriter().Naive(), ctx.Request(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
session := &ClientSession{
|
|
ID: s.nextID("conn"),
|
|
Conn: conn,
|
|
Principal: principal,
|
|
External: external,
|
|
Role: role,
|
|
TerminalType: s.resolveTerminalType(ctx, role),
|
|
Topics: make(map[string]struct{}),
|
|
Send: make(chan []byte, realtimeSendBufferSize),
|
|
}
|
|
session.touch()
|
|
|
|
conn.SetReadLimit(realtimeMaxMessageSize)
|
|
_ = conn.SetReadDeadline(time.Now().Add(realtimePongWait))
|
|
conn.SetPongHandler(func(string) error {
|
|
session.touch()
|
|
return conn.SetReadDeadline(time.Now().Add(realtimePongWait))
|
|
})
|
|
|
|
var logUserID int64
|
|
var logExternalID string
|
|
if principal != nil {
|
|
logUserID = principal.UserID
|
|
}
|
|
if external != nil {
|
|
logExternalID = strings.TrimSpace(external.ExternalID)
|
|
}
|
|
|
|
sessionCount := s.manager.Register(session, s.defaultTopics(session))
|
|
slog.Info("realtime client connected",
|
|
"connId", session.ID,
|
|
"role", session.Role,
|
|
"userId", logUserID,
|
|
"externalId", logExternalID,
|
|
"terminalType", session.TerminalType,
|
|
"topicCount", len(session.Topics),
|
|
"sessionCount", sessionCount,
|
|
)
|
|
|
|
go s.writePump(session)
|
|
go s.readPump(session)
|
|
|
|
session.enqueueEvent(s.newEvent("", RealtimeConnectedEvent{
|
|
Payload: RealtimeConnectedPayload{
|
|
ConnID: session.ID,
|
|
UserID: logUserID,
|
|
GuestID: logExternalID,
|
|
Role: role,
|
|
TerminalType: session.TerminalType,
|
|
Topics: session.topicList(),
|
|
},
|
|
}))
|
|
return nil
|
|
}
|
|
|
|
func (s *wsService) readPump(session *ClientSession) {
|
|
defer s.closeSession(session)
|
|
|
|
for {
|
|
_, body, err := session.Conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
session.touch()
|
|
|
|
input := realtimeClientMessage{}
|
|
if err := json.Unmarshal(body, &input); err != nil {
|
|
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
|
|
Payload: RealtimeResyncRequiredPayload{
|
|
Reason: enums.IMRealtimeResyncReasonInvalidPayload,
|
|
},
|
|
}))
|
|
continue
|
|
}
|
|
|
|
switch strings.TrimSpace(input.Type) {
|
|
case enums.IMRealtimeClientTypePing:
|
|
session.enqueueEvent(s.newEvent("", RealtimePongEvent{}))
|
|
case enums.IMRealtimeClientTypeSubscribe:
|
|
topics := s.subscribeTopics(session, input.Topics)
|
|
if len(topics) > 0 {
|
|
session.enqueueEvent(s.newEvent("", RealtimeSubscribedEvent{
|
|
Payload: RealtimeTopicsPayload{Topics: topics},
|
|
}))
|
|
}
|
|
case enums.IMRealtimeClientTypeUnsubscribe:
|
|
topics := s.unsubscribeTopics(session, input.Topics)
|
|
if len(topics) > 0 {
|
|
session.enqueueEvent(s.newEvent("", RealtimeUnsubscribedEvent{
|
|
Payload: RealtimeTopicsPayload{Topics: topics},
|
|
}))
|
|
}
|
|
case enums.IMRealtimeClientTypeAck:
|
|
slog.Debug("realtime event ack",
|
|
"connId", session.ID,
|
|
"eventId", strings.TrimSpace(input.EventID),
|
|
)
|
|
default:
|
|
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
|
|
Payload: RealtimeResyncRequiredPayload{
|
|
Reason: enums.IMRealtimeResyncReasonUnsupportedMessageType,
|
|
},
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *wsService) writePump(session *ClientSession) {
|
|
ticker := time.NewTicker(realtimePingPeriod)
|
|
defer func() {
|
|
ticker.Stop()
|
|
s.closeSession(session)
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case payload, ok := <-session.Send:
|
|
_ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait))
|
|
if !ok {
|
|
_ = session.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
|
return
|
|
}
|
|
if err := session.Conn.WriteMessage(websocket.TextMessage, payload); err != nil {
|
|
return
|
|
}
|
|
case <-ticker.C:
|
|
_ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait))
|
|
if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *wsService) closeSession(session *ClientSession) {
|
|
if session == nil {
|
|
return
|
|
}
|
|
session.closeOnce.Do(func() {
|
|
session.Closed.Store(true)
|
|
remaining := s.manager.Unregister(session)
|
|
|
|
close(session.Send)
|
|
_ = session.Conn.Close()
|
|
|
|
var discUserID int64
|
|
var discExternalID string
|
|
if session.Principal != nil {
|
|
discUserID = session.Principal.UserID
|
|
}
|
|
if session.External != nil {
|
|
discExternalID = strings.TrimSpace(session.External.ExternalID)
|
|
}
|
|
slog.Info("realtime client disconnected",
|
|
"connId", session.ID,
|
|
"role", session.Role,
|
|
"userId", discUserID,
|
|
"externalId", discExternalID,
|
|
"terminalType", session.TerminalType,
|
|
"sessionCount", remaining,
|
|
)
|
|
})
|
|
}
|
|
|
|
func (s *wsService) subscribeTopics(session *ClientSession, topics []string) []string {
|
|
allowed := s.filterAllowedTopics(session, topics)
|
|
if len(allowed) == 0 {
|
|
return nil
|
|
}
|
|
return s.manager.Subscribe(session, allowed)
|
|
}
|
|
|
|
func (s *wsService) unsubscribeTopics(session *ClientSession, topics []string) []string {
|
|
allowed := s.filterAllowedTopics(session, topics)
|
|
if len(allowed) == 0 {
|
|
return nil
|
|
}
|
|
return s.manager.Unsubscribe(session, allowed, sliceToSet(s.defaultTopics(session)))
|
|
}
|
|
|
|
func (s *wsService) PublishMessageCreated(conversation *models.Conversation, message *models.Message) {
|
|
if conversation == nil || message == nil {
|
|
return
|
|
}
|
|
content, payload := utils.BuildRenderableMessage(message)
|
|
|
|
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeMessageCreatedEvent{
|
|
Payload: RealtimeMessageCreatedPayload{
|
|
ConversationID: conversation.ID,
|
|
MessageID: message.ID,
|
|
Message: s.buildRealtimeMessage(message),
|
|
Status: conversation.Status,
|
|
CurrentAssigneeID: conversation.CurrentAssigneeID,
|
|
SenderType: message.SenderType,
|
|
SenderID: message.SenderID,
|
|
MessageType: message.MessageType,
|
|
Content: content,
|
|
Payload: payload,
|
|
SeqNo: message.SeqNo,
|
|
SendStatus: message.SendStatus,
|
|
SentAt: formatWsTime(message.SentAt),
|
|
},
|
|
})
|
|
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
|
}
|
|
|
|
func (s *wsService) buildRealtimeMessage(item *models.Message) response.MessageResponse {
|
|
if item == nil {
|
|
return response.MessageResponse{}
|
|
}
|
|
agentReadState, customerReadState := ConversationReadStateService.GetConversationReadStates(item.ConversationID)
|
|
content, payload := utils.BuildRenderableMessage(item)
|
|
ret := response.MessageResponse{
|
|
ID: item.ID,
|
|
ConversationID: item.ConversationID,
|
|
ClientMsgID: item.ClientMsgID,
|
|
SenderType: item.SenderType,
|
|
SenderID: item.SenderID,
|
|
MessageType: item.MessageType,
|
|
Content: content,
|
|
Payload: payload,
|
|
SeqNo: item.SeqNo,
|
|
SendStatus: item.SendStatus,
|
|
SentAt: utils.FormatTimePtr(item.SentAt),
|
|
DeliveredAt: utils.FormatTimePtr(item.DeliveredAt),
|
|
ReadAt: utils.FormatTimePtr(item.ReadAt),
|
|
CustomerRead: isRealtimeMessageRead(item, customerReadState),
|
|
CustomerReadAt: realtimeReadMessageAt(item, customerReadState),
|
|
AgentRead: isRealtimeMessageRead(item, agentReadState),
|
|
AgentReadAt: realtimeReadMessageAt(item, agentReadState),
|
|
RecalledAt: utils.FormatTimePtr(item.RecalledAt),
|
|
QuotedMessageID: item.QuotedMessageID,
|
|
}
|
|
s.fillRealtimeMessageSender(&ret, item)
|
|
return ret
|
|
}
|
|
|
|
func (s *wsService) fillRealtimeMessageSender(ret *response.MessageResponse, item *models.Message) {
|
|
if ret == nil || item == nil || item.SenderID <= 0 {
|
|
return
|
|
}
|
|
switch item.SenderType {
|
|
case enums.IMSenderTypeAI:
|
|
if aiAgent := AIAgentService.Get(item.SenderID); aiAgent != nil {
|
|
ret.SenderName = aiAgent.Name
|
|
}
|
|
case enums.IMSenderTypeAgent:
|
|
if profile := AgentProfileService.GetByUserID(item.SenderID); profile != nil {
|
|
if displayName := strings.TrimSpace(profile.DisplayName); displayName != "" {
|
|
ret.SenderName = displayName
|
|
}
|
|
if avatar := strings.TrimSpace(profile.Avatar); avatar != "" {
|
|
ret.SenderAvatar = avatar
|
|
}
|
|
}
|
|
if ret.SenderName == "" {
|
|
s.fillRealtimeMessageUserName(ret, item.SenderID)
|
|
}
|
|
default:
|
|
s.fillRealtimeMessageUserName(ret, item.SenderID)
|
|
}
|
|
}
|
|
|
|
func (s *wsService) fillRealtimeMessageUserName(ret *response.MessageResponse, userID int64) {
|
|
if ret == nil || userID <= 0 {
|
|
return
|
|
}
|
|
if user := UserService.Get(userID); user != nil {
|
|
ret.SenderName = user.Nickname
|
|
if ret.SenderName == "" {
|
|
ret.SenderName = user.Username
|
|
}
|
|
}
|
|
}
|
|
|
|
func isRealtimeMessageRead(item *models.Message, state *models.ConversationReadState) bool {
|
|
return item != nil && state != nil && state.LastReadSeqNo >= item.SeqNo
|
|
}
|
|
|
|
func realtimeReadMessageAt(item *models.Message, state *models.ConversationReadState) string {
|
|
if !isRealtimeMessageRead(item, state) {
|
|
return ""
|
|
}
|
|
return utils.FormatTimePtr(state.LastReadAt)
|
|
}
|
|
|
|
func (s *wsService) PublishMessageRecalled(conversation *models.Conversation, message *models.Message) {
|
|
if conversation == nil || message == nil {
|
|
return
|
|
}
|
|
|
|
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeMessageRecalledEvent{
|
|
Payload: RealtimeMessageRecalledPayload{
|
|
ConversationID: conversation.ID,
|
|
MessageID: message.ID,
|
|
SenderType: message.SenderType,
|
|
SenderID: message.SenderID,
|
|
SendStatus: message.SendStatus,
|
|
RecalledAt: formatWsTime(message.RecalledAt),
|
|
},
|
|
})
|
|
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
|
}
|
|
|
|
func (s *wsService) PublishConversationChanged(conversation *models.Conversation, eventType string) {
|
|
if conversation == nil {
|
|
return
|
|
}
|
|
agentReadState, customerReadState := ConversationReadStateService.GetConversationReadStates(conversation.ID)
|
|
|
|
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeConversationChangedEvent{
|
|
Type: eventType,
|
|
Payload: RealtimeConversationChangedPayload{
|
|
ConversationID: conversation.ID,
|
|
Status: conversation.Status,
|
|
ServiceMode: conversation.ServiceMode,
|
|
CurrentAssigneeID: conversation.CurrentAssigneeID,
|
|
LastMessageID: conversation.LastMessageID,
|
|
LastMessageAt: formatWsTime(&conversation.LastMessageAt),
|
|
LastActiveAt: formatWsTime(&conversation.LastActiveAt),
|
|
LastMessageSummary: conversation.LastMessageSummary,
|
|
CustomerUnreadCount: conversation.CustomerUnreadCount,
|
|
AgentUnreadCount: conversation.AgentUnreadCount,
|
|
CustomerLastReadMessageID: readStateMessageID(customerReadState),
|
|
CustomerLastReadSeqNo: readStateSeqNo(customerReadState),
|
|
CustomerLastReadAt: readStateAt(customerReadState),
|
|
AgentLastReadMessageID: readStateMessageID(agentReadState),
|
|
AgentLastReadSeqNo: readStateSeqNo(agentReadState),
|
|
AgentLastReadAt: readStateAt(agentReadState),
|
|
},
|
|
})
|
|
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
|
}
|
|
|
|
func (s *wsService) PublishResyncRequired(topics []string, reason string) {
|
|
reason = strings.TrimSpace(reason)
|
|
if reason == "" {
|
|
reason = enums.IMRealtimeResyncReasonManual
|
|
}
|
|
s.PublishToTopics(topics, s.newEvent("", RealtimeResyncRequiredEvent{
|
|
Payload: RealtimeResyncRequiredPayload{Reason: reason},
|
|
}))
|
|
}
|
|
|
|
func readStateMessageID(state *models.ConversationReadState) int64 {
|
|
if state == nil {
|
|
return 0
|
|
}
|
|
return state.LastReadMessageID
|
|
}
|
|
|
|
func readStateSeqNo(state *models.ConversationReadState) int64 {
|
|
if state == nil {
|
|
return 0
|
|
}
|
|
return state.LastReadSeqNo
|
|
}
|
|
|
|
func readStateAt(state *models.ConversationReadState) string {
|
|
if state == nil {
|
|
return ""
|
|
}
|
|
return utils.FormatTimePtr(state.LastReadAt)
|
|
}
|
|
|
|
func (s *wsService) Publish(event RealtimeEvent) {
|
|
if strings.TrimSpace(event.Topic) == "" {
|
|
return
|
|
}
|
|
s.PublishToTopic(event.Topic, event)
|
|
}
|
|
|
|
func (s *wsService) PublishToTopic(topic string, event RealtimeEvent) {
|
|
topic = strings.TrimSpace(topic)
|
|
if topic == "" {
|
|
return
|
|
}
|
|
if event.Topic == "" {
|
|
event.Topic = topic
|
|
}
|
|
s.PublishToTopics([]string{topic}, event)
|
|
}
|
|
|
|
func (s *wsService) PublishToTopics(topics []string, event RealtimeEvent) {
|
|
normalized := normalizeRealtimeTopics(topics)
|
|
if len(normalized) == 0 {
|
|
return
|
|
}
|
|
|
|
targets := s.manager.FindByTopics(normalized)
|
|
if len(targets) == 0 {
|
|
return
|
|
}
|
|
|
|
payload, err := json.Marshal(event)
|
|
if err != nil {
|
|
slog.Error("marshal realtime event failed", "error", err, "type", event.Type)
|
|
return
|
|
}
|
|
|
|
for _, session := range targets {
|
|
if session.enqueue(payload) {
|
|
continue
|
|
}
|
|
slog.Warn("drop slow realtime client",
|
|
"connId", session.ID,
|
|
"role", session.Role,
|
|
"type", event.Type,
|
|
"topic", event.Topic,
|
|
)
|
|
go s.closeSession(session)
|
|
}
|
|
}
|
|
|
|
func (s *wsService) IsGuestOnline(guestID string) bool {
|
|
guestID = strings.TrimSpace(guestID)
|
|
if guestID == "" {
|
|
return false
|
|
}
|
|
return s.manager.HasTopic(s.guestTopic(guestID))
|
|
}
|
|
|
|
func (s *wsService) routeConversationTopics(conversation *models.Conversation) []string {
|
|
if conversation == nil {
|
|
return nil
|
|
}
|
|
|
|
topics := []string{s.conversationTopic(conversation.ID)}
|
|
if identity := ConversationService.GetConversationExternalIdentity(conversation); identity != nil && strings.TrimSpace(identity.ExternalID) != "" {
|
|
topics = append(topics, s.guestTopic(identity.ExternalID))
|
|
}
|
|
if conversation.CurrentAssigneeID > 0 {
|
|
topics = append(topics, s.adminTopic(conversation.CurrentAssigneeID))
|
|
} else {
|
|
topics = append(topics, realtimeTopicAdminAll)
|
|
}
|
|
return normalizeRealtimeTopics(topics)
|
|
}
|
|
|
|
func (s *wsService) defaultTopics(session *ClientSession) []string {
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
|
|
switch session.Role {
|
|
case realtimeRoleAdmin:
|
|
if session.Principal == nil || session.Principal.UserID <= 0 {
|
|
return []string{realtimeTopicAdminAll}
|
|
}
|
|
return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll}
|
|
default:
|
|
// 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 guest:{externalId},否则收不到推送。
|
|
if session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" {
|
|
return []string{s.guestTopic(session.External.ExternalID)}
|
|
}
|
|
if session.Principal != nil && session.Principal.UserID > 0 {
|
|
return []string{s.userTopic(session.Principal.UserID)}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (s *wsService) filterAllowedTopics(session *ClientSession, topics []string) []string {
|
|
normalized := normalizeRealtimeTopics(topics)
|
|
if len(normalized) == 0 || session == nil {
|
|
return nil
|
|
}
|
|
switch session.Role {
|
|
case realtimeRoleAdmin:
|
|
if session.Principal == nil {
|
|
return nil
|
|
}
|
|
default:
|
|
hasUser := session.Principal != nil && session.Principal.UserID > 0
|
|
hasExternal := session.External != nil && strings.TrimSpace(session.External.ExternalID) != ""
|
|
if !hasUser && !hasExternal {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
defaultTopics := sliceToSet(s.defaultTopics(session))
|
|
ret := make([]string, 0, len(normalized))
|
|
for _, topic := range normalized {
|
|
if _, ok := defaultTopics[topic]; ok {
|
|
ret = append(ret, topic)
|
|
continue
|
|
}
|
|
if conversationID, ok := parseConversationTopic(topic); ok && s.canSubscribeConversation(session, conversationID) {
|
|
ret = append(ret, topic)
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func (s *wsService) canSubscribeConversation(session *ClientSession, conversationID int64) bool {
|
|
if session == nil || conversationID <= 0 {
|
|
return false
|
|
}
|
|
if session.Role == realtimeRoleAdmin {
|
|
return true
|
|
}
|
|
conversation := ConversationService.Get(conversationID)
|
|
if conversation == nil {
|
|
return false
|
|
}
|
|
if session.External != nil {
|
|
return ConversationService.IsCustomerConversationOwner(conversation, *session.External)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *wsService) resolveTerminalType(ctx iris.Context, role string) string {
|
|
if ctx == nil {
|
|
return "web"
|
|
}
|
|
terminalType := strings.TrimSpace(ctx.URLParam("terminalType"))
|
|
if terminalType != "" {
|
|
return terminalType
|
|
}
|
|
if role == realtimeRoleAdmin {
|
|
return "dashboard_web"
|
|
}
|
|
return "web"
|
|
}
|
|
|
|
func (s *wsService) newEvent(topic string, event RealtimeDomainEvent) RealtimeEvent {
|
|
if event == nil {
|
|
return RealtimeEvent{
|
|
EventID: s.nextID("evt"),
|
|
Topic: topic,
|
|
At: time.Now().Format(time.DateTime),
|
|
}
|
|
}
|
|
return RealtimeEvent{
|
|
EventID: s.nextID("evt"),
|
|
Type: event.EventType(),
|
|
Topic: topic,
|
|
Data: event.EventPayload(),
|
|
At: time.Now().Format(time.DateTime),
|
|
}
|
|
}
|
|
|
|
func (s *wsService) nextID(prefix string) string {
|
|
seq := s.seq.Add(1)
|
|
return fmt.Sprintf("%s_%d_%d", prefix, time.Now().UnixNano(), seq)
|
|
}
|
|
|
|
func (s *wsService) userTopic(userID int64) string {
|
|
return realtimeTopicUserPrefix + strconv.FormatInt(userID, 10)
|
|
}
|
|
|
|
func (s *wsService) guestTopic(guestID string) string {
|
|
return realtimeTopicGuestPrefix + strings.TrimSpace(guestID)
|
|
}
|
|
|
|
func (s *wsService) adminTopic(userID int64) string {
|
|
return realtimeTopicAdminPrefix + strconv.FormatInt(userID, 10)
|
|
}
|
|
|
|
func (s *wsService) conversationTopic(conversationID int64) string {
|
|
return realtimeTopicConversationPrefix + strconv.FormatInt(conversationID, 10)
|
|
}
|
|
|
|
func normalizeRealtimeTopics(topics []string) []string {
|
|
if len(topics) == 0 {
|
|
return nil
|
|
}
|
|
ret := make([]string, 0, len(topics))
|
|
seen := make(map[string]struct{}, len(topics))
|
|
for _, topic := range topics {
|
|
item := strings.TrimSpace(topic)
|
|
if item == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[item]; ok {
|
|
continue
|
|
}
|
|
seen[item] = struct{}{}
|
|
ret = append(ret, item)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func parseConversationTopic(topic string) (int64, bool) {
|
|
if !strings.HasPrefix(topic, realtimeTopicConversationPrefix) {
|
|
return 0, false
|
|
}
|
|
value := strings.TrimPrefix(topic, realtimeTopicConversationPrefix)
|
|
id, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
func sliceToSet(items []string) map[string]struct{} {
|
|
ret := make(map[string]struct{}, len(items))
|
|
for _, item := range items {
|
|
ret[item] = struct{}{}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func formatWsTime(t *time.Time) string {
|
|
if t == nil || t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.Format(time.DateTime)
|
|
}
|