@doist/comms-sdk 0.1.0-alpha.1
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.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/cjs/authentication.js +211 -0
- package/dist/cjs/clients/add-comment-helper.js +53 -0
- package/dist/cjs/clients/base-client.js +27 -0
- package/dist/cjs/clients/channels-client.js +83 -0
- package/dist/cjs/clients/comments-client.js +93 -0
- package/dist/cjs/clients/conversation-messages-client.js +87 -0
- package/dist/cjs/clients/conversations-client.js +103 -0
- package/dist/cjs/clients/groups-client.js +71 -0
- package/dist/cjs/clients/inbox-client.js +98 -0
- package/dist/cjs/clients/reactions-client.js +59 -0
- package/dist/cjs/clients/search-client.js +88 -0
- package/dist/cjs/clients/threads-client.js +135 -0
- package/dist/cjs/clients/users-client.js +199 -0
- package/dist/cjs/clients/workspace-users-client.js +140 -0
- package/dist/cjs/clients/workspaces-client.js +93 -0
- package/dist/cjs/comms-api.js +65 -0
- package/dist/cjs/consts/endpoints.js +27 -0
- package/dist/cjs/index.js +48 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/testUtils/msw-handlers.js +51 -0
- package/dist/cjs/testUtils/msw-setup.js +21 -0
- package/dist/cjs/testUtils/obsidian-fetch-adapter.js +53 -0
- package/dist/cjs/testUtils/test-defaults.js +102 -0
- package/dist/cjs/transport/fetch-with-retry.js +136 -0
- package/dist/cjs/transport/http-client.js +56 -0
- package/dist/cjs/transport/http-dispatcher.js +143 -0
- package/dist/cjs/types/entities.js +411 -0
- package/dist/cjs/types/enums.js +37 -0
- package/dist/cjs/types/errors.js +12 -0
- package/dist/cjs/types/http.js +4 -0
- package/dist/cjs/types/index.js +21 -0
- package/dist/cjs/types/requests.js +117 -0
- package/dist/cjs/utils/case-conversion.js +54 -0
- package/dist/cjs/utils/index.js +19 -0
- package/dist/cjs/utils/timestamp-conversion.js +49 -0
- package/dist/cjs/utils/url-helpers.js +131 -0
- package/dist/cjs/utils/uuidv7.js +174 -0
- package/dist/esm/authentication.js +203 -0
- package/dist/esm/clients/add-comment-helper.js +50 -0
- package/dist/esm/clients/base-client.js +23 -0
- package/dist/esm/clients/channels-client.js +79 -0
- package/dist/esm/clients/comments-client.js +89 -0
- package/dist/esm/clients/conversation-messages-client.js +83 -0
- package/dist/esm/clients/conversations-client.js +99 -0
- package/dist/esm/clients/groups-client.js +67 -0
- package/dist/esm/clients/inbox-client.js +94 -0
- package/dist/esm/clients/reactions-client.js +55 -0
- package/dist/esm/clients/search-client.js +84 -0
- package/dist/esm/clients/threads-client.js +131 -0
- package/dist/esm/clients/users-client.js +195 -0
- package/dist/esm/clients/workspace-users-client.js +136 -0
- package/dist/esm/clients/workspaces-client.js +89 -0
- package/dist/esm/comms-api.js +61 -0
- package/dist/esm/consts/endpoints.js +23 -0
- package/dist/esm/index.js +17 -0
- package/dist/esm/testUtils/msw-handlers.js +45 -0
- package/dist/esm/testUtils/msw-setup.js +18 -0
- package/dist/esm/testUtils/obsidian-fetch-adapter.js +50 -0
- package/dist/esm/testUtils/test-defaults.js +99 -0
- package/dist/esm/transport/fetch-with-retry.js +133 -0
- package/dist/esm/transport/http-client.js +51 -0
- package/dist/esm/transport/http-dispatcher.js +104 -0
- package/dist/esm/types/entities.js +408 -0
- package/dist/esm/types/enums.js +34 -0
- package/dist/esm/types/errors.js +8 -0
- package/dist/esm/types/http.js +1 -0
- package/dist/esm/types/index.js +5 -0
- package/dist/esm/types/requests.js +114 -0
- package/dist/esm/utils/case-conversion.js +47 -0
- package/dist/esm/utils/index.js +3 -0
- package/dist/esm/utils/timestamp-conversion.js +45 -0
- package/dist/esm/utils/url-helpers.js +112 -0
- package/dist/esm/utils/uuidv7.js +163 -0
- package/dist/types/authentication.d.ts +160 -0
- package/dist/types/clients/add-comment-helper.d.ts +12 -0
- package/dist/types/clients/base-client.d.ts +24 -0
- package/dist/types/clients/channels-client.d.ts +91 -0
- package/dist/types/clients/comments-client.d.ts +157 -0
- package/dist/types/clients/conversation-messages-client.d.ts +127 -0
- package/dist/types/clients/conversations-client.d.ts +206 -0
- package/dist/types/clients/groups-client.d.ts +55 -0
- package/dist/types/clients/inbox-client.d.ts +20 -0
- package/dist/types/clients/reactions-client.d.ts +19 -0
- package/dist/types/clients/search-client.d.ts +20 -0
- package/dist/types/clients/threads-client.d.ts +344 -0
- package/dist/types/clients/users-client.d.ts +123 -0
- package/dist/types/clients/workspace-users-client.d.ts +47 -0
- package/dist/types/clients/workspaces-client.d.ts +79 -0
- package/dist/types/comms-api.d.ts +59 -0
- package/dist/types/consts/endpoints.d.ts +19 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/testUtils/msw-handlers.d.ts +28 -0
- package/dist/types/testUtils/msw-setup.d.ts +1 -0
- package/dist/types/testUtils/obsidian-fetch-adapter.d.ts +29 -0
- package/dist/types/testUtils/test-defaults.d.ts +16 -0
- package/dist/types/transport/fetch-with-retry.d.ts +4 -0
- package/dist/types/transport/http-client.d.ts +13 -0
- package/dist/types/transport/http-dispatcher.d.ts +10 -0
- package/dist/types/types/entities.d.ts +1288 -0
- package/dist/types/types/enums.d.ts +55 -0
- package/dist/types/types/errors.d.ts +6 -0
- package/dist/types/types/http.d.ts +54 -0
- package/dist/types/types/index.d.ts +5 -0
- package/dist/types/types/requests.d.ts +385 -0
- package/dist/types/utils/case-conversion.d.ts +8 -0
- package/dist/types/utils/index.d.ts +3 -0
- package/dist/types/utils/timestamp-conversion.d.ts +13 -0
- package/dist/types/utils/url-helpers.d.ts +88 -0
- package/dist/types/utils/uuidv7.d.ts +40 -0
- package/package.json +93 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.timestampToDate = timestampToDate;
|
|
4
|
+
exports.transformTimestamps = transformTimestamps;
|
|
5
|
+
/**
|
|
6
|
+
* Converts a Unix timestamp (in seconds) to a Date object.
|
|
7
|
+
* @param timestamp - Unix timestamp in seconds
|
|
8
|
+
* @returns Date object
|
|
9
|
+
*/
|
|
10
|
+
function timestampToDate(timestamp) {
|
|
11
|
+
return new Date(timestamp * 1000);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Recursively transforms all timestamp fields (ending in 'Ts') in an object to Date objects.
|
|
15
|
+
* Also renames the fields by removing the 'Ts' suffix.
|
|
16
|
+
* @param obj - The object to transform
|
|
17
|
+
* @returns The transformed object with Date fields
|
|
18
|
+
*/
|
|
19
|
+
function transformTimestamps(obj) {
|
|
20
|
+
if (obj === null || obj === undefined) {
|
|
21
|
+
return obj;
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(obj)) {
|
|
24
|
+
return obj.map((item) => transformTimestamps(item));
|
|
25
|
+
}
|
|
26
|
+
if (typeof obj === 'object') {
|
|
27
|
+
const result = {};
|
|
28
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
29
|
+
// Check if the key ends with 'Ts' and the value is a number
|
|
30
|
+
if (key.endsWith('Ts') && typeof value === 'number') {
|
|
31
|
+
// Remove 'Ts' suffix and convert to Date
|
|
32
|
+
const newKey = key.slice(0, -2);
|
|
33
|
+
// If the base key already exists in the original object, use *Date suffix
|
|
34
|
+
// to avoid overwriting it (e.g. pinned + pinnedTs → pinned + pinnedDate)
|
|
35
|
+
const targetKey = newKey in obj ? `${newKey}Date` : newKey;
|
|
36
|
+
result[targetKey] = timestampToDate(value);
|
|
37
|
+
}
|
|
38
|
+
else if (typeof value === 'object' && value !== null) {
|
|
39
|
+
// Recursively transform nested objects
|
|
40
|
+
result[key] = transformTimestamps(value);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
result[key] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
return obj;
|
|
49
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Helper functions for creating Comms permalinks (`https://comms.todoist.com/a/...`).
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getCommsURL = getCommsURL;
|
|
7
|
+
exports.getFullCommsURL = getFullCommsURL;
|
|
8
|
+
exports.getThreadURL = getThreadURL;
|
|
9
|
+
exports.getChannelURL = getChannelURL;
|
|
10
|
+
exports.getConversationURL = getConversationURL;
|
|
11
|
+
exports.getMessageURL = getMessageURL;
|
|
12
|
+
exports.getCommentURL = getCommentURL;
|
|
13
|
+
exports.getThreadsRootURL = getThreadsRootURL;
|
|
14
|
+
exports.getInboxURL = getInboxURL;
|
|
15
|
+
exports.getMessagesRootURL = getMessagesRootURL;
|
|
16
|
+
exports.getUserProfileURL = getUserProfileURL;
|
|
17
|
+
exports.getSavedThreadsRootURL = getSavedThreadsRootURL;
|
|
18
|
+
exports.getSavedThreadURL = getSavedThreadURL;
|
|
19
|
+
exports.getSearchRootURL = getSearchRootURL;
|
|
20
|
+
exports.getSearchQueryURL = getSearchQueryURL;
|
|
21
|
+
exports.getSettingsURL = getSettingsURL;
|
|
22
|
+
exports.getTeamMembersRootURL = getTeamMembersRootURL;
|
|
23
|
+
const COMMS_BASE_URL = 'https://comms.todoist.com';
|
|
24
|
+
/**
|
|
25
|
+
* Builds a relative Comms URL based on the provided parameters
|
|
26
|
+
* @param params - URL parameters including workspace, channel, conversation, thread, etc.
|
|
27
|
+
* @returns A relative URL path
|
|
28
|
+
* @example
|
|
29
|
+
* getCommsURL({ workspaceId: 1, channelId: '7Yp...', threadId: '7Yq...' })
|
|
30
|
+
* // returns '/a/1/ch/7Yp.../t/7Yq.../'
|
|
31
|
+
*/
|
|
32
|
+
function getCommsURL(params) {
|
|
33
|
+
const { workspaceId, channelId, conversationId, threadId, commentId, messageId, userId } = params;
|
|
34
|
+
let url = `/a/${workspaceId}/`;
|
|
35
|
+
if (channelId) {
|
|
36
|
+
url += `ch/${channelId}/`;
|
|
37
|
+
if (threadId) {
|
|
38
|
+
url += `t/${threadId}/`;
|
|
39
|
+
if (commentId) {
|
|
40
|
+
url += `c/${commentId}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if (threadId) {
|
|
45
|
+
url += `inbox/t/${threadId}/`;
|
|
46
|
+
if (commentId) {
|
|
47
|
+
url += `c/${commentId}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (conversationId) {
|
|
51
|
+
url += `msg/${conversationId}/`;
|
|
52
|
+
if (messageId) {
|
|
53
|
+
url += `m/${messageId}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (userId) {
|
|
57
|
+
url += `people/u/${userId}`;
|
|
58
|
+
}
|
|
59
|
+
return url;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Builds a full Comms URL (with protocol and hostname) based on the provided parameters
|
|
63
|
+
* @param params - URL parameters including workspace, channel, conversation, thread, etc.
|
|
64
|
+
* @param baseUrl - Optional base URL (defaults to 'https://comms.todoist.com')
|
|
65
|
+
*/
|
|
66
|
+
function getFullCommsURL(params, baseUrl = COMMS_BASE_URL) {
|
|
67
|
+
return `${baseUrl}${getCommsURL(params)}`;
|
|
68
|
+
}
|
|
69
|
+
/** Returns the URL for a thread in a channel. */
|
|
70
|
+
function getThreadURL(params) {
|
|
71
|
+
return getCommsURL(params);
|
|
72
|
+
}
|
|
73
|
+
/** Returns the URL for a channel. */
|
|
74
|
+
function getChannelURL(params) {
|
|
75
|
+
return getCommsURL(params);
|
|
76
|
+
}
|
|
77
|
+
/** Returns the URL for a conversation. */
|
|
78
|
+
function getConversationURL(params) {
|
|
79
|
+
return getCommsURL(params);
|
|
80
|
+
}
|
|
81
|
+
/** Returns the URL for a specific message in a conversation. */
|
|
82
|
+
function getMessageURL(params) {
|
|
83
|
+
return getCommsURL(params);
|
|
84
|
+
}
|
|
85
|
+
/** Returns the URL for a comment in a thread. */
|
|
86
|
+
function getCommentURL(params) {
|
|
87
|
+
return getCommsURL(params);
|
|
88
|
+
}
|
|
89
|
+
/** Returns the URL for the threads root (channels view). */
|
|
90
|
+
function getThreadsRootURL(workspaceId) {
|
|
91
|
+
return `/a/${workspaceId}/ch`;
|
|
92
|
+
}
|
|
93
|
+
/** Returns the URL for the inbox. */
|
|
94
|
+
function getInboxURL(workspaceId, tab) {
|
|
95
|
+
const tabParam = tab ? `/${tab}` : '';
|
|
96
|
+
return `/a/${workspaceId}/inbox${tabParam}`;
|
|
97
|
+
}
|
|
98
|
+
/** Returns the URL for the messages/conversations root. */
|
|
99
|
+
function getMessagesRootURL(workspaceId) {
|
|
100
|
+
return `/a/${workspaceId}/msg`;
|
|
101
|
+
}
|
|
102
|
+
/** Returns the URL for a user profile. */
|
|
103
|
+
function getUserProfileURL(params) {
|
|
104
|
+
return `/a/${params.workspaceId}/people/u/${params.userId}`;
|
|
105
|
+
}
|
|
106
|
+
/** Returns the URL for the saved threads view. */
|
|
107
|
+
function getSavedThreadsRootURL(workspaceId) {
|
|
108
|
+
return `/a/${workspaceId}/saved`;
|
|
109
|
+
}
|
|
110
|
+
/** Returns the URL for a saved thread. */
|
|
111
|
+
function getSavedThreadURL(params) {
|
|
112
|
+
return `/a/${params.workspaceId}/saved/t/${params.threadId}`;
|
|
113
|
+
}
|
|
114
|
+
/** Returns the URL for the search root. */
|
|
115
|
+
function getSearchRootURL(workspaceId) {
|
|
116
|
+
return `/a/${workspaceId}/search`;
|
|
117
|
+
}
|
|
118
|
+
/** Returns the URL for a search with a query. */
|
|
119
|
+
function getSearchQueryURL(params) {
|
|
120
|
+
return `/a/${params.workspaceId}/search?q=${decodeURIComponent(params.query)}`;
|
|
121
|
+
}
|
|
122
|
+
/** Returns the URL for settings. */
|
|
123
|
+
function getSettingsURL(params) {
|
|
124
|
+
return params.initialLocation
|
|
125
|
+
? `/a/${params.workspaceId}/settings/${params.initialLocation}`
|
|
126
|
+
: `/a/${params.workspaceId}/settings`;
|
|
127
|
+
}
|
|
128
|
+
/** Returns the URL for the team members root. */
|
|
129
|
+
function getTeamMembersRootURL(workspaceId) {
|
|
130
|
+
return `/a/${workspaceId}/people/u`;
|
|
131
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UuidV7Error = void 0;
|
|
4
|
+
exports.encodeUuidToBase58 = encodeUuidToBase58;
|
|
5
|
+
exports.decodeBase58ToUuidBytes = decodeBase58ToUuidBytes;
|
|
6
|
+
exports.generateId = generateId;
|
|
7
|
+
exports.resolveCreateId = resolveCreateId;
|
|
8
|
+
exports.isValidUuidV7Base58 = isValidUuidV7Base58;
|
|
9
|
+
exports.base58FromUuidString = base58FromUuidString;
|
|
10
|
+
exports.uuidStringFromBase58 = uuidStringFromBase58;
|
|
11
|
+
const uuid_1 = require("uuid");
|
|
12
|
+
/**
|
|
13
|
+
* ID utilities for entities that use opaque string identifiers (channels,
|
|
14
|
+
* threads, comments, conversations, messages, groups).
|
|
15
|
+
*
|
|
16
|
+
* Use {@link generateId} to mint a new ID locally and pass it to a creation
|
|
17
|
+
* endpoint. {@link encodeUuidToBase58} / {@link decodeBase58ToUuidBytes}
|
|
18
|
+
* expose the underlying encoding for callers that need to round-trip raw
|
|
19
|
+
* UUID bytes themselves.
|
|
20
|
+
*/
|
|
21
|
+
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
22
|
+
const BASE58_MAP = (() => {
|
|
23
|
+
const map = {};
|
|
24
|
+
for (let i = 0; i < BASE58_ALPHABET.length; i++) {
|
|
25
|
+
map[BASE58_ALPHABET[i]] = i;
|
|
26
|
+
}
|
|
27
|
+
return map;
|
|
28
|
+
})();
|
|
29
|
+
const UUID_BYTES_LEN = 16;
|
|
30
|
+
const UUID_BASE58_MAX_LEN = 22;
|
|
31
|
+
const HEX_RE = /^[0-9a-f]+$/;
|
|
32
|
+
class UuidV7Error extends Error {
|
|
33
|
+
constructor(message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'UuidV7Error';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.UuidV7Error = UuidV7Error;
|
|
39
|
+
/** Encode a 16-byte UUID as a base58 string. */
|
|
40
|
+
function encodeUuidToBase58(bytes) {
|
|
41
|
+
if (bytes.length !== UUID_BYTES_LEN) {
|
|
42
|
+
throw new UuidV7Error(`id must be ${UUID_BYTES_LEN} bytes`);
|
|
43
|
+
}
|
|
44
|
+
let zeros = 0;
|
|
45
|
+
while (zeros < bytes.length && bytes[zeros] === 0) {
|
|
46
|
+
zeros++;
|
|
47
|
+
}
|
|
48
|
+
let n = 0n;
|
|
49
|
+
for (const b of bytes) {
|
|
50
|
+
n = (n << 8n) | BigInt(b);
|
|
51
|
+
}
|
|
52
|
+
let out = '';
|
|
53
|
+
while (n > 0n) {
|
|
54
|
+
const rem = Number(n % 58n);
|
|
55
|
+
out = BASE58_ALPHABET[rem] + out;
|
|
56
|
+
n /= 58n;
|
|
57
|
+
}
|
|
58
|
+
return '1'.repeat(zeros) + out;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Decode a base58 string back into 16 UUID bytes. Throws {@link UuidV7Error}
|
|
62
|
+
* if the input is malformed or doesn't decode to exactly 16 bytes.
|
|
63
|
+
*/
|
|
64
|
+
function decodeBase58ToUuidBytes(value) {
|
|
65
|
+
if (typeof value !== 'string') {
|
|
66
|
+
throw new UuidV7Error('id must be a base58 string');
|
|
67
|
+
}
|
|
68
|
+
if (value.length === 0) {
|
|
69
|
+
throw new UuidV7Error('id is empty');
|
|
70
|
+
}
|
|
71
|
+
if (value.length > UUID_BASE58_MAX_LEN) {
|
|
72
|
+
throw new UuidV7Error('id is too long');
|
|
73
|
+
}
|
|
74
|
+
let zeros = 0;
|
|
75
|
+
while (zeros < value.length && value[zeros] === '1') {
|
|
76
|
+
zeros++;
|
|
77
|
+
}
|
|
78
|
+
let n = 0n;
|
|
79
|
+
for (let i = 0; i < value.length; i++) {
|
|
80
|
+
const ch = value[i];
|
|
81
|
+
const v = BASE58_MAP[ch];
|
|
82
|
+
if (v === undefined) {
|
|
83
|
+
throw new UuidV7Error(`invalid base58 character: '${ch}'`);
|
|
84
|
+
}
|
|
85
|
+
n = n * 58n + BigInt(v);
|
|
86
|
+
}
|
|
87
|
+
const raw = [];
|
|
88
|
+
while (n > 0n) {
|
|
89
|
+
raw.unshift(Number(n & 0xffn));
|
|
90
|
+
n >>= 8n;
|
|
91
|
+
}
|
|
92
|
+
const padded = new Uint8Array(zeros + raw.length);
|
|
93
|
+
padded.set(raw, zeros);
|
|
94
|
+
if (padded.length !== UUID_BYTES_LEN) {
|
|
95
|
+
throw new UuidV7Error(`id must decode to ${UUID_BYTES_LEN} bytes`);
|
|
96
|
+
}
|
|
97
|
+
return padded;
|
|
98
|
+
}
|
|
99
|
+
function hexToBytes(hex) {
|
|
100
|
+
const out = new Uint8Array(hex.length / 2);
|
|
101
|
+
for (let i = 0; i < out.length; i++) {
|
|
102
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Mint a fresh ID. Callers should generate one of these locally when
|
|
108
|
+
* creating a new channel / thread / comment / conversation / message /
|
|
109
|
+
* group — the backend requires the client to supply the ID on create.
|
|
110
|
+
*/
|
|
111
|
+
function generateId() {
|
|
112
|
+
const hex = (0, uuid_1.v7)().replace(/-/g, '');
|
|
113
|
+
return encodeUuidToBase58(hexToBytes(hex));
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the `id` for a create-style API call: validate the caller-supplied
|
|
117
|
+
* value (throwing {@link UuidV7Error} before the request leaves the SDK) or
|
|
118
|
+
* mint a fresh one via {@link generateId}.
|
|
119
|
+
*/
|
|
120
|
+
function resolveCreateId(id) {
|
|
121
|
+
if (id === undefined)
|
|
122
|
+
return generateId();
|
|
123
|
+
if (!isValidUuidV7Base58(id)) {
|
|
124
|
+
throw new UuidV7Error(`invalid id ${JSON.stringify(id)} — use generateId() or omit \`id\` and let the SDK mint one.`);
|
|
125
|
+
}
|
|
126
|
+
return id;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Validate that a value matches the expected ID format (the decoded bytes
|
|
130
|
+
* have the v7 version nibble + RFC 4122/9562 variant bits). Does NOT
|
|
131
|
+
* validate the embedded timestamp — the backend may still reject a value
|
|
132
|
+
* that is too far in the future or past.
|
|
133
|
+
*/
|
|
134
|
+
function isValidUuidV7Base58(value) {
|
|
135
|
+
if (typeof value !== 'string')
|
|
136
|
+
return false;
|
|
137
|
+
let bytes;
|
|
138
|
+
try {
|
|
139
|
+
bytes = decodeBase58ToUuidBytes(value);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const versionNibble = bytes[6] & 0xf0;
|
|
145
|
+
const variantBits = bytes[8] & 0xc0;
|
|
146
|
+
return versionNibble === 0x70 && variantBits === 0x80;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Encode a canonical UUID string (hyphenated or not, any case) as a
|
|
150
|
+
* wire-format ID. Useful when interoperating with systems that hand you
|
|
151
|
+
* UUIDs in canonical form.
|
|
152
|
+
*/
|
|
153
|
+
function base58FromUuidString(uuid) {
|
|
154
|
+
const stripped = uuid.replace(/-/g, '').toLowerCase();
|
|
155
|
+
if (stripped.length !== 32 || !HEX_RE.test(stripped)) {
|
|
156
|
+
throw new UuidV7Error('not a valid UUID string');
|
|
157
|
+
}
|
|
158
|
+
return encodeUuidToBase58(hexToBytes(stripped));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Inverse of {@link base58FromUuidString}: takes a wire-format ID and
|
|
162
|
+
* returns the canonical hyphenated UUID string.
|
|
163
|
+
*/
|
|
164
|
+
function uuidStringFromBase58(value) {
|
|
165
|
+
const bytes = decodeBase58ToUuidBytes(value);
|
|
166
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
167
|
+
return [
|
|
168
|
+
hex.slice(0, 8),
|
|
169
|
+
hex.slice(8, 12),
|
|
170
|
+
hex.slice(12, 16),
|
|
171
|
+
hex.slice(16, 20),
|
|
172
|
+
hex.slice(20, 32),
|
|
173
|
+
].join('-');
|
|
174
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { v4 as uuid } from 'uuid';
|
|
2
|
+
import { isSuccess, request } from './transport/http-client.js';
|
|
3
|
+
import { CommsRequestError } from './types/errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* OAuth scopes for the Comms API.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Request only the scopes your application needs:
|
|
9
|
+
*
|
|
10
|
+
* **User Scopes:**
|
|
11
|
+
* - `user:read` - Access user's personal settings
|
|
12
|
+
* - `user:write` - Access and update user's personal settings
|
|
13
|
+
*
|
|
14
|
+
* **Workspace Scopes:**
|
|
15
|
+
* - `workspaces:read` - Access teams the user is part of
|
|
16
|
+
* - `workspaces:write` - Access and update teams the user is part of
|
|
17
|
+
*
|
|
18
|
+
* **Channel Scopes:**
|
|
19
|
+
* - `channels:read` - Access channels
|
|
20
|
+
* - `channels:write` - Access and update channels
|
|
21
|
+
* - `channels:remove` - Access, update, and delete channels
|
|
22
|
+
*
|
|
23
|
+
* **Thread Scopes:**
|
|
24
|
+
* - `threads:read` - Access threads
|
|
25
|
+
* - `threads:write` - Access and update threads
|
|
26
|
+
* - `threads:remove` - Access, update, and delete threads
|
|
27
|
+
*
|
|
28
|
+
* **Comment Scopes:**
|
|
29
|
+
* - `comments:read` - Access comments
|
|
30
|
+
* - `comments:write` - Access and update comments
|
|
31
|
+
* - `comments:remove` - Access, update, and delete comments
|
|
32
|
+
*
|
|
33
|
+
* **Group Scopes:**
|
|
34
|
+
* - `groups:read` - Access groups
|
|
35
|
+
* - `groups:write` - Access and update groups
|
|
36
|
+
* - `groups:remove` - Access, update, and delete groups
|
|
37
|
+
*
|
|
38
|
+
* **Message Scopes:**
|
|
39
|
+
* - `messages:read` - Access messages
|
|
40
|
+
* - `messages:write` - Access and update messages
|
|
41
|
+
* - `messages:remove` - Access, update, and delete messages
|
|
42
|
+
*
|
|
43
|
+
* **Reaction Scopes:**
|
|
44
|
+
* - `reactions:read` - Access reactions
|
|
45
|
+
* - `reactions:write` - Access and update reactions
|
|
46
|
+
* - `reactions:remove` - Access, update, and delete reactions
|
|
47
|
+
*
|
|
48
|
+
* **Search Scopes:**
|
|
49
|
+
* - `search:read` - Search
|
|
50
|
+
*
|
|
51
|
+
* **Attachment Scopes:**
|
|
52
|
+
* - `attachments:read` - Access attachments
|
|
53
|
+
* - `attachments:write` - Access and update attachments
|
|
54
|
+
*
|
|
55
|
+
* **Notification Scopes:**
|
|
56
|
+
* - `notifications:read` - Read user's notifications settings
|
|
57
|
+
* - `notifications:write` - Read and update user's notifications settings
|
|
58
|
+
*/
|
|
59
|
+
export const COMMS_SCOPES = [
|
|
60
|
+
'user:read',
|
|
61
|
+
'user:write',
|
|
62
|
+
'workspaces:read',
|
|
63
|
+
'workspaces:write',
|
|
64
|
+
'channels:read',
|
|
65
|
+
'channels:write',
|
|
66
|
+
'channels:remove',
|
|
67
|
+
'threads:read',
|
|
68
|
+
'threads:write',
|
|
69
|
+
'threads:remove',
|
|
70
|
+
'comments:read',
|
|
71
|
+
'comments:write',
|
|
72
|
+
'comments:remove',
|
|
73
|
+
'groups:read',
|
|
74
|
+
'groups:write',
|
|
75
|
+
'groups:remove',
|
|
76
|
+
'messages:read',
|
|
77
|
+
'messages:write',
|
|
78
|
+
'messages:remove',
|
|
79
|
+
'reactions:read',
|
|
80
|
+
'reactions:write',
|
|
81
|
+
'reactions:remove',
|
|
82
|
+
'search:read',
|
|
83
|
+
'attachments:read',
|
|
84
|
+
'attachments:write',
|
|
85
|
+
'notifications:read',
|
|
86
|
+
'notifications:write',
|
|
87
|
+
];
|
|
88
|
+
/** Supported token endpoint authentication methods for dynamic client registration. */
|
|
89
|
+
export const TOKEN_ENDPOINT_AUTH_METHODS = [
|
|
90
|
+
'client_secret_post',
|
|
91
|
+
'client_secret_basic',
|
|
92
|
+
'none',
|
|
93
|
+
];
|
|
94
|
+
export function getAuthStateParameter() {
|
|
95
|
+
return uuid();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Generates the authorization URL for the OAuth2 flow.
|
|
99
|
+
*
|
|
100
|
+
* The `clientId` can be either a traditional client ID string (e.g. from
|
|
101
|
+
* {@link registerClient}) or an HTTPS URL pointing to a client metadata document,
|
|
102
|
+
* as defined in {@link https://drafts.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ RFC draft-ietf-oauth-client-id-metadata-document}.
|
|
103
|
+
*/
|
|
104
|
+
export function getAuthorizationUrl(clientId, scopes, state, redirectUri, baseUrl) {
|
|
105
|
+
if (!scopes?.length) {
|
|
106
|
+
throw new Error('At least one scope value is required.');
|
|
107
|
+
}
|
|
108
|
+
const authBaseUrl = baseUrl ? `${baseUrl}/oauth` : 'https://comms.todoist.com/oauth';
|
|
109
|
+
const scope = scopes.join(' ');
|
|
110
|
+
const params = new URLSearchParams({
|
|
111
|
+
client_id: clientId,
|
|
112
|
+
response_type: 'code',
|
|
113
|
+
scope,
|
|
114
|
+
state,
|
|
115
|
+
});
|
|
116
|
+
if (redirectUri) {
|
|
117
|
+
params.append('redirect_uri', redirectUri);
|
|
118
|
+
}
|
|
119
|
+
return `${authBaseUrl}/authorize?${params.toString()}`;
|
|
120
|
+
}
|
|
121
|
+
export async function getAuthToken(args, options) {
|
|
122
|
+
const tokenUrl = options?.baseUrl
|
|
123
|
+
? `${options.baseUrl}/oauth/token`
|
|
124
|
+
: 'https://comms.todoist.com/oauth/token';
|
|
125
|
+
const payload = {
|
|
126
|
+
clientId: args.clientId,
|
|
127
|
+
clientSecret: args.clientSecret,
|
|
128
|
+
code: args.code,
|
|
129
|
+
grantType: 'authorization_code',
|
|
130
|
+
...(args.redirectUri && { redirectUri: args.redirectUri }),
|
|
131
|
+
};
|
|
132
|
+
const response = await request({
|
|
133
|
+
httpMethod: 'POST',
|
|
134
|
+
baseUri: tokenUrl,
|
|
135
|
+
relativePath: '',
|
|
136
|
+
payload,
|
|
137
|
+
customFetch: options?.customFetch,
|
|
138
|
+
});
|
|
139
|
+
if (!isSuccess(response) || !response.data?.accessToken) {
|
|
140
|
+
throw new CommsRequestError('Authentication token exchange failed.', response.status, response.data);
|
|
141
|
+
}
|
|
142
|
+
return response.data;
|
|
143
|
+
}
|
|
144
|
+
export async function revokeAuthToken(args, options) {
|
|
145
|
+
const revokeUrl = options?.baseUrl
|
|
146
|
+
? `${options.baseUrl}/oauth/revoke`
|
|
147
|
+
: 'https://comms.todoist.com/oauth/revoke';
|
|
148
|
+
const response = await request({
|
|
149
|
+
httpMethod: 'POST',
|
|
150
|
+
baseUri: revokeUrl,
|
|
151
|
+
relativePath: '',
|
|
152
|
+
payload: {
|
|
153
|
+
clientId: args.clientId,
|
|
154
|
+
clientSecret: args.clientSecret,
|
|
155
|
+
token: args.accessToken,
|
|
156
|
+
},
|
|
157
|
+
customFetch: options?.customFetch,
|
|
158
|
+
});
|
|
159
|
+
return isSuccess(response);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Registers a new OAuth client via Dynamic Client Registration (RFC 7591).
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```typescript
|
|
166
|
+
* const client = await registerClient({
|
|
167
|
+
* redirectUris: ['https://example.com/callback'],
|
|
168
|
+
* clientName: 'My App',
|
|
169
|
+
* scope: ['user:read', 'channels:read'],
|
|
170
|
+
* })
|
|
171
|
+
* // Use client.clientId and client.clientSecret for OAuth flows
|
|
172
|
+
* ```
|
|
173
|
+
*
|
|
174
|
+
* @returns The registered client details
|
|
175
|
+
* @throws {@link CommsRequestError} If the registration fails
|
|
176
|
+
* @see {@link https://datatracker.ietf.org/doc/html/rfc7591 RFC 7591}
|
|
177
|
+
*/
|
|
178
|
+
export async function registerClient(args, options) {
|
|
179
|
+
const registerUrl = options?.baseUrl
|
|
180
|
+
? `${options.baseUrl}/oauth/register`
|
|
181
|
+
: 'https://comms.todoist.com/oauth/register';
|
|
182
|
+
const response = await request({
|
|
183
|
+
httpMethod: 'POST',
|
|
184
|
+
baseUri: registerUrl,
|
|
185
|
+
relativePath: '',
|
|
186
|
+
payload: { ...args, scope: args.scope?.join(' ') },
|
|
187
|
+
customFetch: options?.customFetch,
|
|
188
|
+
});
|
|
189
|
+
if (!isSuccess(response) || !response.data?.clientId) {
|
|
190
|
+
throw new CommsRequestError('Dynamic client registration failed.', response.status, response.data);
|
|
191
|
+
}
|
|
192
|
+
const { clientIdIssuedAt, clientSecretExpiresAt, scope, ...rest } = response.data;
|
|
193
|
+
return {
|
|
194
|
+
...rest,
|
|
195
|
+
scope: scope ? scope.split(' ') : undefined,
|
|
196
|
+
clientIdIssuedAt: clientIdIssuedAt !== undefined ? new Date(clientIdIssuedAt * 1000) : undefined,
|
|
197
|
+
clientSecretExpiresAt: clientSecretExpiresAt === undefined
|
|
198
|
+
? undefined
|
|
199
|
+
: clientSecretExpiresAt === 0
|
|
200
|
+
? null
|
|
201
|
+
: new Date(clientSecretExpiresAt * 1000),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ENDPOINT_COMMENTS } from '../consts/endpoints.js';
|
|
2
|
+
import { request } from '../transport/http-client.js';
|
|
3
|
+
import { CommentSchema } from '../types/entities.js';
|
|
4
|
+
import { NOTIFY_AUDIENCE_GROUP_IDS, NOTIFY_AUDIENCES } from '../types/enums.js';
|
|
5
|
+
import { resolveCreateId } from '../utils/uuidv7.js';
|
|
6
|
+
const SENTINEL_GROUP_IDS = new Set(Object.values(NOTIFY_AUDIENCE_GROUP_IDS));
|
|
7
|
+
function isNotifyAudience(value) {
|
|
8
|
+
return typeof value === 'string' && NOTIFY_AUDIENCES.includes(value);
|
|
9
|
+
}
|
|
10
|
+
function collectMarkerOffenses(field, values) {
|
|
11
|
+
if (!values)
|
|
12
|
+
return null;
|
|
13
|
+
const offending = values.filter((id) => SENTINEL_GROUP_IDS.has(id));
|
|
14
|
+
return offending.length > 0 ? { field, offending } : null;
|
|
15
|
+
}
|
|
16
|
+
function applyNotifyAudience(params) {
|
|
17
|
+
const offenses = [
|
|
18
|
+
collectMarkerOffenses('groups', params.groups ?? undefined),
|
|
19
|
+
collectMarkerOffenses('directGroupMentions', params.directGroupMentions ?? undefined),
|
|
20
|
+
].filter((o) => o !== null);
|
|
21
|
+
if (offenses.length > 0) {
|
|
22
|
+
const details = offenses
|
|
23
|
+
.map(({ field, offending }) => `\`${field}\` contains ${offending.join(', ')}`)
|
|
24
|
+
.join('; ');
|
|
25
|
+
throw new Error(`Reserved broadcast marker IDs found: ${details}. Pass these via \`notifyAudience\` on createComment / closeThread / reopenThread (e.g. \`notifyAudience: 'channel'\` for EVERYONE) instead of populating \`groups\` / \`directGroupMentions\` directly.`);
|
|
26
|
+
}
|
|
27
|
+
if (params.notifyAudience == null)
|
|
28
|
+
return params;
|
|
29
|
+
if (!isNotifyAudience(params.notifyAudience)) {
|
|
30
|
+
throw new Error(`Invalid \`notifyAudience\` value "${String(params.notifyAudience)}". Expected one of: ${NOTIFY_AUDIENCES.join(', ')}.`);
|
|
31
|
+
}
|
|
32
|
+
const sentinel = NOTIFY_AUDIENCE_GROUP_IDS[params.notifyAudience];
|
|
33
|
+
const { notifyAudience: _stripped, groups, ...rest } = params;
|
|
34
|
+
return { ...rest, groups: [...(groups ?? []), sentinel] };
|
|
35
|
+
}
|
|
36
|
+
export function addCommentRequest(context, params, options) {
|
|
37
|
+
const normalized = applyNotifyAudience(params);
|
|
38
|
+
const withId = { ...normalized, id: resolveCreateId(normalized.id) };
|
|
39
|
+
const payload = options?.threadAction
|
|
40
|
+
? { ...withId, threadAction: options.threadAction }
|
|
41
|
+
: withId;
|
|
42
|
+
return request({
|
|
43
|
+
httpMethod: 'POST',
|
|
44
|
+
baseUri: context.baseUri,
|
|
45
|
+
relativePath: `${ENDPOINT_COMMENTS}/add`,
|
|
46
|
+
apiToken: context.apiToken,
|
|
47
|
+
payload,
|
|
48
|
+
customFetch: context.customFetch,
|
|
49
|
+
}).then((response) => CommentSchema.parse(response.data));
|
|
50
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getCommsBaseUri } from '../consts/endpoints.js';
|
|
2
|
+
/**
|
|
3
|
+
* Base class for every Comms API client. Centralizes URL handling and
|
|
4
|
+
* config so individual clients stay focused on their endpoints.
|
|
5
|
+
*/
|
|
6
|
+
export class BaseClient {
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.apiToken = config.apiToken;
|
|
9
|
+
this.baseUrl = config.baseUrl;
|
|
10
|
+
this.customFetch = config.customFetch;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Returns the base URI for an API request, with a guaranteed trailing
|
|
14
|
+
* slash so relative paths resolve cleanly through `URL`.
|
|
15
|
+
*/
|
|
16
|
+
getBaseUri() {
|
|
17
|
+
if (this.baseUrl) {
|
|
18
|
+
const normalizedBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
|
|
19
|
+
return `${normalizedBaseUrl}api/v1/`;
|
|
20
|
+
}
|
|
21
|
+
return getCommsBaseUri();
|
|
22
|
+
}
|
|
23
|
+
}
|