veza/veza-backend-api/internal/websocket/chat/messages.go
senke a1000ce7fb style(backend): gofmt -w on 85 files (whitespace only)
backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.

The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
2026-04-14 12:22:14 +02:00

334 lines
11 KiB
Go

package chat
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// Incoming message types (client -> server)
const (
TypeSendMessage = "SendMessage"
TypeJoinConversation = "JoinConversation"
TypeLeaveConversation = "LeaveConversation"
TypeMarkAsRead = "MarkAsRead"
TypeTyping = "Typing"
TypeDelivered = "Delivered"
TypeEditMessage = "EditMessage"
TypeDeleteMessage = "DeleteMessage"
TypeAddReaction = "AddReaction"
TypeRemoveReaction = "RemoveReaction"
TypeFetchHistory = "FetchHistory"
TypeSearchMessages = "SearchMessages"
TypeSyncMessages = "SyncMessages"
TypeCallOffer = "CallOffer"
TypeCallAnswer = "CallAnswer"
TypeICECandidate = "ICECandidate"
TypeCallHangup = "CallHangup"
TypeCallReject = "CallReject"
TypePing = "Ping"
)
// Outgoing message types (server -> client)
const (
TypeNewMessage = "NewMessage"
TypeMessageRead = "MessageRead"
TypeMessageDelivered = "MessageDelivered"
TypeUserTyping = "UserTyping"
TypeMessageEdited = "MessageEdited"
TypeMessageDeleted = "MessageDeleted"
TypeReactionAdded = "ReactionAdded"
TypeReactionRemoved = "ReactionRemoved"
TypeHistoryChunk = "HistoryChunk"
TypeSearchResults = "SearchResults"
TypeSyncChunk = "SyncChunk"
TypeActionConfirmed = "ActionConfirmed"
TypeError = "Error"
TypeOutCallOffer = "CallOffer"
TypeOutCallAnswer = "CallAnswer"
TypeOutICECandidate = "ICECandidate"
TypeOutCallHangup = "CallHangup"
TypeCallRejected = "CallRejected"
TypePong = "Pong"
TypeMentionedInMessage = "MentionedInMessage"
)
// IncomingMessage is a flat struct that deserializes any client message.
// The "type" field selects which fields are relevant.
type IncomingMessage struct {
Type string `json:"type"`
// Common fields
ConversationID *uuid.UUID `json:"conversation_id,omitempty"`
MessageID *uuid.UUID `json:"message_id,omitempty"`
// SendMessage
Content string `json:"content,omitempty"`
ParentMessageID *uuid.UUID `json:"parent_message_id,omitempty"`
Attachments []MessageAttachment `json:"attachments,omitempty"`
// Typing
IsTyping *bool `json:"is_typing,omitempty"`
// EditMessage
NewContent string `json:"new_content,omitempty"`
// AddReaction
Emoji string `json:"emoji,omitempty"`
// FetchHistory
Before *string `json:"before,omitempty"`
After *string `json:"after,omitempty"`
Limit *int `json:"limit,omitempty"`
// SearchMessages
Query string `json:"query,omitempty"`
Offset *int `json:"offset,omitempty"`
// SyncMessages
Since *string `json:"since,omitempty"`
// Call signaling
TargetUserID *uuid.UUID `json:"target_user_id,omitempty"`
CallerUserID *uuid.UUID `json:"caller_user_id,omitempty"`
SDP string `json:"sdp,omitempty"`
CallType string `json:"call_type,omitempty"`
Candidate string `json:"candidate,omitempty"`
}
type MessageAttachment struct {
FileName string `json:"file_name"`
FileType string `json:"file_type"`
FileURL string `json:"file_url"`
FileSize int64 `json:"file_size"`
}
// MessageDTO is the JSON representation of a message in history/search/sync responses.
type MessageDTO struct {
ID uuid.UUID `json:"id"`
ConversationID uuid.UUID `json:"conversation_id"`
SenderID uuid.UUID `json:"sender_id"`
Content string `json:"content"`
MessageType string `json:"message_type"`
ParentMessageID *uuid.UUID `json:"parent_message_id,omitempty"`
ReplyToID *uuid.UUID `json:"reply_to_id,omitempty"`
IsPinned bool `json:"is_pinned"`
IsEdited bool `json:"is_edited"`
IsDeleted bool `json:"is_deleted"`
EditedAt *time.Time `json:"edited_at,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
Status string `json:"status"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Attachments []MessageAttachment `json:"attachments,omitempty"`
Reactions map[string][]string `json:"reactions,omitempty"` // emoji -> user IDs
}
// --- Outgoing message factory functions ---
func NewNewMessageResponse(conversationID, messageID, senderID uuid.UUID, senderUsername, content string, createdAt time.Time, attachments []MessageAttachment, parentMessageID *uuid.UUID) map[string]interface{} {
out := map[string]interface{}{
"type": TypeNewMessage,
"conversation_id": conversationID,
"message_id": messageID,
"sender_id": senderID,
"sender_username": senderUsername,
"content": content,
"created_at": createdAt.UTC().Format(time.RFC3339),
"attachments": attachments,
}
if parentMessageID != nil {
out["parent_message_id"] = parentMessageID.String()
}
return out
}
func NewMessageReadResponse(messageID, userID, conversationID uuid.UUID, readAt time.Time) map[string]interface{} {
return map[string]interface{}{
"type": TypeMessageRead,
"message_id": messageID,
"user_id": userID,
"conversation_id": conversationID,
"read_at": readAt.UTC().Format(time.RFC3339),
}
}
func NewMessageDeliveredResponse(messageID, userID, conversationID uuid.UUID, deliveredAt time.Time) map[string]interface{} {
return map[string]interface{}{
"type": TypeMessageDelivered,
"message_id": messageID,
"user_id": userID,
"conversation_id": conversationID,
"delivered_at": deliveredAt.UTC().Format(time.RFC3339),
}
}
func NewUserTypingResponse(conversationID, userID uuid.UUID, isTyping bool) map[string]interface{} {
return map[string]interface{}{
"type": TypeUserTyping,
"conversation_id": conversationID,
"user_id": userID,
"is_typing": isTyping,
}
}
func NewMessageEditedResponse(messageID, conversationID, editorID uuid.UUID, editedAt time.Time, newContent string) map[string]interface{} {
return map[string]interface{}{
"type": TypeMessageEdited,
"message_id": messageID,
"conversation_id": conversationID,
"editor_id": editorID,
"edited_at": editedAt.UTC().Format(time.RFC3339),
"new_content": newContent,
}
}
func NewMessageDeletedResponse(messageID, conversationID, deleterID uuid.UUID, deletedAt time.Time) map[string]interface{} {
return map[string]interface{}{
"type": TypeMessageDeleted,
"message_id": messageID,
"conversation_id": conversationID,
"deleter_id": deleterID,
"deleted_at": deletedAt.UTC().Format(time.RFC3339),
}
}
func NewReactionAddedResponse(messageID, conversationID, userID uuid.UUID, emoji string) map[string]interface{} {
return map[string]interface{}{
"type": TypeReactionAdded,
"message_id": messageID,
"conversation_id": conversationID,
"user_id": userID,
"emoji": emoji,
}
}
func NewReactionRemovedResponse(messageID, conversationID, userID uuid.UUID) map[string]interface{} {
return NewReactionRemovedResponseWithEmoji(messageID, conversationID, userID, "")
}
// NewReactionRemovedResponseWithEmoji includes emoji when provided (for remove-by-emoji).
func NewReactionRemovedResponseWithEmoji(messageID, conversationID, userID uuid.UUID, emoji string) map[string]interface{} {
res := map[string]interface{}{
"type": TypeReactionRemoved,
"message_id": messageID,
"conversation_id": conversationID,
"user_id": userID,
}
if emoji != "" {
res["emoji"] = emoji
}
return res
}
func NewHistoryChunkResponse(conversationID uuid.UUID, messages []MessageDTO, hasMoreBefore, hasMoreAfter bool) map[string]interface{} {
return map[string]interface{}{
"type": TypeHistoryChunk,
"conversation_id": conversationID,
"messages": messages,
"has_more_before": hasMoreBefore,
"has_more_after": hasMoreAfter,
}
}
func NewSearchResultsResponse(conversationID uuid.UUID, messages []MessageDTO, query string, total int64) map[string]interface{} {
return map[string]interface{}{
"type": TypeSearchResults,
"conversation_id": conversationID,
"messages": messages,
"query": query,
"total": total,
}
}
func NewSyncChunkResponse(conversationID uuid.UUID, messages []MessageDTO, lastSync time.Time) map[string]interface{} {
return map[string]interface{}{
"type": TypeSyncChunk,
"conversation_id": conversationID,
"messages": messages,
"last_sync": lastSync.UTC().Format(time.RFC3339),
}
}
func NewActionConfirmedResponse(action string, success bool) map[string]interface{} {
return map[string]interface{}{
"type": TypeActionConfirmed,
"action": action,
"success": success,
}
}
func NewErrorResponse(message string) map[string]interface{} {
return map[string]interface{}{
"type": TypeError,
"message": message,
}
}
func NewPongResponse() map[string]interface{} {
return map[string]interface{}{
"type": TypePong,
}
}
func NewCallOfferResponse(conversationID, callerUserID uuid.UUID, sdp, callType string) map[string]interface{} {
return map[string]interface{}{
"type": TypeOutCallOffer,
"conversation_id": conversationID,
"caller_user_id": callerUserID,
"sdp": sdp,
"call_type": callType,
}
}
func NewCallAnswerResponse(conversationID, targetUserID, fromUserID uuid.UUID, sdp string) map[string]interface{} {
return map[string]interface{}{
"type": TypeOutCallAnswer,
"conversation_id": conversationID,
"target_user_id": targetUserID,
"from_user_id": fromUserID,
"sdp": sdp,
}
}
func NewICECandidateResponse(conversationID, fromUserID uuid.UUID, candidate string) map[string]interface{} {
return map[string]interface{}{
"type": TypeOutICECandidate,
"conversation_id": conversationID,
"from_user_id": fromUserID,
"candidate": candidate,
}
}
func NewCallHangupResponse(conversationID, userID uuid.UUID) map[string]interface{} {
return map[string]interface{}{
"type": TypeOutCallHangup,
"conversation_id": conversationID,
"user_id": userID,
}
}
func NewCallRejectedResponse(conversationID, userID uuid.UUID) map[string]interface{} {
return map[string]interface{}{
"type": TypeCallRejected,
"conversation_id": conversationID,
"user_id": userID,
}
}
// NewMentionedInMessageResponse notifies a user they were mentioned in a message (v0.9.6)
func NewMentionedInMessageResponse(conversationID, messageID, authorID uuid.UUID, contentPreview string) map[string]interface{} {
preview := contentPreview
if len(preview) > 100 {
preview = preview[:97] + "..."
}
return map[string]interface{}{
"type": TypeMentionedInMessage,
"conversation_id": conversationID,
"message_id": messageID,
"author_id": authorID,
"content_preview": preview,
}
}