@doist/comms-sdk 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AttachmentsClient = void 0;
4
+ const endpoints_1 = require("../consts/endpoints");
5
+ const entities_1 = require("../types/entities");
6
+ const multipart_upload_1 = require("../utils/multipart-upload");
7
+ const uuidv7_1 = require("../utils/uuidv7");
8
+ const base_client_1 = require("./base-client");
9
+ /**
10
+ * Resolve the `attachment_id`: validate a caller-supplied value (throwing an
11
+ * {@link UuidV7Error} that names `attachmentId` rather than the generic `id`) or
12
+ * mint a fresh one. Wraps {@link resolveCreateId} so the error contract matches the
13
+ * client's actual parameter.
14
+ */
15
+ function resolveAttachmentId(attachmentId) {
16
+ if (attachmentId !== undefined && !(0, uuidv7_1.isValidUuidV7Base58)(attachmentId)) {
17
+ throw new uuidv7_1.UuidV7Error(`invalid attachmentId ${JSON.stringify(attachmentId)} — use generateId() or omit \`attachmentId\` and let the SDK mint one.`);
18
+ }
19
+ return (0, uuidv7_1.resolveCreateId)(attachmentId);
20
+ }
21
+ /**
22
+ * Client for uploading file attachments to Comms.
23
+ *
24
+ * Attachments are uploaded independently, then referenced by passing the returned
25
+ * {@link Attachment} into the `attachments` array of `comments.createComment`,
26
+ * `conversationMessages.createMessage`, and similar calls.
27
+ */
28
+ class AttachmentsClient extends base_client_1.BaseClient {
29
+ /**
30
+ * Uploads a file and returns the created {@link Attachment}.
31
+ *
32
+ * Mirrors the canonical multipart upload: `POST /attachments/upload` with the `file`
33
+ * binary plus `file_name`, `file_size`, `attachment_id`, and `underlying_type` form
34
+ * fields.
35
+ *
36
+ * @param args - The file to upload and optional metadata.
37
+ * @returns The created attachment, ready to attach to a comment or message.
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * import { readFile } from 'node:fs/promises'
42
+ *
43
+ * const attachment = await api.attachments.upload({
44
+ * file: await readFile('./diagram.png'),
45
+ * fileName: 'diagram.png',
46
+ * })
47
+ *
48
+ * await api.comments.createComment({
49
+ * threadId: '7YpL3oZ4kZ9vP7Q1tR2sX3z',
50
+ * content: 'See attached',
51
+ * attachments: [attachment],
52
+ * })
53
+ * ```
54
+ */
55
+ async upload(args) {
56
+ const data = await (0, multipart_upload_1.uploadMultipartFile)({
57
+ baseUrl: this.getBaseUri(),
58
+ authToken: this.apiToken,
59
+ endpoint: `${endpoints_1.ENDPOINT_ATTACHMENTS}/upload`,
60
+ file: args.file,
61
+ fileName: args.fileName,
62
+ contentType: args.contentType,
63
+ additionalFields: {
64
+ attachment_id: resolveAttachmentId(args.attachmentId),
65
+ },
66
+ customFetch: this.customFetch,
67
+ });
68
+ return entities_1.AttachmentSchema.parse(data);
69
+ }
70
+ }
71
+ exports.AttachmentsClient = AttachmentsClient;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CommsApi = void 0;
4
+ const attachments_client_1 = require("./clients/attachments-client");
4
5
  const channels_client_1 = require("./clients/channels-client");
5
6
  const comments_client_1 = require("./clients/comments-client");
6
7
  const conversation_messages_client_1 = require("./clients/conversation-messages-client");
@@ -51,6 +52,7 @@ class CommsApi {
51
52
  this.inbox = new inbox_client_1.InboxClient(clientConfig);
52
53
  this.reactions = new reactions_client_1.ReactionsClient(clientConfig);
53
54
  this.search = new search_client_1.SearchClient(clientConfig);
55
+ this.attachments = new attachments_client_1.AttachmentsClient(clientConfig);
54
56
  }
55
57
  /**
56
58
  * Drains the SDK's process-global connection pool. CLIs and scripts
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ENDPOINT_CONVERSATION_MESSAGES = exports.ENDPOINT_SEARCH = exports.ENDPOINT_REACTIONS = exports.ENDPOINT_INBOX = exports.ENDPOINT_NOTIFICATIONS = exports.ENDPOINT_COMMENTS = exports.ENDPOINT_CONVERSATIONS = exports.ENDPOINT_GROUPS = exports.ENDPOINT_THREADS = exports.ENDPOINT_CHANNELS = exports.ENDPOINT_WORKSPACES = exports.ENDPOINT_USERS = void 0;
3
+ exports.ENDPOINT_ATTACHMENTS = exports.ENDPOINT_CONVERSATION_MESSAGES = exports.ENDPOINT_SEARCH = exports.ENDPOINT_REACTIONS = exports.ENDPOINT_INBOX = exports.ENDPOINT_NOTIFICATIONS = exports.ENDPOINT_COMMENTS = exports.ENDPOINT_CONVERSATIONS = exports.ENDPOINT_GROUPS = exports.ENDPOINT_THREADS = exports.ENDPOINT_CHANNELS = exports.ENDPOINT_WORKSPACES = exports.ENDPOINT_USERS = void 0;
4
4
  exports.getCommsBaseUri = getCommsBaseUri;
5
5
  const api_version_1 = require("../types/api-version");
6
6
  const BASE_URI = 'https://comms.todoist.com';
@@ -30,3 +30,4 @@ exports.ENDPOINT_INBOX = 'inbox';
30
30
  exports.ENDPOINT_REACTIONS = 'reactions';
31
31
  exports.ENDPOINT_SEARCH = 'search';
32
32
  exports.ENDPOINT_CONVERSATION_MESSAGES = 'conversation_messages';
33
+ exports.ENDPOINT_ATTACHMENTS = 'attachments';
package/dist/cjs/index.js CHANGED
@@ -14,8 +14,10 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.closeDefaultDispatcher = exports.CommsApi = exports.WorkspacesClient = exports.WorkspaceUsersClient = exports.UsersClient = exports.ThreadsClient = exports.SearchClient = exports.ReactionsClient = exports.InboxClient = exports.GroupsClient = exports.ConversationsClient = exports.ConversationMessagesClient = exports.CommentsClient = exports.ChannelsClient = void 0;
17
+ exports.closeDefaultDispatcher = exports.CommsApi = exports.WorkspacesClient = exports.WorkspaceUsersClient = exports.UsersClient = exports.ThreadsClient = exports.SearchClient = exports.ReactionsClient = exports.InboxClient = exports.GroupsClient = exports.ConversationsClient = exports.ConversationMessagesClient = exports.CommentsClient = exports.ChannelsClient = exports.AttachmentsClient = void 0;
18
18
  __exportStar(require("./authentication"), exports);
19
+ var attachments_client_1 = require("./clients/attachments-client");
20
+ Object.defineProperty(exports, "AttachmentsClient", { enumerable: true, get: function () { return attachments_client_1.AttachmentsClient; } });
19
21
  var channels_client_1 = require("./clients/channels-client");
20
22
  Object.defineProperty(exports, "ChannelsClient", { enumerable: true, get: function () { return channels_client_1.ChannelsClient; } });
21
23
  var comments_client_1 = require("./clients/comments-client");
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getContentTypeFromFileName = getContentTypeFromFileName;
4
+ exports.uploadMultipartFile = uploadMultipartFile;
5
+ const fetch_with_retry_1 = require("../transport/fetch-with-retry");
6
+ /**
7
+ * Determine a content-type from a filename extension. Falls back to
8
+ * `application/octet-stream` for unknown extensions.
9
+ */
10
+ function getContentTypeFromFileName(fileName) {
11
+ const extension = fileName.toLowerCase().split('.').pop();
12
+ return extension ? (MIME_BY_EXTENSION[extension] ?? DEFAULT_MIME_TYPE) : DEFAULT_MIME_TYPE;
13
+ }
14
+ const DEFAULT_MIME_TYPE = 'application/octet-stream';
15
+ /**
16
+ * Extension → MIME map covering the formats commonly attached to messages.
17
+ * Anything not listed falls back to {@link DEFAULT_MIME_TYPE}; callers can
18
+ * always override via the `contentType` argument.
19
+ */
20
+ const MIME_BY_EXTENSION = {
21
+ // Images
22
+ png: 'image/png',
23
+ jpg: 'image/jpeg',
24
+ jpeg: 'image/jpeg',
25
+ gif: 'image/gif',
26
+ webp: 'image/webp',
27
+ svg: 'image/svg+xml',
28
+ bmp: 'image/bmp',
29
+ ico: 'image/x-icon',
30
+ tif: 'image/tiff',
31
+ tiff: 'image/tiff',
32
+ heic: 'image/heic',
33
+ heif: 'image/heif',
34
+ avif: 'image/avif',
35
+ // Documents / text
36
+ pdf: 'application/pdf',
37
+ txt: 'text/plain',
38
+ md: 'text/markdown',
39
+ csv: 'text/csv',
40
+ json: 'application/json',
41
+ xml: 'application/xml',
42
+ html: 'text/html',
43
+ htm: 'text/html',
44
+ rtf: 'application/rtf',
45
+ doc: 'application/msword',
46
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
47
+ xls: 'application/vnd.ms-excel',
48
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
49
+ ppt: 'application/vnd.ms-powerpoint',
50
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
51
+ // Archives
52
+ zip: 'application/zip',
53
+ gz: 'application/gzip',
54
+ tar: 'application/x-tar',
55
+ '7z': 'application/x-7z-compressed',
56
+ rar: 'application/vnd.rar',
57
+ // Audio
58
+ mp3: 'audio/mpeg',
59
+ wav: 'audio/wav',
60
+ ogg: 'audio/ogg',
61
+ m4a: 'audio/mp4',
62
+ flac: 'audio/flac',
63
+ aac: 'audio/aac',
64
+ // Video
65
+ mp4: 'video/mp4',
66
+ m4v: 'video/mp4',
67
+ mov: 'video/quicktime',
68
+ avi: 'video/x-msvideo',
69
+ webm: 'video/webm',
70
+ mkv: 'video/x-matroska',
71
+ };
72
+ /**
73
+ * Normalise a supported {@link UploadFile} into a `Blob` plus a resolved file name and
74
+ * content type, so uploads use the cross-platform global `FormData`/`Blob` body that
75
+ * `undici` and browsers both accept natively.
76
+ */
77
+ function toBlob(file, fileName, contentType) {
78
+ if (file instanceof Blob) {
79
+ // Duck-type the name rather than `instanceof File`: `File` isn't a global in
80
+ // Node 18, so a `node:buffer` `File` (which carries a real `name`) would
81
+ // otherwise fall back to `'upload'` and lose its inferred file name.
82
+ const blobName = file.name;
83
+ const name = fileName || (typeof blobName === 'string' ? blobName : undefined) || 'upload';
84
+ const type = contentType || file.type || getContentTypeFromFileName(name);
85
+ // Re-wrap only when stamping a type the Blob doesn't already carry.
86
+ const blob = file.type === type ? file : new Blob([file], { type });
87
+ return { blob, fileName: name, contentType: type };
88
+ }
89
+ if (file instanceof Uint8Array) {
90
+ if (!fileName) {
91
+ throw new Error('fileName is required when uploading raw bytes');
92
+ }
93
+ const type = contentType || getContentTypeFromFileName(fileName);
94
+ // `Blob` accepts any `ArrayBufferView`; the cast satisfies the stricter lib
95
+ // `BlobPart` type (which pins the backing buffer to `ArrayBuffer`).
96
+ return { blob: new Blob([file], { type }), fileName, contentType: type };
97
+ }
98
+ throw new Error('Unsupported file type for upload: expected a Blob or Uint8Array');
99
+ }
100
+ /**
101
+ * Upload a file using `multipart/form-data`.
102
+ *
103
+ * Builds the request body with the global `FormData`/`Blob` so it works unchanged in the
104
+ * browser and in Node.js (via `undici`). The `file` part is sent alongside `file_name`,
105
+ * `file_size`, and `underlying_type` fields (the canonical Comms upload shape); any
106
+ * `additionalFields` are merged in and override the derived values. Authentication uses
107
+ * `Authorization: Bearer`, matching every other Comms SDK client, and `Content-Type` is
108
+ * intentionally left unset so the runtime adds the correct multipart boundary.
109
+ *
110
+ * The response is JSON-parsed and camel-cased by {@link fetchWithRetry}; callers validate
111
+ * the returned shape with the appropriate schema.
112
+ */
113
+ async function uploadMultipartFile(args) {
114
+ const { baseUrl, authToken, endpoint, file, fileName, contentType, additionalFields, maxRetries = 0, requestId, customFetch, } = args;
115
+ const { blob, fileName: resolvedFileName, contentType: resolvedType, } = toBlob(file, fileName, contentType);
116
+ const fields = {
117
+ file_name: resolvedFileName,
118
+ file_size: blob.size,
119
+ underlying_type: resolvedType,
120
+ ...additionalFields,
121
+ };
122
+ const form = new FormData();
123
+ form.append('file', blob, resolvedFileName);
124
+ for (const [key, value] of Object.entries(fields)) {
125
+ if (value !== undefined && value !== null) {
126
+ form.append(key, String(value));
127
+ }
128
+ }
129
+ const headers = {
130
+ Authorization: `Bearer ${authToken}`,
131
+ };
132
+ if (requestId) {
133
+ headers['X-Request-Id'] = requestId;
134
+ }
135
+ const url = new URL(endpoint, baseUrl).toString();
136
+ const response = await (0, fetch_with_retry_1.fetchWithRetry)(url, {
137
+ method: 'POST',
138
+ headers,
139
+ body: form,
140
+ // Don't set Content-Type — the runtime adds the multipart boundary.
141
+ timeout: 30000,
142
+ }, maxRetries, customFetch);
143
+ return response.data;
144
+ }
@@ -0,0 +1,67 @@
1
+ import { ENDPOINT_ATTACHMENTS } from '../consts/endpoints.js';
2
+ import { AttachmentSchema } from '../types/entities.js';
3
+ import { uploadMultipartFile } from '../utils/multipart-upload.js';
4
+ import { isValidUuidV7Base58, resolveCreateId, UuidV7Error } from '../utils/uuidv7.js';
5
+ import { BaseClient } from './base-client.js';
6
+ /**
7
+ * Resolve the `attachment_id`: validate a caller-supplied value (throwing an
8
+ * {@link UuidV7Error} that names `attachmentId` rather than the generic `id`) or
9
+ * mint a fresh one. Wraps {@link resolveCreateId} so the error contract matches the
10
+ * client's actual parameter.
11
+ */
12
+ function resolveAttachmentId(attachmentId) {
13
+ if (attachmentId !== undefined && !isValidUuidV7Base58(attachmentId)) {
14
+ throw new UuidV7Error(`invalid attachmentId ${JSON.stringify(attachmentId)} — use generateId() or omit \`attachmentId\` and let the SDK mint one.`);
15
+ }
16
+ return resolveCreateId(attachmentId);
17
+ }
18
+ /**
19
+ * Client for uploading file attachments to Comms.
20
+ *
21
+ * Attachments are uploaded independently, then referenced by passing the returned
22
+ * {@link Attachment} into the `attachments` array of `comments.createComment`,
23
+ * `conversationMessages.createMessage`, and similar calls.
24
+ */
25
+ export class AttachmentsClient extends BaseClient {
26
+ /**
27
+ * Uploads a file and returns the created {@link Attachment}.
28
+ *
29
+ * Mirrors the canonical multipart upload: `POST /attachments/upload` with the `file`
30
+ * binary plus `file_name`, `file_size`, `attachment_id`, and `underlying_type` form
31
+ * fields.
32
+ *
33
+ * @param args - The file to upload and optional metadata.
34
+ * @returns The created attachment, ready to attach to a comment or message.
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * import { readFile } from 'node:fs/promises'
39
+ *
40
+ * const attachment = await api.attachments.upload({
41
+ * file: await readFile('./diagram.png'),
42
+ * fileName: 'diagram.png',
43
+ * })
44
+ *
45
+ * await api.comments.createComment({
46
+ * threadId: '7YpL3oZ4kZ9vP7Q1tR2sX3z',
47
+ * content: 'See attached',
48
+ * attachments: [attachment],
49
+ * })
50
+ * ```
51
+ */
52
+ async upload(args) {
53
+ const data = await uploadMultipartFile({
54
+ baseUrl: this.getBaseUri(),
55
+ authToken: this.apiToken,
56
+ endpoint: `${ENDPOINT_ATTACHMENTS}/upload`,
57
+ file: args.file,
58
+ fileName: args.fileName,
59
+ contentType: args.contentType,
60
+ additionalFields: {
61
+ attachment_id: resolveAttachmentId(args.attachmentId),
62
+ },
63
+ customFetch: this.customFetch,
64
+ });
65
+ return AttachmentSchema.parse(data);
66
+ }
67
+ }
@@ -1,3 +1,4 @@
1
+ import { AttachmentsClient } from './clients/attachments-client.js';
1
2
  import { ChannelsClient } from './clients/channels-client.js';
2
3
  import { CommentsClient } from './clients/comments-client.js';
3
4
  import { ConversationMessagesClient } from './clients/conversation-messages-client.js';
@@ -48,6 +49,7 @@ export class CommsApi {
48
49
  this.inbox = new InboxClient(clientConfig);
49
50
  this.reactions = new ReactionsClient(clientConfig);
50
51
  this.search = new SearchClient(clientConfig);
52
+ this.attachments = new AttachmentsClient(clientConfig);
51
53
  }
52
54
  /**
53
55
  * Drains the SDK's process-global connection pool. CLIs and scripts
@@ -26,3 +26,4 @@ export const ENDPOINT_INBOX = 'inbox';
26
26
  export const ENDPOINT_REACTIONS = 'reactions';
27
27
  export const ENDPOINT_SEARCH = 'search';
28
28
  export const ENDPOINT_CONVERSATION_MESSAGES = 'conversation_messages';
29
+ export const ENDPOINT_ATTACHMENTS = 'attachments';
package/dist/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './authentication.js';
2
+ export { AttachmentsClient } from './clients/attachments-client.js';
2
3
  export { ChannelsClient } from './clients/channels-client.js';
3
4
  export { CommentsClient } from './clients/comments-client.js';
4
5
  export { ConversationMessagesClient } from './clients/conversation-messages-client.js';
@@ -0,0 +1,140 @@
1
+ import { fetchWithRetry } from '../transport/fetch-with-retry.js';
2
+ /**
3
+ * Determine a content-type from a filename extension. Falls back to
4
+ * `application/octet-stream` for unknown extensions.
5
+ */
6
+ export function getContentTypeFromFileName(fileName) {
7
+ const extension = fileName.toLowerCase().split('.').pop();
8
+ return extension ? (MIME_BY_EXTENSION[extension] ?? DEFAULT_MIME_TYPE) : DEFAULT_MIME_TYPE;
9
+ }
10
+ const DEFAULT_MIME_TYPE = 'application/octet-stream';
11
+ /**
12
+ * Extension → MIME map covering the formats commonly attached to messages.
13
+ * Anything not listed falls back to {@link DEFAULT_MIME_TYPE}; callers can
14
+ * always override via the `contentType` argument.
15
+ */
16
+ const MIME_BY_EXTENSION = {
17
+ // Images
18
+ png: 'image/png',
19
+ jpg: 'image/jpeg',
20
+ jpeg: 'image/jpeg',
21
+ gif: 'image/gif',
22
+ webp: 'image/webp',
23
+ svg: 'image/svg+xml',
24
+ bmp: 'image/bmp',
25
+ ico: 'image/x-icon',
26
+ tif: 'image/tiff',
27
+ tiff: 'image/tiff',
28
+ heic: 'image/heic',
29
+ heif: 'image/heif',
30
+ avif: 'image/avif',
31
+ // Documents / text
32
+ pdf: 'application/pdf',
33
+ txt: 'text/plain',
34
+ md: 'text/markdown',
35
+ csv: 'text/csv',
36
+ json: 'application/json',
37
+ xml: 'application/xml',
38
+ html: 'text/html',
39
+ htm: 'text/html',
40
+ rtf: 'application/rtf',
41
+ doc: 'application/msword',
42
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
43
+ xls: 'application/vnd.ms-excel',
44
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
45
+ ppt: 'application/vnd.ms-powerpoint',
46
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
47
+ // Archives
48
+ zip: 'application/zip',
49
+ gz: 'application/gzip',
50
+ tar: 'application/x-tar',
51
+ '7z': 'application/x-7z-compressed',
52
+ rar: 'application/vnd.rar',
53
+ // Audio
54
+ mp3: 'audio/mpeg',
55
+ wav: 'audio/wav',
56
+ ogg: 'audio/ogg',
57
+ m4a: 'audio/mp4',
58
+ flac: 'audio/flac',
59
+ aac: 'audio/aac',
60
+ // Video
61
+ mp4: 'video/mp4',
62
+ m4v: 'video/mp4',
63
+ mov: 'video/quicktime',
64
+ avi: 'video/x-msvideo',
65
+ webm: 'video/webm',
66
+ mkv: 'video/x-matroska',
67
+ };
68
+ /**
69
+ * Normalise a supported {@link UploadFile} into a `Blob` plus a resolved file name and
70
+ * content type, so uploads use the cross-platform global `FormData`/`Blob` body that
71
+ * `undici` and browsers both accept natively.
72
+ */
73
+ function toBlob(file, fileName, contentType) {
74
+ if (file instanceof Blob) {
75
+ // Duck-type the name rather than `instanceof File`: `File` isn't a global in
76
+ // Node 18, so a `node:buffer` `File` (which carries a real `name`) would
77
+ // otherwise fall back to `'upload'` and lose its inferred file name.
78
+ const blobName = file.name;
79
+ const name = fileName || (typeof blobName === 'string' ? blobName : undefined) || 'upload';
80
+ const type = contentType || file.type || getContentTypeFromFileName(name);
81
+ // Re-wrap only when stamping a type the Blob doesn't already carry.
82
+ const blob = file.type === type ? file : new Blob([file], { type });
83
+ return { blob, fileName: name, contentType: type };
84
+ }
85
+ if (file instanceof Uint8Array) {
86
+ if (!fileName) {
87
+ throw new Error('fileName is required when uploading raw bytes');
88
+ }
89
+ const type = contentType || getContentTypeFromFileName(fileName);
90
+ // `Blob` accepts any `ArrayBufferView`; the cast satisfies the stricter lib
91
+ // `BlobPart` type (which pins the backing buffer to `ArrayBuffer`).
92
+ return { blob: new Blob([file], { type }), fileName, contentType: type };
93
+ }
94
+ throw new Error('Unsupported file type for upload: expected a Blob or Uint8Array');
95
+ }
96
+ /**
97
+ * Upload a file using `multipart/form-data`.
98
+ *
99
+ * Builds the request body with the global `FormData`/`Blob` so it works unchanged in the
100
+ * browser and in Node.js (via `undici`). The `file` part is sent alongside `file_name`,
101
+ * `file_size`, and `underlying_type` fields (the canonical Comms upload shape); any
102
+ * `additionalFields` are merged in and override the derived values. Authentication uses
103
+ * `Authorization: Bearer`, matching every other Comms SDK client, and `Content-Type` is
104
+ * intentionally left unset so the runtime adds the correct multipart boundary.
105
+ *
106
+ * The response is JSON-parsed and camel-cased by {@link fetchWithRetry}; callers validate
107
+ * the returned shape with the appropriate schema.
108
+ */
109
+ export async function uploadMultipartFile(args) {
110
+ const { baseUrl, authToken, endpoint, file, fileName, contentType, additionalFields, maxRetries = 0, requestId, customFetch, } = args;
111
+ const { blob, fileName: resolvedFileName, contentType: resolvedType, } = toBlob(file, fileName, contentType);
112
+ const fields = {
113
+ file_name: resolvedFileName,
114
+ file_size: blob.size,
115
+ underlying_type: resolvedType,
116
+ ...additionalFields,
117
+ };
118
+ const form = new FormData();
119
+ form.append('file', blob, resolvedFileName);
120
+ for (const [key, value] of Object.entries(fields)) {
121
+ if (value !== undefined && value !== null) {
122
+ form.append(key, String(value));
123
+ }
124
+ }
125
+ const headers = {
126
+ Authorization: `Bearer ${authToken}`,
127
+ };
128
+ if (requestId) {
129
+ headers['X-Request-Id'] = requestId;
130
+ }
131
+ const url = new URL(endpoint, baseUrl).toString();
132
+ const response = await fetchWithRetry(url, {
133
+ method: 'POST',
134
+ headers,
135
+ body: form,
136
+ // Don't set Content-Type — the runtime adds the multipart boundary.
137
+ timeout: 30000,
138
+ }, maxRetries, customFetch);
139
+ return response.data;
140
+ }
@@ -0,0 +1,39 @@
1
+ import { type Attachment } from '../types/entities.js';
2
+ import type { UploadAttachmentArgs } from '../types/requests.js';
3
+ import { BaseClient } from './base-client.js';
4
+ /**
5
+ * Client for uploading file attachments to Comms.
6
+ *
7
+ * Attachments are uploaded independently, then referenced by passing the returned
8
+ * {@link Attachment} into the `attachments` array of `comments.createComment`,
9
+ * `conversationMessages.createMessage`, and similar calls.
10
+ */
11
+ export declare class AttachmentsClient extends BaseClient {
12
+ /**
13
+ * Uploads a file and returns the created {@link Attachment}.
14
+ *
15
+ * Mirrors the canonical multipart upload: `POST /attachments/upload` with the `file`
16
+ * binary plus `file_name`, `file_size`, `attachment_id`, and `underlying_type` form
17
+ * fields.
18
+ *
19
+ * @param args - The file to upload and optional metadata.
20
+ * @returns The created attachment, ready to attach to a comment or message.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import { readFile } from 'node:fs/promises'
25
+ *
26
+ * const attachment = await api.attachments.upload({
27
+ * file: await readFile('./diagram.png'),
28
+ * fileName: 'diagram.png',
29
+ * })
30
+ *
31
+ * await api.comments.createComment({
32
+ * threadId: '7YpL3oZ4kZ9vP7Q1tR2sX3z',
33
+ * content: 'See attached',
34
+ * attachments: [attachment],
35
+ * })
36
+ * ```
37
+ */
38
+ upload(args: UploadAttachmentArgs): Promise<Attachment>;
39
+ }
@@ -1,3 +1,4 @@
1
+ import { AttachmentsClient } from './clients/attachments-client.js';
1
2
  import { ChannelsClient } from './clients/channels-client.js';
2
3
  import { CommentsClient } from './clients/comments-client.js';
3
4
  import { ConversationMessagesClient } from './clients/conversation-messages-client.js';
@@ -44,6 +45,7 @@ export declare class CommsApi {
44
45
  inbox: InboxClient;
45
46
  reactions: ReactionsClient;
46
47
  search: SearchClient;
48
+ attachments: AttachmentsClient;
47
49
  /**
48
50
  * Creates a new Comms API client.
49
51
  *
@@ -22,3 +22,4 @@ export declare const ENDPOINT_INBOX = "inbox";
22
22
  export declare const ENDPOINT_REACTIONS = "reactions";
23
23
  export declare const ENDPOINT_SEARCH = "search";
24
24
  export declare const ENDPOINT_CONVERSATION_MESSAGES = "conversation_messages";
25
+ export declare const ENDPOINT_ATTACHMENTS = "attachments";
@@ -1,4 +1,5 @@
1
1
  export * from './authentication.js';
2
+ export { AttachmentsClient } from './clients/attachments-client.js';
2
3
  export { ChannelsClient } from './clients/channels-client.js';
3
4
  export { CommentsClient } from './clients/comments-client.js';
4
5
  export { ConversationMessagesClient } from './clients/conversation-messages-client.js';
@@ -368,4 +368,26 @@ export type GetUserLocalTimeArgs = {
368
368
  workspaceId: number;
369
369
  userId: number;
370
370
  };
371
+ type UploadAttachmentCommonArgs = {
372
+ /** MIME type. Defaults to the `Blob`'s type or one inferred from the file extension. */
373
+ contentType?: string;
374
+ /** Attachment ID to use. A random ID is generated when omitted. */
375
+ attachmentId?: string;
376
+ };
377
+ /**
378
+ * Arguments for `attachments.upload`. A discriminated union on `file` enforces, at
379
+ * compile time, that raw `Uint8Array` bytes are accompanied by a `fileName` (there is
380
+ * no name to infer), while a `Blob`/`File` may omit it.
381
+ */
382
+ export type UploadAttachmentArgs = UploadAttachmentCommonArgs & ({
383
+ /** The file to upload — a `Blob`/`File` (browser, or any runtime with a global `Blob`). */
384
+ file: Blob;
385
+ /** File name. Inferred from the `File.name` when omitted. */
386
+ fileName?: string;
387
+ } | {
388
+ /** Raw bytes to upload. A Node `Buffer` is a `Uint8Array`, so `await readFile(path)` works directly. */
389
+ file: Uint8Array;
390
+ /** File name. Required for raw bytes — there is no name to infer. */
391
+ fileName: string;
392
+ });
371
393
  export {};
@@ -1,3 +1,4 @@
1
1
  export * from './case-conversion.js';
2
+ export type { UploadFile } from './multipart-upload.js';
2
3
  export * from './url-helpers.js';
3
4
  export * from './uuidv7.js';
@@ -0,0 +1,58 @@
1
+ import type { CustomFetch } from '../types/http.js';
2
+ /**
3
+ * File content accepted by the upload helpers.
4
+ *
5
+ * - `Blob`/`File` — browser, or any runtime with a global `Blob` (Node 18+).
6
+ * - `Uint8Array` — raw bytes (requires `fileName`). A Node `Buffer` is a `Uint8Array`,
7
+ * so reading a file with `fs` and passing the result works without conversion.
8
+ *
9
+ * Both are universal types, so the published `.d.ts` stays free of Node-only globals
10
+ * (`Buffer`, `NodeJS.*`) and the helper pulls in no Node built-ins — keeping the browser
11
+ * bundle clean.
12
+ */
13
+ export type UploadFile = Blob | Uint8Array;
14
+ type UploadMultipartFileArgs = {
15
+ /** Base API URI with trailing slash, e.g. `https://comms.todoist.com/api/v1/`. */
16
+ baseUrl: string;
17
+ /** API token used for `Authorization: Bearer`. */
18
+ authToken: string;
19
+ /** Relative endpoint path, e.g. `attachments/upload`. */
20
+ endpoint: string;
21
+ /** File content to upload. */
22
+ file: UploadFile;
23
+ /** File name. Required for raw `Uint8Array` bytes; inferred from a `File`. */
24
+ fileName?: string;
25
+ /** MIME type. Defaults to the `Blob`'s type or one inferred from the file extension. */
26
+ contentType?: string;
27
+ /** Extra multipart fields to send alongside the file metadata fields. */
28
+ additionalFields?: Record<string, string | number | boolean | undefined | null>;
29
+ /**
30
+ * Network-error retry count. Defaults to `0` — unlike JSON requests, a retry here
31
+ * resends the entire file body, so large uploads aren't retried automatically.
32
+ */
33
+ maxRetries?: number;
34
+ /** Optional request ID for tracing. */
35
+ requestId?: string;
36
+ /** Optional custom fetch implementation. */
37
+ customFetch?: CustomFetch;
38
+ };
39
+ /**
40
+ * Determine a content-type from a filename extension. Falls back to
41
+ * `application/octet-stream` for unknown extensions.
42
+ */
43
+ export declare function getContentTypeFromFileName(fileName: string): string;
44
+ /**
45
+ * Upload a file using `multipart/form-data`.
46
+ *
47
+ * Builds the request body with the global `FormData`/`Blob` so it works unchanged in the
48
+ * browser and in Node.js (via `undici`). The `file` part is sent alongside `file_name`,
49
+ * `file_size`, and `underlying_type` fields (the canonical Comms upload shape); any
50
+ * `additionalFields` are merged in and override the derived values. Authentication uses
51
+ * `Authorization: Bearer`, matching every other Comms SDK client, and `Content-Type` is
52
+ * intentionally left unset so the runtime adds the correct multipart boundary.
53
+ *
54
+ * The response is JSON-parsed and camel-cased by {@link fetchWithRetry}; callers validate
55
+ * the returned shape with the appropriate schema.
56
+ */
57
+ export declare function uploadMultipartFile<T>(args: UploadMultipartFileArgs): Promise<T>;
58
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/comms-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A TypeScript wrapper for the Comms REST API.",
5
5
  "author": "Doist developers",
6
6
  "homepage": "https://doist.github.io/comms-sdk-typescript/",