package chat import ( "context" "encoding/json" "regexp" "strings" "time" "veza-backend-api/internal/models" "github.com/google/uuid" "go.uber.org/zap" ) var mentionRegex = regexp.MustCompile(`@(\w+)`) func (h *MessageHandler) HandleSendMessage(ctx context.Context, client *Client, msg *IncomingMessage) { if msg.ConversationID == nil { client.SendJSON(NewErrorResponse("conversation_id is required")) return } if msg.Content == "" { client.SendJSON(NewErrorResponse("content is required")) return } // F474: live chat rate limit 1 msg/3s when conversation is a live stream room action := "send_message" if h.permissions.IsLiveRoom(ctx, *msg.ConversationID) { action = "send_live_message" } if !h.rateLimiter.Allow(client.UserID, action) { client.SendJSON(NewErrorResponse("rate limit exceeded")) return } if !h.permissions.CanSend(ctx, client.UserID, *msg.ConversationID) { client.SendJSON(NewErrorResponse("not allowed to send messages in this conversation")) return } md := make(map[string]interface{}) if len(msg.Attachments) > 0 { md["attachments"] = msg.Attachments } var mentionIDs []string if h.userRepo != nil { matches := mentionRegex.FindAllStringSubmatch(msg.Content, -1) seen := make(map[string]bool) for _, m := range matches { if len(m) < 2 || seen[m[1]] { continue } username := strings.TrimSpace(m[1]) if username == "" { continue } u, err := h.userRepo.GetUserByUsername(ctx, username) if err != nil || u == nil { continue } if u.ID != client.UserID && u.ID != uuid.Nil { mentionIDs = append(mentionIDs, u.ID.String()) seen[m[1]] = true } } if len(mentionIDs) > 0 { md["mentions"] = mentionIDs } } var metadata []byte if len(md) > 0 { metadata, _ = json.Marshal(md) } chatMsg := &models.ChatMessage{ ID: uuid.New(), ConversationID: *msg.ConversationID, SenderID: client.UserID, Content: msg.Content, MessageType: "text", ParentMessageID: msg.ParentMessageID, ReplyToID: msg.ParentMessageID, // DB column; same as parent for threading Status: "sent", Metadata: metadata, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := h.msgRepo.Create(ctx, chatMsg); err != nil { h.logger.Error("Failed to save message", zap.Error(err), zap.String("user_id", client.UserID.String())) client.SendJSON(NewErrorResponse("failed to send message")) return } client.SendJSON(NewActionConfirmedResponse("message_sent", true)) outgoing := NewNewMessageResponse( chatMsg.ConversationID, chatMsg.ID, chatMsg.SenderID, client.Username, chatMsg.Content, chatMsg.CreatedAt, msg.Attachments, chatMsg.ParentMessageID, ) data, _ := json.Marshal(outgoing) h.hub.BroadcastToRoom(*msg.ConversationID, data, nil) if h.pubsub != nil { _ = h.pubsub.Publish(ctx, *msg.ConversationID, data) } // v0.9.6: Notify mentioned users for _, idStr := range mentionIDs { mentionedID, err := uuid.Parse(idStr) if err != nil { continue } mentionEvt := NewMentionedInMessageResponse( chatMsg.ConversationID, chatMsg.ID, client.UserID, chatMsg.Content, ) mentionData, _ := json.Marshal(mentionEvt) h.hub.SendToUser(mentionedID, mentionData) } } func (h *MessageHandler) HandleEditMessage(ctx context.Context, client *Client, msg *IncomingMessage) { if msg.MessageID == nil || msg.ConversationID == nil { client.SendJSON(NewErrorResponse("message_id and conversation_id are required")) return } if msg.NewContent == "" { client.SendJSON(NewErrorResponse("new_content is required")) return } chatMsg, err := h.msgRepo.GetByID(ctx, *msg.MessageID) if err != nil { client.SendJSON(NewErrorResponse("message not found")) return } if chatMsg.SenderID != client.UserID { client.SendJSON(NewErrorResponse("can only edit your own messages")) return } now := time.Now() chatMsg.Content = msg.NewContent chatMsg.IsEdited = true chatMsg.EditedAt = &now if err := h.msgRepo.Update(ctx, chatMsg); err != nil { h.logger.Error("Failed to update message", zap.Error(err)) client.SendJSON(NewErrorResponse("failed to edit message")) return } client.SendJSON(NewActionConfirmedResponse("message_edited", true)) outgoing := NewMessageEditedResponse( chatMsg.ID, chatMsg.ConversationID, client.UserID, now, msg.NewContent, ) data, _ := json.Marshal(outgoing) h.hub.BroadcastToRoom(*msg.ConversationID, data, nil) if h.pubsub != nil { _ = h.pubsub.Publish(ctx, *msg.ConversationID, data) } } func (h *MessageHandler) HandleDeleteMessage(ctx context.Context, client *Client, msg *IncomingMessage) { if msg.MessageID == nil || msg.ConversationID == nil { client.SendJSON(NewErrorResponse("message_id and conversation_id are required")) return } chatMsg, err := h.msgRepo.GetByID(ctx, *msg.MessageID) if err != nil { client.SendJSON(NewErrorResponse("message not found")) return } isModerator := h.permissions.CanModerate(ctx, client.UserID, *msg.ConversationID) if chatMsg.SenderID != client.UserID && !isModerator { client.SendJSON(NewErrorResponse("can only delete your own messages")) return } if err := h.msgRepo.SoftDelete(ctx, *msg.MessageID); err != nil { h.logger.Error("Failed to delete message", zap.Error(err)) client.SendJSON(NewErrorResponse("failed to delete message")) return } client.SendJSON(NewActionConfirmedResponse("message_deleted", true)) outgoing := NewMessageDeletedResponse( *msg.MessageID, *msg.ConversationID, client.UserID, time.Now(), ) data, _ := json.Marshal(outgoing) h.hub.BroadcastToRoom(*msg.ConversationID, data, nil) if h.pubsub != nil { _ = h.pubsub.Publish(ctx, *msg.ConversationID, data) } }