Files
agent-desk-ng/internal/services/ws_service.go

820 lines
24 KiB
Go
Raw 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 services
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/pkg/utils"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"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 *gin.Context) {
principal := AuthService.GetAuthPrincipal(ctx)
if principal == nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired")))
return
}
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin); err != nil {
slog.Error("upgrade admin websocket failed", "error", err, "path", ctx.Request.URL.Path)
ctx.Abort()
return
}
}
func (s *wsService) HandleDashboardNotificationWS(ctx *gin.Context) {
principal := AuthService.GetAuthPrincipal(ctx)
if principal == nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired")))
return
}
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleNotification); err != nil {
slog.Error("upgrade dashboard notification websocket failed", "error", err, "path", ctx.Request.URL.Path)
ctx.Abort()
return
}
}
func (s *wsService) HandleOpenWS(ctx *gin.Context) {
channel := ChannelService.GetEnabledChannel(ctx)
if channel == nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, web.JsonErrorCode(errorsx.CodeInvalidParam, i18nx.T(ctx, "error.e0209")))
return
}
var (
principal = AuthService.GetAuthPrincipal(ctx)
external *openidentity.ExternalUser
customerSessionInfo *CustomerSessionVerifyResult
)
if principal == nil {
result, err := CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
return
}
external = result.ExternalUser
customerSessionInfo = result
}
if err := s.upgradeConnection(ctx, principal, external, realtimeRoleUser, customerSessionInfo); err != nil {
slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Request.URL.Path, "channelId", channel.ChannelID, "channel_id", channel.ID)
ctx.Abort()
return
}
}
func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string, customerSessionInfo ...*CustomerSessionVerifyResult) error {
conn, err := s.upgrader.Upgrade(ctx.Writer, 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(),
},
}))
if len(customerSessionInfo) > 0 && customerSessionInfo[0] != nil && customerSessionInfo[0].Refreshed {
session.enqueueEvent(s.newEvent("", RealtimeCustomerSessionRefreshEvent{
Payload: RealtimeCustomerSessionRefreshPayload{
CustomerSessionToken: customerSessionInfo[0].Token,
ExpiresAt: customerSessionInfo[0].ExpiresAt.UTC().Format(time.RFC3339),
},
}))
}
return nil
}
func (s *wsService) readPump(session *ClientSession) {
defer s.closeSession(session)
for {
_, body, err := session.Conn.ReadMessage()
if err != nil {
return
}
session.touch()
s.handleClientMessage(session, body)
}
}
func (s *wsService) handleClientMessage(session *ClientSession, payload []byte) {
input := realtimeClientMessage{}
if err := json.Unmarshal(payload, &input); err != nil {
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
Payload: RealtimeResyncRequiredPayload{
Reason: enums.IMRealtimeResyncReasonInvalidPayload,
},
}))
return
}
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),
)
case enums.IMRealtimeClientTypePresence:
if input.ConversationID > 0 && session.Principal != nil {
action := strings.TrimSpace(input.Action)
if action == "" {
action = "viewing"
}
s.PublishOperatorPresence(input.ConversationID, session.Principal, action, session.ID)
}
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)
if session.Conn != nil {
_ = 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,
RequestID: message.RequestID,
Message: s.buildRealtimeMessage(message),
Status: conversation.Status,
CurrentAssigneeID: conversation.CurrentAssigneeID,
SenderType: message.SenderType,
SenderID: message.SenderID,
MessageType: message.MessageType,
Content: content,
Payload: payload,
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,
RequestID: item.RequestID,
WorkflowRunID: item.WorkflowRunID,
ClientMsgID: item.ClientMsgID,
SenderType: item.SenderType,
SenderID: item.SenderID,
MessageType: item.MessageType,
Content: content,
Payload: payload,
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.LastReadMessageID >= item.ID
}
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,
CurrentTeamID: conversation.CurrentTeamID,
LastMessageID: conversation.LastMessageID,
LastMessageAt: formatWsTime(&conversation.LastMessageAt),
LastActiveAt: formatWsTime(&conversation.LastActiveAt),
LastMessageSummary: conversation.LastMessageSummary,
CustomerUnreadCount: conversation.CustomerUnreadCount,
AgentUnreadCount: conversation.AgentUnreadCount,
CustomerLastReadMessageID: readStateMessageID(customerReadState),
CustomerLastReadAt: readStateAt(customerReadState),
AgentLastReadMessageID: readStateMessageID(agentReadState),
AgentLastReadAt: readStateAt(agentReadState),
},
})
s.PublishToTopics(s.routeConversationTopics(conversation), event)
}
func (s *wsService) PublishNotificationCreated(userID int64, notification response.NotificationResponse) {
if userID <= 0 || notification.ID <= 0 {
return
}
topic := s.notificationTopic(userID)
event := s.newEvent(topic, RealtimeNotificationCreatedEvent{
Payload: RealtimeNotificationCreatedPayload{
Notification: notification,
},
})
s.PublishToTopic(topic, event)
}
func (s *wsService) PublishAgentActivity(conversation *models.Conversation, stepCode, toolName, status string) {
if conversation == nil || conversation.ID <= 0 {
return
}
now := time.Now()
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeAgentActivityEvent{
Payload: RealtimeAgentActivityPayload{
ConversationID: conversation.ID,
StepCode: stepCode,
ToolName: toolName,
Status: status,
At: formatWsTime(&now),
},
})
s.PublishToTopics(s.routeConversationTopics(conversation), event)
}
func (s *wsService) PublishOperatorPresence(conversationID int64, principal *dto.AuthPrincipal, action string, excludeConnID string) {
if conversationID <= 0 || principal == nil {
return
}
action = strings.TrimSpace(action)
if action == "" {
action = "viewing"
}
topic := s.conversationTopic(conversationID)
payload := RealtimeOperatorPresencePayload{
ConversationID: conversationID,
UserID: principal.UserID,
Username: principal.Username,
Nickname: principal.Nickname,
Action: action,
At: time.Now().Format(time.RFC3339),
}
event := s.newEvent(topic, RealtimeOperatorPresenceEvent{Payload: payload})
s.publishTopicWithFilter(topic, event, func(session *ClientSession) bool {
return excludeConnID == "" || session.ID != excludeConnID
})
}
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 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) publishTopicWithFilter(topic string, event RealtimeEvent, filter func(session *ClientSession) bool) {
topic = strings.TrimSpace(topic)
if topic == "" {
return
}
if event.Topic == "" {
event.Topic = topic
}
s.PublishToTopicsWithFilter([]string{topic}, event, filter)
}
func (s *wsService) PublishToTopics(topics []string, event RealtimeEvent) {
s.PublishToTopicsWithFilter(topics, event, nil)
}
func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEvent, filter func(session *ClientSession) bool) {
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 filter != nil && !filter(session) {
continue
}
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 realtimeRoleNotification:
if session.Principal == nil || session.Principal.UserID <= 0 {
return nil
}
return []string{s.notificationTopic(session.Principal.UserID)}
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 realtimeRoleNotification:
if session.Principal == nil {
return nil
}
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 {
// Staff sessions must hold the same conversation-view permission the
// REST endpoints require; a bare admin-role websocket must not become
// a side channel around RequirePermission.
if session.Principal != nil && slices.Contains(session.Principal.Permissions, constants.PermissionConversationView.Code) {
return true
}
return false
}
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 *gin.Context, role string) string {
if ctx == nil {
return "web"
}
terminalType := strings.TrimSpace(ctx.Query("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) notificationTopic(userID int64) string {
return realtimeTopicNotificationPrefix + 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)
}