Skip to content

Commit eb26399

Browse files
committed
feat: implement file transfer
1 parent 1a6a817 commit eb26399

File tree

5 files changed

+956
-6
lines changed

5 files changed

+956
-6
lines changed

docs/openapi.yaml

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ paths:
6363
If a phone number is provided and a mapping exists, the message is sent on behalf of the mapped Matrix user.
6464
If a phone number is provided but no mapping exists, the phone number is used as the sender ID.
6565
66+
**File Attachments:**
67+
To send files, set `content_type` to `application/x-acro-filetransfer+json` and provide the
68+
file transfer JSON in the `body` field. See the FileTransferMessage schema for the format.
69+
Matrix media URLs (mxc://) must already be uploaded to the Matrix content repository.
70+
6671
Authentication is handled by the Application Service backend; the `password` field is ignored.
6772
requestBody:
6873
required: true
@@ -87,10 +92,17 @@ paths:
8792
description: Recipient Matrix ID, Alias, or mapped phone number.
8893
body:
8994
type: string
90-
description: The message content.
95+
description: |
96+
The message content. For plain text messages, this is the text body.
97+
For file attachments (when content_type is application/x-acro-filetransfer+json),
98+
this contains the JSON-encoded FileTransferMessage.
9199
content_type:
92100
type: string
93101
default: text/plain
102+
description: |
103+
MIME content type. Supported values:
104+
- `text/plain` (default): Plain text message
105+
- `application/x-acro-filetransfer+json`: File attachment message
94106
disposition_notification:
95107
type: string
96108
description: Opaque string for read receipts.
@@ -279,10 +291,16 @@ components:
279291
description: Recipient identifier (phone number or user name). Only present in sent messages.
280292
sms_text:
281293
type: string
282-
description: Message body (UTF-8 encoded).
294+
description: |
295+
Message body (UTF-8 encoded). For plain text messages, this is the text content.
296+
For file attachments (when content_type is application/x-acro-filetransfer+json),
297+
this contains a JSON-encoded FileTransferMessage.
283298
content_type:
284299
type: string
285-
description: MIME content-type. Defaults to text/plain if omitted.
300+
description: |
301+
MIME content-type. Values:
302+
- `text/plain` (default): Plain text message, sms_text contains the text
303+
- `application/x-acro-filetransfer+json`: File attachment, sms_text contains FileTransferMessage JSON
286304
disposition_notification:
287305
type: string
288306
description: Opaque string from Send Message request. Can be omitted if empty.
@@ -292,6 +310,66 @@ components:
292310
stream_id:
293311
type: string
294312
description: Identifier for the conversation stream (Room ID or identifier).
313+
FileTransferMessage:
314+
type: object
315+
description: |
316+
Acrobits file transfer message format used when content_type is application/x-acro-filetransfer+json.
317+
See https://doc.acrobits.net/api/client/x-acro-filetransfer.html
318+
properties:
319+
body:
320+
type: string
321+
description: Optional text message applying to the attachment(s).
322+
attachments:
323+
type: array
324+
description: Array of file attachments.
325+
items:
326+
$ref: '#/components/schemas/Attachment'
327+
required:
328+
- attachments
329+
Attachment:
330+
type: object
331+
description: A single file attachment in the Acrobits file transfer format.
332+
properties:
333+
content-type:
334+
type: string
335+
description: MIME type of the file. If not present, image/jpeg is assumed.
336+
default: image/jpeg
337+
content-url:
338+
type: string
339+
description: URL to download the file content (Matrix mxc:// URL or HTTP URL).
340+
content-size:
341+
type: integer
342+
format: int64
343+
description: Size of the file in bytes. Used to decide download behavior.
344+
filename:
345+
type: string
346+
description: Original filename on the sending device.
347+
description:
348+
type: string
349+
description: Text description for the attachment (not widely used).
350+
encryption-key:
351+
type: string
352+
description: Hex-encoded AES128/192/256 CTR key for decryption. If present, content is encrypted.
353+
hash:
354+
type: string
355+
description: CRC32 digest of the decrypted binary data for integrity verification.
356+
preview:
357+
$ref: '#/components/schemas/AttachmentPreview'
358+
required:
359+
- content-url
360+
AttachmentPreview:
361+
type: object
362+
description: Low quality preview image for an attachment.
363+
properties:
364+
content-type:
365+
type: string
366+
description: MIME type of the preview. If not present, image/jpeg is assumed.
367+
default: image/jpeg
368+
content:
369+
type: string
370+
description: BASE64 encoded preview image data, or URL to thumbnail.
371+
required:
372+
- content
295373
FetchMessagesResponse:
296374
type: object
297375
description: Response from the fetch_messages endpoint following Acrobits Modern API specification.

models/filetransfer.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package models
2+
3+
// FileTransferContentType is the MIME type for Acrobits file transfer messages.
4+
const FileTransferContentType = "application/x-acro-filetransfer+json"
5+
6+
// FileTransferMessage represents the Acrobits file transfer format.
7+
// This is used when Content-Type is application/x-acro-filetransfer+json.
8+
// See: https://doc.acrobits.net/api/client/x-acro-filetransfer.html
9+
type FileTransferMessage struct {
10+
// Body is an optional text message applying to the attachment(s).
11+
Body string `json:"body,omitempty"`
12+
// Attachments is the array of individual attachment dictionaries.
13+
Attachments []Attachment `json:"attachments"`
14+
}
15+
16+
// Attachment represents a single file attachment in the Acrobits file transfer format.
17+
type Attachment struct {
18+
// ContentType is optional. If not present, image/jpeg is assumed.
19+
ContentType string `json:"content-type,omitempty"`
20+
// ContentURL is mandatory. It is the location from where to download the data.
21+
ContentURL string `json:"content-url"`
22+
// ContentSize is optional. Used to decide whether to download automatically.
23+
ContentSize int64 `json:"content-size,omitempty"`
24+
// Filename is optional. Original filename on the sending device.
25+
Filename string `json:"filename,omitempty"`
26+
// Description is optional. Text for the particular attachment (not used so far).
27+
Description string `json:"description,omitempty"`
28+
// EncryptionKey is optional. Hex-encoded AES128/192/256 CTR key for decryption.
29+
EncryptionKey string `json:"encryption-key,omitempty"`
30+
// Hash is optional. CRC32 digest of the decrypted binary data.
31+
Hash string `json:"hash,omitempty"`
32+
// Preview is optional. Low quality representation of the data to be downloaded.
33+
Preview *AttachmentPreview `json:"preview,omitempty"`
34+
}
35+
36+
// AttachmentPreview represents a preview image for an attachment.
37+
type AttachmentPreview struct {
38+
// ContentType is optional. If not present, image/jpeg is assumed.
39+
ContentType string `json:"content-type,omitempty"`
40+
// Content is mandatory. BASE64 representation of the preview image.
41+
Content string `json:"content"`
42+
}
43+
44+
// IsImageContentType checks if the content type is an image type.
45+
func IsImageContentType(contentType string) bool {
46+
switch contentType {
47+
case "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/bmp", "image/tiff":
48+
return true
49+
default:
50+
return false
51+
}
52+
}
53+
54+
// IsVideoContentType checks if the content type is a video type.
55+
func IsVideoContentType(contentType string) bool {
56+
switch contentType {
57+
case "video/mp4", "video/webm", "video/ogg", "video/quicktime", "video/x-msvideo":
58+
return true
59+
default:
60+
return false
61+
}
62+
}
63+
64+
// IsAudioContentType checks if the content type is an audio type.
65+
func IsAudioContentType(contentType string) bool {
66+
switch contentType {
67+
case "audio/mpeg", "audio/mp3", "audio/ogg", "audio/wav", "audio/webm", "audio/aac", "audio/flac":
68+
return true
69+
default:
70+
return false
71+
}
72+
}

models/filetransfer_convert.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package models
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
)
9+
10+
// MatrixToAcrobitsAttachment converts Matrix media event content to an Acrobits Attachment.
11+
// It handles m.image, m.video, m.audio, and m.file message types.
12+
func MatrixToAcrobitsAttachment(msgType, body, url, mimeType, filename string, size int64, thumbnailURL, thumbnailMimeType string, thumbnailData []byte) *Attachment {
13+
attachment := &Attachment{
14+
ContentURL: url,
15+
ContentType: mimeType,
16+
ContentSize: size,
17+
Filename: filename,
18+
Description: body,
19+
}
20+
21+
// If filename is empty but body is present, use body as filename for non-text content
22+
if attachment.Filename == "" && body != "" {
23+
attachment.Filename = body
24+
}
25+
26+
// Set default content type based on message type if not provided
27+
if attachment.ContentType == "" {
28+
switch msgType {
29+
case "m.image":
30+
attachment.ContentType = "image/jpeg"
31+
case "m.video":
32+
attachment.ContentType = "video/mp4"
33+
case "m.audio":
34+
attachment.ContentType = "audio/mpeg"
35+
default:
36+
attachment.ContentType = "application/octet-stream"
37+
}
38+
}
39+
40+
// Add preview/thumbnail if available
41+
if thumbnailURL != "" || len(thumbnailData) > 0 {
42+
preview := &AttachmentPreview{}
43+
if thumbnailMimeType != "" {
44+
preview.ContentType = thumbnailMimeType
45+
} else {
46+
preview.ContentType = "image/jpeg"
47+
}
48+
// If we have thumbnail data, encode it as base64
49+
// If we only have a URL, store the URL in Content field (client will need to fetch it)
50+
if len(thumbnailData) > 0 {
51+
preview.Content = base64.StdEncoding.EncodeToString(thumbnailData)
52+
} else if thumbnailURL != "" {
53+
// Store URL as a special marker for clients to fetch
54+
// This is a workaround since Acrobits expects base64 content
55+
preview.Content = thumbnailURL
56+
}
57+
attachment.Preview = preview
58+
}
59+
60+
return attachment
61+
}
62+
63+
// MatrixMediaToFileTransfer converts a Matrix media message to an Acrobits FileTransferMessage.
64+
// Returns the JSON-encoded file transfer message suitable for sms_text field.
65+
func MatrixMediaToFileTransfer(msgType, body, url, mimeType, filename string, size int64, thumbnailURL, thumbnailMimeType string) (string, error) {
66+
attachment := MatrixToAcrobitsAttachment(msgType, body, url, mimeType, filename, size, thumbnailURL, thumbnailMimeType, nil)
67+
68+
ftMsg := &FileTransferMessage{
69+
Body: body,
70+
Attachments: []Attachment{*attachment},
71+
}
72+
73+
jsonData, err := json.Marshal(ftMsg)
74+
if err != nil {
75+
return "", fmt.Errorf("failed to marshal file transfer message: %w", err)
76+
}
77+
78+
return string(jsonData), nil
79+
}
80+
81+
// ParseFileTransferMessage parses a JSON-encoded file transfer message.
82+
func ParseFileTransferMessage(data string) (*FileTransferMessage, error) {
83+
var ftMsg FileTransferMessage
84+
if err := json.Unmarshal([]byte(data), &ftMsg); err != nil {
85+
return nil, fmt.Errorf("failed to parse file transfer message: %w", err)
86+
}
87+
return &ftMsg, nil
88+
}
89+
90+
// FileTransferToMatrixEventContent converts an Acrobits file transfer message to Matrix event content.
91+
// Returns the message type, body, and a map suitable for Matrix event content.
92+
// For messages with multiple attachments, only the first attachment is used (Matrix doesn't support multiple files in one event).
93+
func FileTransferToMatrixEventContent(ftMsg *FileTransferMessage) (msgType string, content map[string]interface{}, err error) {
94+
if ftMsg == nil || len(ftMsg.Attachments) == 0 {
95+
return "", nil, fmt.Errorf("file transfer message has no attachments")
96+
}
97+
98+
// Use the first attachment
99+
att := ftMsg.Attachments[0]
100+
101+
// Determine Matrix message type based on content type
102+
contentType := att.ContentType
103+
if contentType == "" {
104+
contentType = "image/jpeg" // Default per Acrobits spec
105+
}
106+
107+
switch {
108+
case IsImageContentType(contentType):
109+
msgType = "m.image"
110+
case IsVideoContentType(contentType):
111+
msgType = "m.video"
112+
case IsAudioContentType(contentType):
113+
msgType = "m.audio"
114+
default:
115+
msgType = "m.file"
116+
}
117+
118+
// Build the message body
119+
body := att.Filename
120+
if body == "" {
121+
body = ftMsg.Body
122+
}
123+
if body == "" {
124+
body = "attachment"
125+
}
126+
127+
// Build Matrix event content
128+
content = map[string]interface{}{
129+
"msgtype": msgType,
130+
"body": body,
131+
"url": att.ContentURL,
132+
}
133+
134+
// Add info block
135+
info := map[string]interface{}{}
136+
if contentType != "" {
137+
info["mimetype"] = contentType
138+
}
139+
if att.ContentSize > 0 {
140+
info["size"] = att.ContentSize
141+
}
142+
143+
// Add thumbnail info if available
144+
if att.Preview != nil && att.Preview.Content != "" {
145+
// If preview content looks like a URL, use it as thumbnail_url
146+
if strings.HasPrefix(att.Preview.Content, "http://") || strings.HasPrefix(att.Preview.Content, "https://") || strings.HasPrefix(att.Preview.Content, "mxc://") {
147+
info["thumbnail_url"] = att.Preview.Content
148+
if att.Preview.ContentType != "" {
149+
info["thumbnail_info"] = map[string]interface{}{
150+
"mimetype": att.Preview.ContentType,
151+
}
152+
}
153+
}
154+
// Note: Base64 preview content cannot be directly used in Matrix events
155+
// The client would need to upload it first
156+
}
157+
158+
if len(info) > 0 {
159+
content["info"] = info
160+
}
161+
162+
// Add filename if available
163+
if att.Filename != "" {
164+
content["filename"] = att.Filename
165+
}
166+
167+
return msgType, content, nil
168+
}
169+
170+
// IsFileTransferContentType checks if the content type is the Acrobits file transfer type.
171+
func IsFileTransferContentType(contentType string) bool {
172+
return contentType == FileTransferContentType
173+
}
174+
175+
// ExtractMatrixMediaInfo extracts media information from Matrix event content.
176+
// Returns the content URL, mimetype, filename, size, thumbnail URL, and thumbnail mimetype.
177+
func ExtractMatrixMediaInfo(raw map[string]interface{}) (url, mimeType, filename string, size int64, thumbnailURL, thumbnailMimeType string) {
178+
// Extract URL
179+
if u, ok := raw["url"].(string); ok {
180+
url = u
181+
}
182+
183+
// Extract filename
184+
if f, ok := raw["filename"].(string); ok {
185+
filename = f
186+
}
187+
188+
// Extract info block
189+
if info, ok := raw["info"].(map[string]interface{}); ok {
190+
if mt, ok := info["mimetype"].(string); ok {
191+
mimeType = mt
192+
}
193+
if s, ok := info["size"].(float64); ok {
194+
size = int64(s)
195+
}
196+
// Extract thumbnail info
197+
if tu, ok := info["thumbnail_url"].(string); ok {
198+
thumbnailURL = tu
199+
}
200+
if ti, ok := info["thumbnail_info"].(map[string]interface{}); ok {
201+
if tm, ok := ti["mimetype"].(string); ok {
202+
thumbnailMimeType = tm
203+
}
204+
}
205+
}
206+
207+
return
208+
}

0 commit comments

Comments
 (0)