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.
334 lines
11 KiB
Go
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,
|
|
}
|
|
}
|