diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 83144bb..523f44f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -63,6 +63,11 @@ paths: If a phone number is provided and a mapping exists, the message is sent on behalf of the mapped Matrix user. If a phone number is provided but no mapping exists, the phone number is used as the sender ID. + **File Attachments:** + To send files, set `content_type` to `application/x-acro-filetransfer+json` and provide the + file transfer JSON in the `body` field. See the FileTransferMessage schema for the format. + Matrix media URLs (mxc://) must already be uploaded to the Matrix content repository. + Authentication is handled by the Application Service backend; the `password` field is ignored. requestBody: required: true @@ -87,10 +92,17 @@ paths: description: Recipient Matrix ID, Alias, or mapped phone number. body: type: string - description: The message content. + description: | + The message content. For plain text messages, this is the text body. + For file attachments (when content_type is application/x-acro-filetransfer+json), + this contains the JSON-encoded FileTransferMessage. content_type: type: string default: text/plain + description: | + MIME content type. Supported values: + - `text/plain` (default): Plain text message + - `application/x-acro-filetransfer+json`: File attachment message disposition_notification: type: string description: Opaque string for read receipts. @@ -279,10 +291,16 @@ components: description: Recipient identifier (phone number or user name). Only present in sent messages. sms_text: type: string - description: Message body (UTF-8 encoded). + description: | + Message body (UTF-8 encoded). For plain text messages, this is the text content. + For file attachments (when content_type is application/x-acro-filetransfer+json), + this contains a JSON-encoded FileTransferMessage. content_type: type: string - description: MIME content-type. Defaults to text/plain if omitted. + description: | + MIME content-type. Values: + - `text/plain` (default): Plain text message, sms_text contains the text + - `application/x-acro-filetransfer+json`: File attachment, sms_text contains FileTransferMessage JSON disposition_notification: type: string description: Opaque string from Send Message request. Can be omitted if empty. @@ -292,6 +310,66 @@ components: stream_id: type: string description: Identifier for the conversation stream (Room ID or identifier). + FileTransferMessage: + type: object + description: | + Acrobits file transfer message format used when content_type is application/x-acro-filetransfer+json. + See https://doc.acrobits.net/api/client/x-acro-filetransfer.html + properties: + body: + type: string + description: Optional text message applying to the attachment(s). + attachments: + type: array + description: Array of file attachments. + items: + $ref: '#/components/schemas/Attachment' + required: + - attachments + Attachment: + type: object + description: A single file attachment in the Acrobits file transfer format. + properties: + content-type: + type: string + description: MIME type of the file. If not present, image/jpeg is assumed. + default: image/jpeg + content-url: + type: string + description: URL to download the file content (Matrix mxc:// URL or HTTP URL). + content-size: + type: integer + format: int64 + description: Size of the file in bytes. Used to decide download behavior. + filename: + type: string + description: Original filename on the sending device. + description: + type: string + description: Text description for the attachment (not widely used). + encryption-key: + type: string + description: Hex-encoded AES128/192/256 CTR key for decryption. If present, content is encrypted. + hash: + type: string + description: CRC32 digest of the decrypted binary data for integrity verification. + preview: + $ref: '#/components/schemas/AttachmentPreview' + required: + - content-url + AttachmentPreview: + type: object + description: Low quality preview image for an attachment. + properties: + content-type: + type: string + description: MIME type of the preview. If not present, image/jpeg is assumed. + default: image/jpeg + content: + type: string + description: BASE64 encoded preview image data, or URL to thumbnail. + required: + - content FetchMessagesResponse: type: object description: Response from the fetch_messages endpoint following Acrobits Modern API specification. diff --git a/matrix/client.go b/matrix/client.go index fd501ef..d90b387 100644 --- a/matrix/client.go +++ b/matrix/client.go @@ -210,6 +210,40 @@ func (mc *MatrixClient) ListJoinedRooms(ctx context.Context, userID id.UserID) ( return resp.JoinedRooms, nil } +// UploadMedia uploads media to the Matrix content repository and returns the MXC URI. +// This follows the Matrix spec: https://spec.matrix.org/v1.2/client-server-api/#content-repository +// The returned URI can be used in message events via the `url` field. +func (mc *MatrixClient) UploadMedia(ctx context.Context, userID id.UserID, contentType string, data []byte) (id.ContentURIString, error) { + mc.mu.Lock() + defer mc.mu.Unlock() + + logger.Debug(). + Str("user_id", string(userID)). + Str("content_type", contentType). + Int("size", len(data)). + Msg("matrix: uploading media to content repository") + + mc.cli.UserID = userID + + resp, err := mc.cli.UploadBytes(ctx, data, contentType) + if err != nil { + logger.Error(). + Str("user_id", string(userID)). + Str("content_type", contentType). + Err(err). + Msg("matrix: failed to upload media") + return "", fmt.Errorf("upload media: %w", err) + } + + contentURI := resp.ContentURI.CUString() + logger.Debug(). + Str("user_id", string(userID)). + Str("content_uri", string(contentURI)). + Msg("matrix: media uploaded successfully") + + return contentURI, nil +} + // SetPusher registers or updates a push gateway for the specified user. // This is used to configure Matrix to send push notifications to the proxy's /_matrix/push/v1/notify endpoint. func (mc *MatrixClient) SetPusher(ctx context.Context, userID id.UserID, req *models.SetPusherRequest) error { @@ -247,3 +281,26 @@ func (mc *MatrixClient) SetPusher(ctx context.Context, userID id.UserID, req *mo Msg("matrix: pusher set successfully") return nil } + +// ResolveMXC converts an MXC URI (mxc://server/mediaId) to a downloadable HTTP URL. +// It uses the configured homeserver URL to construct the download link. +func (mc *MatrixClient) ResolveMXC(mxcURI string) string { + if !strings.HasPrefix(mxcURI, "mxc://") { + return mxcURI + } + + // Parse the MXC URI + // Format: mxc:/// + trimmed := strings.TrimPrefix(mxcURI, "mxc://") + parts := strings.SplitN(trimmed, "/", 2) + if len(parts) != 2 { + return mxcURI + } + serverName := parts[0] + mediaID := parts[1] + + // Construct the download URL + // Format: /_matrix/media/v3/download// + baseURL := strings.TrimSuffix(mc.homeserverURL, "/") + return fmt.Sprintf("%s/_matrix/media/v3/download/%s/%s", baseURL, serverName, mediaID) +} diff --git a/models/filetransfer.go b/models/filetransfer.go new file mode 100644 index 0000000..8d59635 --- /dev/null +++ b/models/filetransfer.go @@ -0,0 +1,72 @@ +package models + +// FileTransferContentType is the MIME type for Acrobits file transfer messages. +const FileTransferContentType = "application/x-acro-filetransfer+json" + +// FileTransferMessage represents the Acrobits file transfer format. +// This is used when Content-Type is application/x-acro-filetransfer+json. +// See: https://doc.acrobits.net/api/client/x-acro-filetransfer.html +type FileTransferMessage struct { + // Body is an optional text message applying to the attachment(s). + Body string `json:"body,omitempty"` + // Attachments is the array of individual attachment dictionaries. + Attachments []Attachment `json:"attachments"` +} + +// Attachment represents a single file attachment in the Acrobits file transfer format. +type Attachment struct { + // ContentType is optional. If not present, image/jpeg is assumed. + ContentType string `json:"content-type,omitempty"` + // ContentURL is mandatory. It is the location from where to download the data. + ContentURL string `json:"content-url"` + // ContentSize is optional. Used to decide whether to download automatically. + ContentSize int64 `json:"content-size,omitempty"` + // Filename is optional. Original filename on the sending device. + Filename string `json:"filename,omitempty"` + // Description is optional. Text for the particular attachment (not used so far). + Description string `json:"description,omitempty"` + // EncryptionKey is optional. Hex-encoded AES128/192/256 CTR key for decryption. + EncryptionKey string `json:"encryption-key,omitempty"` + // Hash is optional. CRC32 digest of the decrypted binary data. + Hash string `json:"hash,omitempty"` + // Preview is optional. Low quality representation of the data to be downloaded. + Preview *AttachmentPreview `json:"preview,omitempty"` +} + +// AttachmentPreview represents a preview image for an attachment. +type AttachmentPreview struct { + // ContentType is optional. If not present, image/jpeg is assumed. + ContentType string `json:"content-type,omitempty"` + // Content is mandatory. BASE64 representation of the preview image. + Content string `json:"content"` +} + +// IsImageContentType checks if the content type is an image type. +func IsImageContentType(contentType string) bool { + switch contentType { + case "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/bmp", "image/tiff": + return true + default: + return false + } +} + +// IsVideoContentType checks if the content type is a video type. +func IsVideoContentType(contentType string) bool { + switch contentType { + case "video/mp4", "video/webm", "video/ogg", "video/quicktime", "video/x-msvideo": + return true + default: + return false + } +} + +// IsAudioContentType checks if the content type is an audio type. +func IsAudioContentType(contentType string) bool { + switch contentType { + case "audio/mpeg", "audio/mp3", "audio/ogg", "audio/wav", "audio/webm", "audio/aac", "audio/flac": + return true + default: + return false + } +} diff --git a/models/filetransfer_convert.go b/models/filetransfer_convert.go new file mode 100644 index 0000000..343e74d --- /dev/null +++ b/models/filetransfer_convert.go @@ -0,0 +1,213 @@ +package models + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// MatrixToAcrobitsAttachment converts Matrix media event content to an Acrobits Attachment. +// It handles m.image, m.video, m.audio, and m.file message types. +func MatrixToAcrobitsAttachment(msgType, body, url, mimeType, filename string, size int64, thumbnailURL, thumbnailMimeType string, thumbnailData []byte) *Attachment { + attachment := &Attachment{ + ContentURL: url, + ContentType: mimeType, + ContentSize: size, + Filename: filename, + Description: body, + } + + // If filename is empty but body is present, use body as filename for non-text content + if attachment.Filename == "" && body != "" { + attachment.Filename = body + } + + // Set default content type based on message type if not provided + if attachment.ContentType == "" { + switch msgType { + case "m.image": + attachment.ContentType = "image/jpeg" + case "m.video": + attachment.ContentType = "video/mp4" + case "m.audio": + attachment.ContentType = "audio/mpeg" + default: + attachment.ContentType = "application/octet-stream" + } + } + + // Add preview/thumbnail if available + if thumbnailURL != "" || len(thumbnailData) > 0 { + preview := &AttachmentPreview{} + if thumbnailMimeType != "" { + preview.ContentType = thumbnailMimeType + } else { + preview.ContentType = "image/jpeg" + } + // If we have thumbnail data, encode it as base64 + // If we only have a URL, store the URL in Content field (client will need to fetch it) + if len(thumbnailData) > 0 { + preview.Content = base64.StdEncoding.EncodeToString(thumbnailData) + } else if thumbnailURL != "" { + // Store URL as a special marker for clients to fetch + // This is a workaround since Acrobits expects base64 content + preview.Content = thumbnailURL + } + attachment.Preview = preview + } + + return attachment +} + +// MatrixMediaToFileTransfer converts a Matrix media message to an Acrobits FileTransferMessage. +// Returns the JSON-encoded file transfer message suitable for sms_text field. +func MatrixMediaToFileTransfer(msgType, body, url, mimeType, filename string, size int64, thumbnailURL, thumbnailMimeType string) (string, error) { + attachment := MatrixToAcrobitsAttachment(msgType, body, url, mimeType, filename, size, thumbnailURL, thumbnailMimeType, nil) + + ftMsg := &FileTransferMessage{ + Body: body, + Attachments: []Attachment{*attachment}, + } + + jsonData, err := json.Marshal(ftMsg) + if err != nil { + return "", fmt.Errorf("failed to marshal file transfer message: %w", err) + } + + return string(jsonData), nil +} + +// ParseFileTransferMessage parses a JSON-encoded file transfer message. +func ParseFileTransferMessage(data string) (*FileTransferMessage, error) { + var ftMsg FileTransferMessage + if err := json.Unmarshal([]byte(data), &ftMsg); err != nil { + return nil, fmt.Errorf("failed to parse file transfer message: %w", err) + } + return &ftMsg, nil +} + +// FileTransferToMatrixEventContent converts an Acrobits file transfer message to Matrix event content. +// Returns the message type, body, and a map suitable for Matrix event content. +// For messages with multiple attachments, only the first attachment is used (Matrix doesn't support multiple files in one event). +func FileTransferToMatrixEventContent(ftMsg *FileTransferMessage) (msgType string, content map[string]interface{}, err error) { + if ftMsg == nil || len(ftMsg.Attachments) == 0 { + return "", nil, fmt.Errorf("file transfer message has no attachments") + } + + // Use the first attachment + att := ftMsg.Attachments[0] + + // Determine Matrix message type based on content type + contentType := att.ContentType + if contentType == "" { + contentType = "image/jpeg" // Default per Acrobits spec + } + + switch { + case IsImageContentType(contentType): + msgType = "m.image" + case IsVideoContentType(contentType): + msgType = "m.video" + case IsAudioContentType(contentType): + msgType = "m.audio" + default: + msgType = "m.file" + } + + // Build the message body + body := att.Filename + if body == "" { + body = ftMsg.Body + } + if body == "" { + body = "attachment" + } + + // Build Matrix event content + content = map[string]interface{}{ + "msgtype": msgType, + "body": body, + "url": att.ContentURL, + } + + // Include filename when present so clients can render proper disposition + if att.Filename != "" { + content["filename"] = att.Filename + } + + // Add info block + info := map[string]interface{}{} + if contentType != "" { + info["mimetype"] = contentType + } + if att.ContentSize > 0 { + info["size"] = att.ContentSize + } + + // Add thumbnail info if available + if att.Preview != nil && att.Preview.Content != "" { + // If preview content looks like a URL, use it as thumbnail_url + if strings.HasPrefix(att.Preview.Content, "http://") || strings.HasPrefix(att.Preview.Content, "https://") || strings.HasPrefix(att.Preview.Content, "mxc://") { + info["thumbnail_url"] = att.Preview.Content + if att.Preview.ContentType != "" { + info["thumbnail_info"] = map[string]interface{}{ + "mimetype": att.Preview.ContentType, + } + } + } + // Note: Base64 preview content cannot be directly used in Matrix events + // The client would need to upload it first + } + + if len(info) > 0 { + content["info"] = info + } + + // Add filename if available + if att.Filename != "" { + content["filename"] = att.Filename + } + + return msgType, content, nil +} + +// IsFileTransferContentType checks if the content type is the Acrobits file transfer type. +func IsFileTransferContentType(contentType string) bool { + return contentType == FileTransferContentType +} + +// ExtractMatrixMediaInfo extracts media information from Matrix event content. +// Returns the content URL, mimetype, filename, size, thumbnail URL, and thumbnail mimetype. +func ExtractMatrixMediaInfo(raw map[string]interface{}) (url, mimeType, filename string, size int64, thumbnailURL, thumbnailMimeType string) { + // Extract URL + if u, ok := raw["url"].(string); ok { + url = u + } + + // Extract filename + if f, ok := raw["filename"].(string); ok { + filename = f + } + + // Extract info block + if info, ok := raw["info"].(map[string]interface{}); ok { + if mt, ok := info["mimetype"].(string); ok { + mimeType = mt + } + if s, ok := info["size"].(float64); ok { + size = int64(s) + } + // Extract thumbnail info + if tu, ok := info["thumbnail_url"].(string); ok { + thumbnailURL = tu + } + if ti, ok := info["thumbnail_info"].(map[string]interface{}); ok { + if tm, ok := ti["mimetype"].(string); ok { + thumbnailMimeType = tm + } + } + } + + return +} diff --git a/models/filetransfer_test.go b/models/filetransfer_test.go new file mode 100644 index 0000000..d4ca7ac --- /dev/null +++ b/models/filetransfer_test.go @@ -0,0 +1,522 @@ +package models + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsFileTransferContentType(t *testing.T) { + tests := []struct { + name string + contentType string + expected bool + }{ + { + name: "exact match", + contentType: "application/x-acro-filetransfer+json", + expected: true, + }, + { + name: "plain text", + contentType: "text/plain", + expected: false, + }, + { + name: "empty", + contentType: "", + expected: false, + }, + { + name: "json", + contentType: "application/json", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsFileTransferContentType(tt.contentType) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsImageContentType(t *testing.T) { + tests := []struct { + contentType string + expected bool + }{ + {"image/jpeg", true}, + {"image/jpg", true}, + {"image/png", true}, + {"image/gif", true}, + {"image/webp", true}, + {"video/mp4", false}, + {"audio/mpeg", false}, + {"application/pdf", false}, + } + + for _, tt := range tests { + t.Run(tt.contentType, func(t *testing.T) { + assert.Equal(t, tt.expected, IsImageContentType(tt.contentType)) + }) + } +} + +func TestIsVideoContentType(t *testing.T) { + tests := []struct { + contentType string + expected bool + }{ + {"video/mp4", true}, + {"video/webm", true}, + {"video/ogg", true}, + {"image/jpeg", false}, + {"audio/mpeg", false}, + } + + for _, tt := range tests { + t.Run(tt.contentType, func(t *testing.T) { + assert.Equal(t, tt.expected, IsVideoContentType(tt.contentType)) + }) + } +} + +func TestIsAudioContentType(t *testing.T) { + tests := []struct { + contentType string + expected bool + }{ + {"audio/mpeg", true}, + {"audio/mp3", true}, + {"audio/ogg", true}, + {"audio/wav", true}, + {"image/jpeg", false}, + {"video/mp4", false}, + } + + for _, tt := range tests { + t.Run(tt.contentType, func(t *testing.T) { + assert.Equal(t, tt.expected, IsAudioContentType(tt.contentType)) + }) + } +} + +func TestParseFileTransferMessage(t *testing.T) { + tests := []struct { + name string + input string + want *FileTransferMessage + wantErr bool + }{ + { + name: "simple image attachment", + input: `{ + "body": "Check this out", + "attachments": [{ + "content-type": "image/jpeg", + "content-url": "https://example.com/image.jpg", + "content-size": 12345, + "filename": "photo.jpg" + }] + }`, + want: &FileTransferMessage{ + Body: "Check this out", + Attachments: []Attachment{ + { + ContentType: "image/jpeg", + ContentURL: "https://example.com/image.jpg", + ContentSize: 12345, + Filename: "photo.jpg", + }, + }, + }, + }, + { + name: "attachment with preview", + input: `{ + "attachments": [{ + "content-url": "https://example.com/video.mp4", + "content-type": "video/mp4", + "preview": { + "content-type": "image/jpeg", + "content": "BASE64DATA" + } + }] + }`, + want: &FileTransferMessage{ + Attachments: []Attachment{ + { + ContentURL: "https://example.com/video.mp4", + ContentType: "video/mp4", + Preview: &AttachmentPreview{ + ContentType: "image/jpeg", + Content: "BASE64DATA", + }, + }, + }, + }, + }, + { + name: "attachment with encryption", + input: `{ + "attachments": [{ + "content-url": "https://example.com/encrypted.bin", + "encryption-key": "F4EC56A83CDA65B2C6DC11E2CF693DAA", + "hash": "4488649" + }] + }`, + want: &FileTransferMessage{ + Attachments: []Attachment{ + { + ContentURL: "https://example.com/encrypted.bin", + EncryptionKey: "F4EC56A83CDA65B2C6DC11E2CF693DAA", + Hash: "4488649", + }, + }, + }, + }, + { + name: "invalid json", + input: `{invalid}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseFileTransferMessage(tt.input) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMatrixToAcrobitsAttachment(t *testing.T) { + tests := []struct { + name string + msgType string + body string + url string + mimeType string + filename string + size int64 + thumbnailURL string + thumbnailMimeType string + thumbnailData []byte + expected *Attachment + }{ + { + name: "image message", + msgType: "m.image", + body: "photo.jpg", + url: "mxc://matrix.org/abc123", + mimeType: "image/jpeg", + filename: "photo.jpg", + size: 54321, + expected: &Attachment{ + ContentType: "image/jpeg", + ContentURL: "mxc://matrix.org/abc123", + ContentSize: 54321, + Filename: "photo.jpg", + Description: "photo.jpg", + }, + }, + { + name: "image with thumbnail", + msgType: "m.image", + body: "large_image.png", + url: "mxc://matrix.org/large", + mimeType: "image/png", + filename: "large_image.png", + size: 1000000, + thumbnailURL: "mxc://matrix.org/thumb", + thumbnailMimeType: "image/jpeg", + expected: &Attachment{ + ContentType: "image/png", + ContentURL: "mxc://matrix.org/large", + ContentSize: 1000000, + Filename: "large_image.png", + Description: "large_image.png", + Preview: &AttachmentPreview{ + ContentType: "image/jpeg", + Content: "mxc://matrix.org/thumb", + }, + }, + }, + { + name: "file with default mime type", + msgType: "m.file", + body: "document", + url: "mxc://matrix.org/doc", + mimeType: "", + filename: "", + size: 0, + expected: &Attachment{ + ContentType: "application/octet-stream", + ContentURL: "mxc://matrix.org/doc", + Filename: "document", + Description: "document", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MatrixToAcrobitsAttachment(tt.msgType, tt.body, tt.url, tt.mimeType, tt.filename, tt.size, tt.thumbnailURL, tt.thumbnailMimeType, tt.thumbnailData) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestMatrixMediaToFileTransfer(t *testing.T) { + ftJSON, err := MatrixMediaToFileTransfer( + "m.image", + "vacation.jpg", + "mxc://matrix.org/vacation", + "image/jpeg", + "vacation.jpg", + 98765, + "", + "", + ) + require.NoError(t, err) + + // Parse it back to verify + var ftMsg FileTransferMessage + err = json.Unmarshal([]byte(ftJSON), &ftMsg) + require.NoError(t, err) + + assert.Equal(t, "vacation.jpg", ftMsg.Body) + require.Len(t, ftMsg.Attachments, 1) + assert.Equal(t, "mxc://matrix.org/vacation", ftMsg.Attachments[0].ContentURL) + assert.Equal(t, "image/jpeg", ftMsg.Attachments[0].ContentType) + assert.Equal(t, "vacation.jpg", ftMsg.Attachments[0].Filename) + assert.Equal(t, int64(98765), ftMsg.Attachments[0].ContentSize) +} + +func TestFileTransferToMatrixEventContent(t *testing.T) { + tests := []struct { + name string + ftMsg *FileTransferMessage + expectedMsgType string + expectedBody string + expectedURL string + wantErr bool + }{ + { + name: "image attachment", + ftMsg: &FileTransferMessage{ + Body: "Nice photo", + Attachments: []Attachment{ + { + ContentType: "image/jpeg", + ContentURL: "https://example.com/photo.jpg", + Filename: "photo.jpg", + ContentSize: 12345, + }, + }, + }, + expectedMsgType: "m.image", + expectedBody: "photo.jpg", + expectedURL: "https://example.com/photo.jpg", + }, + { + name: "video attachment", + ftMsg: &FileTransferMessage{ + Attachments: []Attachment{ + { + ContentType: "video/mp4", + ContentURL: "https://example.com/video.mp4", + Filename: "video.mp4", + }, + }, + }, + expectedMsgType: "m.video", + expectedBody: "video.mp4", + expectedURL: "https://example.com/video.mp4", + }, + { + name: "audio attachment", + ftMsg: &FileTransferMessage{ + Attachments: []Attachment{ + { + ContentType: "audio/mpeg", + ContentURL: "https://example.com/song.mp3", + Filename: "song.mp3", + }, + }, + }, + expectedMsgType: "m.audio", + expectedBody: "song.mp3", + expectedURL: "https://example.com/song.mp3", + }, + { + name: "generic file attachment", + ftMsg: &FileTransferMessage{ + Body: "Document attached", + Attachments: []Attachment{ + { + ContentType: "application/pdf", + ContentURL: "https://example.com/doc.pdf", + Filename: "document.pdf", + }, + }, + }, + expectedMsgType: "m.file", + expectedBody: "document.pdf", + expectedURL: "https://example.com/doc.pdf", + }, + { + name: "default to image/jpeg when no content type", + ftMsg: &FileTransferMessage{ + Attachments: []Attachment{ + { + ContentURL: "https://example.com/unknown", + Filename: "unknown.jpg", + }, + }, + }, + expectedMsgType: "m.image", + expectedBody: "unknown.jpg", + expectedURL: "https://example.com/unknown", + }, + { + name: "nil message", + ftMsg: nil, + wantErr: true, + }, + { + name: "no attachments", + ftMsg: &FileTransferMessage{ + Body: "Empty", + Attachments: []Attachment{}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msgType, content, err := FileTransferToMatrixEventContent(tt.ftMsg) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expectedMsgType, msgType) + assert.Equal(t, tt.expectedBody, content["body"]) + assert.Equal(t, tt.expectedURL, content["url"]) + }) + } +} + +func TestExtractMatrixMediaInfo(t *testing.T) { + tests := []struct { + name string + raw map[string]interface{} + expectedURL string + expectedMimeType string + expectedFilename string + expectedSize int64 + expectedThumbnailURL string + expectedThumbnailMime string + }{ + { + name: "full info", + raw: map[string]interface{}{ + "url": "mxc://matrix.org/abc123", + "filename": "test.jpg", + "info": map[string]interface{}{ + "mimetype": "image/jpeg", + "size": float64(12345), + "thumbnail_url": "mxc://matrix.org/thumb", + "thumbnail_info": map[string]interface{}{ + "mimetype": "image/jpeg", + }, + }, + }, + expectedURL: "mxc://matrix.org/abc123", + expectedMimeType: "image/jpeg", + expectedFilename: "test.jpg", + expectedSize: 12345, + expectedThumbnailURL: "mxc://matrix.org/thumb", + expectedThumbnailMime: "image/jpeg", + }, + { + name: "minimal info", + raw: map[string]interface{}{ + "url": "mxc://matrix.org/minimal", + }, + expectedURL: "mxc://matrix.org/minimal", + }, + { + name: "empty map", + raw: map[string]interface{}{}, + expectedURL: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url, mimeType, filename, size, thumbnailURL, thumbnailMime := ExtractMatrixMediaInfo(tt.raw) + assert.Equal(t, tt.expectedURL, url) + assert.Equal(t, tt.expectedMimeType, mimeType) + assert.Equal(t, tt.expectedFilename, filename) + assert.Equal(t, tt.expectedSize, size) + assert.Equal(t, tt.expectedThumbnailURL, thumbnailURL) + assert.Equal(t, tt.expectedThumbnailMime, thumbnailMime) + }) + } +} + +func TestFileTransferMessageJSONRoundtrip(t *testing.T) { + original := FileTransferMessage{ + Body: "Test message with attachment", + Attachments: []Attachment{ + { + ContentType: "image/png", + ContentURL: "https://example.com/image.png", + ContentSize: 999999, + Filename: "screenshot.png", + Description: "A screenshot", + EncryptionKey: "AABBCCDD", + Hash: "12345", + Preview: &AttachmentPreview{ + ContentType: "image/jpeg", + Content: "BASE64ENCODEDDATA", + }, + }, + }, + } + + // Marshal to JSON + data, err := json.Marshal(original) + require.NoError(t, err) + + // Parse back + parsed, err := ParseFileTransferMessage(string(data)) + require.NoError(t, err) + + // Verify roundtrip + assert.Equal(t, original.Body, parsed.Body) + require.Len(t, parsed.Attachments, 1) + att := parsed.Attachments[0] + assert.Equal(t, "image/png", att.ContentType) + assert.Equal(t, "https://example.com/image.png", att.ContentURL) + assert.Equal(t, int64(999999), att.ContentSize) + assert.Equal(t, "screenshot.png", att.Filename) + assert.Equal(t, "A screenshot", att.Description) + assert.Equal(t, "AABBCCDD", att.EncryptionKey) + assert.Equal(t, "12345", att.Hash) + require.NotNil(t, att.Preview) + assert.Equal(t, "image/jpeg", att.Preview.ContentType) + assert.Equal(t, "BASE64ENCODEDDATA", att.Preview.Content) +} diff --git a/service/messages.go b/service/messages.go index 031a903..5203153 100644 --- a/service/messages.go +++ b/service/messages.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "io" + "net/http" "os" "strconv" "strings" @@ -195,9 +197,156 @@ func (s *MessageService) SendMessage(ctx context.Context, req *models.SendMessag return nil, fmt.Errorf("send message: %w", err) } - content := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: req.Body, + var content *event.MessageEventContent + + // Check if this is a file transfer message + if models.IsFileTransferContentType(req.ContentType) { + logger.Debug().Str("content_type", req.ContentType).Msg("processing file transfer message") + + // Parse the file transfer JSON from the body + ftMsg, err := models.ParseFileTransferMessage(req.Body) + if err != nil { + logger.Warn().Err(err).Str("body", req.Body).Msg("failed to parse file transfer message") + return nil, fmt.Errorf("invalid file transfer message: %w", err) + } + + // Check if attachments are empty - if so, treat as text message + if len(ftMsg.Attachments) == 0 { + logger.Debug().Msg("file transfer message has empty attachments, sending as text message") + content = &event.MessageEventContent{ + MsgType: event.MsgText, + Body: ftMsg.Body, + } + } else { + // Download attachments from Acrobits and upload to Matrix content repository + msgType, rawContent, err := models.FileTransferToMatrixEventContent(ftMsg) + if err != nil { + logger.Warn().Err(err).Msg("failed to convert file transfer to Matrix format") + return nil, fmt.Errorf("failed to convert file transfer: %w", err) + } + + // Download and upload the main attachment + acrobitsURL := "" + if url, ok := rawContent["url"].(string); ok { + acrobitsURL = url + } + + mimetype := "" + if info, ok := rawContent["info"].(map[string]interface{}); ok { + if mt, ok := info["mimetype"].(string); ok { + mimetype = mt + } + } + + matrixURL := "" + uploadSuccess := true + fileSize := 0 + + if acrobitsURL != "" { + logger.Debug().Str("content_url", acrobitsURL).Msg("downloading attachment from Acrobits") + fileData, err := s.downloadFile(ctx, acrobitsURL) + if err != nil { + logger.Warn().Err(err).Str("content_url", acrobitsURL).Msg("failed to download attachment, falling back to text message") + // Fallback: send as text message only + content = &event.MessageEventContent{ + MsgType: event.MsgText, + Body: ftMsg.Body, + } + uploadSuccess = false + } else { + fileSize = len(fileData) + + // Detect content type from file data to ensure correct display + detectedMime := http.DetectContentType(fileData) + // Strip parameters (e.g. "; charset=utf-8") + if idx := strings.Index(detectedMime, ";"); idx != -1 { + detectedMime = detectedMime[:idx] + } + + // Use detected mime if it's an image or if original is missing/generic + if strings.HasPrefix(detectedMime, "image/") || mimetype == "" || mimetype == "application/octet-stream" { + mimetype = detectedMime + + // Update msgType based on new mimetype + if models.IsImageContentType(mimetype) { + msgType = "m.image" + } else if models.IsVideoContentType(mimetype) { + msgType = "m.video" + } else if models.IsAudioContentType(mimetype) { + msgType = "m.audio" + } + } + + // Upload to Matrix content repository + logger.Debug().Str("content_url", acrobitsURL).Int("size", fileSize).Str("mimetype", mimetype).Msg("uploading attachment to Matrix content repository") + uploadedURL, err := s.matrixClient.UploadMedia(ctx, senderMatrix, mimetype, fileData) + if err != nil { + logger.Warn().Err(err).Str("content_url", acrobitsURL).Msg("failed to upload attachment to Matrix, falling back to text message") + // Fallback: send as text message only + content = &event.MessageEventContent{ + MsgType: event.MsgText, + Body: ftMsg.Body, + } + uploadSuccess = false + } else { + matrixURL = string(uploadedURL) + logger.Debug().Str("matrix_url", matrixURL).Str("content_url", acrobitsURL).Msg("attachment uploaded to Matrix content repository") + } + } + } + + if uploadSuccess { + // Build the Matrix event content + content = &event.MessageEventContent{ + MsgType: event.MessageType(msgType), + Body: rawContent["body"].(string), + } + + // Set the URL for media messages (use uploaded Matrix URL) + if matrixURL != "" { + content.URL = id.ContentURIString(matrixURL) + } + + // Set the filename if present + if filename, ok := rawContent["filename"].(string); ok { + content.FileName = filename + } + + // Set info block if present + if info, ok := rawContent["info"].(map[string]interface{}); ok { + content.Info = &event.FileInfo{} + + // Use the resolved mimetype + content.Info.MimeType = mimetype + + // Use actual file size if available, otherwise fallback to info + if fileSize > 0 { + content.Info.Size = fileSize + } else if size, ok := info["size"].(int64); ok { + content.Info.Size = int(size) + } + + // Handle thumbnail info + if thumbnailURL, ok := info["thumbnail_url"].(string); ok { + content.Info.ThumbnailURL = id.ContentURIString(thumbnailURL) + } + if thumbnailInfo, ok := info["thumbnail_info"].(map[string]interface{}); ok { + content.Info.ThumbnailInfo = &event.FileInfo{} + if tm, ok := thumbnailInfo["mimetype"].(string); ok { + content.Info.ThumbnailInfo.MimeType = tm + } + } + } + + logger.Debug().Str("msg_type", msgType).Str("matrix_url", matrixURL).Msg("converted file transfer to Matrix media message") + } + } + } else { + // Regular text message + content = &event.MessageEventContent{ + MsgType: event.MsgText, + Body: req.Body, + } } resp, err := s.matrixClient.SendMessage(ctx, senderMatrix, roomID, content) @@ -292,18 +441,96 @@ func (s *MessageService) FetchMessages(ctx context.Context, req *models.FetchMes logger.Debug().Str("event_id", string(evt.ID)).Str("room_id", string(eventRoomID)).Msg("processing message event") - body := "" + // Extract content fields + var msgType string + var body string + var url string + var info map[string]interface{} + var filename string + + if t, ok := evt.Content.Raw["msgtype"].(string); ok { + msgType = t + } if b, ok := evt.Content.Raw["body"].(string); ok { body = b } + if fn, ok := evt.Content.Raw["filename"].(string); ok { + filename = fn + } + if u, ok := evt.Content.Raw["url"].(string); ok { + url = u + } + if i, ok := evt.Content.Raw["info"].(map[string]interface{}); ok { + info = i + } + sms := models.SMS{ SMSID: string(evt.ID), SendingDate: time.UnixMilli(evt.Timestamp).UTC().Format(time.RFC3339), - SMSText: body, - ContentType: "text/plain", StreamID: string(roomID), } + // Check if it's a media message + if msgType == "m.image" || msgType == "m.video" || msgType == "m.audio" || msgType == "m.file" { + // Convert to Acrobits file transfer format + // Resolve MXC URI to HTTP URL + httpURL := s.matrixClient.ResolveMXC(url) + if httpURL == "" { + logger.Warn().Str("event_id", string(evt.ID)).Msg("media event missing URL, falling back to text") + sms.SMSText = body + sms.ContentType = "text/plain" + } else { + // Extract metadata + mimetype := "" + var size int64 + thumbnailURL := "" + thumbnailMime := "" + + if info != nil { + if m, ok := info["mimetype"].(string); ok { + mimetype = m + } + if sVal, ok := info["size"].(float64); ok { // JSON numbers are float64 + size = int64(sVal) + } else if sVal, ok := info["size"].(int64); ok { + size = sVal + } else if sVal, ok := info["size"].(int); ok { + size = int64(sVal) + } + if tURL, ok := info["thumbnail_url"].(string); ok { + thumbnailURL = s.matrixClient.ResolveMXC(tURL) + } + if tInfo, ok := info["thumbnail_info"].(map[string]interface{}); ok { + if tm, ok := tInfo["mimetype"].(string); ok { + thumbnailMime = tm + } + } + } + + // Convert to JSON + // Prefer explicit filename if present; fall back to body + effectiveFilename := filename + if effectiveFilename == "" { + effectiveFilename = body + } + + ftJSON, err := models.MatrixMediaToFileTransfer(msgType, body, httpURL, mimetype, effectiveFilename, size, thumbnailURL, thumbnailMime) + if err != nil { + logger.Warn().Err(err).Msg("failed to convert matrix media to file transfer") + // Fallback to text + sms.SMSText = body + sms.ContentType = "text/plain" + } else { + sms.SMSText = ftJSON + sms.ContentType = models.FileTransferContentType + } + } + } else { + // Regular text message + sms.SMSText = body + sms.ContentType = "text/plain" + } + // Determine if I sent the message senderMatrixID := string(evt.Sender) isSent := isSentBy(senderMatrixID, string(userID)) @@ -817,3 +1044,41 @@ func (s *MessageService) clearBatchToken(userID string) { defer s.mu.Unlock() delete(s.batchTokens, userID) } + +// downloadFile downloads a file from the given URL with a context timeout. +// Returns the file contents as bytes, or an error if the download fails. +func (s *MessageService) downloadFile(ctx context.Context, url string) ([]byte, error) { + // Create a new request with context + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create download request: %w", err) + } + + // Execute the request + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to download file: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + // Read the response body with a reasonable size limit (100MB) + const maxSize = 100 * 1024 * 1024 // 100MB + limitedReader := io.LimitReader(resp.Body, maxSize+1) + data, err := io.ReadAll(limitedReader) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + if len(data) > maxSize { + return nil, fmt.Errorf("file too large: %d bytes exceeds limit of %d bytes", len(data), maxSize) + } + + logger.Debug().Str("url", url).Int("size", len(data)).Int("status", resp.StatusCode).Msg("file downloaded successfully") + return data, nil +}