@boodibox/api-client 0.1.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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/index.js +312 -0
- package/lib/http.js +19 -0
- package/package.json +13 -0
- package/test/assets/tiny.jpg +0 -0
- package/test/e2e.bun.test.js +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SC0d3r
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# @boodibox/api-client
|
|
2
|
+
|
|
3
|
+
این بسته برای **ارسال خودکار پستها به `https://boodibox.com/posts`** طراحی شده — یعنی شما میتوانید بهسرعت پست منتشر کنید یا یک بات بسازید که محتوای شما را خودکار ارسال کند.
|
|
4
|
+
کتابخانه جریان کامل را پوشش میدهد: آپلود تصویر (اختیاری) → نظارت روی وضعیت تا زمانی که پردازش شود → ارسال پست با `medias` که سرور تولید میکند.
|
|
5
|
+
|
|
6
|
+
**آموزش گرفتن API Key:** https://boodibox.com/dev/api-key
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## نصب
|
|
10
|
+
```bash
|
|
11
|
+
npm install @boodibox/api-client
|
|
12
|
+
# یا
|
|
13
|
+
yarn add @boodibox/api-client
|
|
14
|
+
````
|
|
15
|
+
|
|
16
|
+
> این کتابخانه از امکانات بومی Node (fetch, FormData, Blob) استفاده میکند. از Node >= 18 استفاده کنید، یا برای محیطهایی که `FormData` / `Blob` ندارند یک polyfill نصب کنید (مثلاً `formdata-node`).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
این بسته برای خودکارسازی ارسال پستها به آدرس https://boodibox.com/posts ساخته شده است. شما میتوانید:
|
|
22
|
+
|
|
23
|
+
* بهراحتی پست منتشر کنید، یا
|
|
24
|
+
* یک ربات بسازید که محتوا را خودکار ارسال کند.
|
|
25
|
+
|
|
26
|
+
تذکرهای مهم:
|
|
27
|
+
|
|
28
|
+
* پارامتر `replyPermission` **فقط** دو مقدار معتبر دارد: `PRIVATE` یا `PUBLIC`. (در غیر این صورت کتابخانه خطا پرتاب میکند.)
|
|
29
|
+
* اگر میخواهید یک پست را به عنوان نقل قول ارسال کنید، مقدار `quotePostID` باید یک آیدی معتبر **CUID v2** باشد (پترن عملی: حرف اول، سپس حروف و اعداد کوچک، طول معمول 24–32 کاراکتر).
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## استفادهٔ سریع
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
const createClient = require('@boodibox/api-client');
|
|
37
|
+
|
|
38
|
+
const client = createClient({
|
|
39
|
+
apiKey: 'YOUR_API_KEY_HERE' // میتواند فقط توکن یا "Bearer <token>" باشد
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ارسال پست ساده بدون تصویر
|
|
43
|
+
await client.submitPost({ body: 'سلام از API' });
|
|
44
|
+
|
|
45
|
+
// ارسال پست با تصویر (از دیسک)
|
|
46
|
+
await client.submitPostWithFiles({
|
|
47
|
+
body: 'پست با عکس',
|
|
48
|
+
files: [{ path: './tiny.jpg' }],
|
|
49
|
+
replyPermission: 'PUBLIC' // یا 'PRIVATE'
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## APIها (توابع اصلی)
|
|
56
|
+
|
|
57
|
+
* `createClient({ baseUrl, apiKey })` – ساخت کلاینت
|
|
58
|
+
* `uploadFiles(files)` – آپلود فایلها (برمیگرداند آرایهای از apiupload ids)
|
|
59
|
+
* `pollUntilProcessed(uploadId, options)` – نظارت تا وضعیت `PROCESSED`
|
|
60
|
+
* `submitPost({ body, medias, replyPermission, quotePostID, userIP })`
|
|
61
|
+
* `submitPostWithFiles({ body, files, replyPermission, quotePostID, pollOptions, timeoutMs })`
|
|
62
|
+
|
|
63
|
+
* این تابع بهصورت convenience تمامی مراحل را انجام میدهد: آپلود فایلها → poll تا پردازش → ارسال پست با `medias` برگشتی
|
|
64
|
+
|
|
65
|
+
### شکل فایلها (files)
|
|
66
|
+
|
|
67
|
+
هر عنصر میتواند یکی از موارد باشد:
|
|
68
|
+
|
|
69
|
+
* `{ path: "./file.jpg" }` — خواندن از دیسک
|
|
70
|
+
* `{ buffer: Buffer, filename: "a.jpg" }`
|
|
71
|
+
* `{ file: File }` (در مرورگر)
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## اعتبارسنجی و محدودیتها
|
|
76
|
+
|
|
77
|
+
* `replyPermission` تنها مقدارهای معتبر `PRIVATE` یا `PUBLIC` را میپذیرد.
|
|
78
|
+
* `quotePostID` باید شبیه **CUID v2** باشد (حرف اول، سپس حروف/اعداد کوچک؛ طول معمول 24–32). اگر نامعتبر باشد، خطا پرتاب میشود.
|
|
79
|
+
* کتابخانه تلاشهایی برای retry آپلود انجام میدهد و اگر سرور خطا بازگرداند، خطا با بدنهٔ پاسخ شامل جزئیات برمیگردد (`err.body`).
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## تستها (Bun)
|
|
84
|
+
|
|
85
|
+
برای اجرای تستهای یکپارچه (integration) به صورت اختیاری از متغیرهای محیطی زیر استفاده کنید:
|
|
86
|
+
|
|
87
|
+
* `API_KEY` — مقدار API key (فرمت: `Bearer <token>` یا فقط توکن)
|
|
88
|
+
* `TEST_INTEGRATION=true` — اگر این مقدار تنظیم نشده باشد، تست یکپارچه نادیده گرفته میشود
|
|
89
|
+
* `BASE_URL` — آدرس سرور (مثلاً `http://localhost:3000` یا `https://boodibox.com`)
|
|
90
|
+
|
|
91
|
+
نمونه:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
export API_KEY="Bearer xxxxx"
|
|
95
|
+
export TEST_INTEGRATION=true
|
|
96
|
+
export BASE_URL="http://localhost:3000"
|
|
97
|
+
bun test
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## لینک مفید
|
|
101
|
+
* راهنمای گرفتن API Key: [https://boodibox.com/dev/api-key](https://boodibox.com/dev/api-key)
|
|
102
|
+
|
|
103
|
+
* پست ها بودیباکس: [https://boodibox.com/posts](https://boodibox.com/posts)
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT License
|
|
108
|
+
|
|
109
|
+
Copyright (c) 2026 BoodiBox
|
|
110
|
+
|
|
111
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
112
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
113
|
+
in the Software without restriction, including without limitation the rights
|
|
114
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
115
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
116
|
+
furnished to do so, subject to the following conditions:
|
|
117
|
+
|
|
118
|
+
The above copyright notice and this permission notice shall be included in all
|
|
119
|
+
copies or substantial portions of the Software.
|
|
120
|
+
|
|
121
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
122
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
123
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
124
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
125
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
126
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
127
|
+
SOFTWARE.
|
package/index.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.js
|
|
3
|
+
* BoodiBox API client (minimal, no deps)
|
|
4
|
+
*
|
|
5
|
+
* Changes:
|
|
6
|
+
* - replyPermission defaults to "PUBLIC" when omitted
|
|
7
|
+
* - explicitly empty or falsey replyPermission values are rejected
|
|
8
|
+
* - other behavior unchanged (upload -> poll -> submit)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { fetchWithTimeout } = require('./lib/http');
|
|
14
|
+
|
|
15
|
+
const DEFAULTS = {
|
|
16
|
+
baseUrl: process.env.BASE_URL || 'https://boodibox.com',
|
|
17
|
+
uploadPath: '/api/v1/uploads',
|
|
18
|
+
postsPath: '/api/v1/posts',
|
|
19
|
+
pollIntervalMs: 1000,
|
|
20
|
+
pollTimeoutMs: 30000,
|
|
21
|
+
maxRetries: 3
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Practical CUID v2-ish regex: starts with a letter, lowercase alnum, length 24..32
|
|
25
|
+
const CUID2_REGEX = /^[a-z][a-z0-9]{23,31}$/i;
|
|
26
|
+
const REPLY_PERMISSIONS = new Set(['PRIVATE', 'PUBLIC']);
|
|
27
|
+
|
|
28
|
+
function ensureFormDataSupport() {
|
|
29
|
+
if (typeof FormData === 'undefined' || typeof Blob === 'undefined') {
|
|
30
|
+
throw new Error('Global FormData/Blob not available. Run in Node >=18 or provide a polyfill (e.g. formdata-node).');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeApiKey(raw) {
|
|
35
|
+
if (!raw) return null;
|
|
36
|
+
if (raw.startsWith('Bearer ')) return raw;
|
|
37
|
+
return `Bearer ${raw}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate replyPermission:
|
|
42
|
+
* - If rp is undefined -> caller should handle defaulting (we default to PUBLIC upstream)
|
|
43
|
+
* - If rp is explicitly provided but falsey ('' / null / 0 / false) -> reject
|
|
44
|
+
* - Otherwise normalize and ensure it is PUBLIC or PRIVATE
|
|
45
|
+
*/
|
|
46
|
+
function validateReplyPermission(rp) {
|
|
47
|
+
if (rp === undefined) return; // caller handles default
|
|
48
|
+
if (rp === null || (typeof rp === 'string' && rp.trim() === '') || !rp) {
|
|
49
|
+
throw new Error('replyPermission cannot be empty. Use "PUBLIC" or "PRIVATE".');
|
|
50
|
+
}
|
|
51
|
+
const up = String(rp).toUpperCase();
|
|
52
|
+
if (!REPLY_PERMISSIONS.has(up)) {
|
|
53
|
+
throw new Error(`replyPermission must be one of: ${Array.from(REPLY_PERMISSIONS).join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
return up;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateQuotePostID(id) {
|
|
59
|
+
if (id == null) return;
|
|
60
|
+
const s = String(id).trim();
|
|
61
|
+
if (!CUID2_REGEX.test(s)) {
|
|
62
|
+
throw new Error('quotePostID must be a valid CUID v2-style id (letter + lowercase alphanumeric, length ~24-32).');
|
|
63
|
+
}
|
|
64
|
+
return s.toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createClient(opts = {}) {
|
|
68
|
+
const config = { ...DEFAULTS, ...opts };
|
|
69
|
+
if (!config.apiKey) throw new Error('apiKey is required to create BoodiBox client');
|
|
70
|
+
const authHeader = normalizeApiKey(config.apiKey);
|
|
71
|
+
|
|
72
|
+
ensureFormDataSupport();
|
|
73
|
+
|
|
74
|
+
async function _doFetch(url, options = {}, timeoutMs = 15000) {
|
|
75
|
+
const headers = Object.assign({}, options.headers || {}, {
|
|
76
|
+
Authorization: authHeader,
|
|
77
|
+
Accept: 'application/json'
|
|
78
|
+
});
|
|
79
|
+
return fetchWithTimeout(url, { ...options, headers }, timeoutMs);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* uploadFiles
|
|
84
|
+
* Accepts files array items:
|
|
85
|
+
* - { path }
|
|
86
|
+
* - { buffer, filename?, contentType? }
|
|
87
|
+
* - { file } (File)
|
|
88
|
+
*
|
|
89
|
+
* Uses File when available (Bun/browser) to ensure filename is sent correctly.
|
|
90
|
+
*/
|
|
91
|
+
async function uploadFiles(files = []) {
|
|
92
|
+
if (!Array.isArray(files) || files.length === 0) throw new Error('files must be a non-empty array');
|
|
93
|
+
const url = new URL(config.uploadPath, config.baseUrl).toString();
|
|
94
|
+
|
|
95
|
+
const form = new FormData();
|
|
96
|
+
for (const f of files) {
|
|
97
|
+
if (f.path) {
|
|
98
|
+
const filename = f.filename || path.basename(f.path);
|
|
99
|
+
const buffer = fs.readFileSync(f.path);
|
|
100
|
+
const contentType = f.contentType || guessContentTypeFromFilename(filename) || 'application/octet-stream';
|
|
101
|
+
|
|
102
|
+
if (typeof File !== 'undefined') {
|
|
103
|
+
const fileObj = new File([buffer], filename, { type: contentType });
|
|
104
|
+
form.append('files', fileObj);
|
|
105
|
+
} else {
|
|
106
|
+
const blob = new Blob([buffer], { type: contentType });
|
|
107
|
+
form.append('files', blob, filename);
|
|
108
|
+
}
|
|
109
|
+
} else if (f.buffer) {
|
|
110
|
+
const filename = f.filename || 'file';
|
|
111
|
+
const contentType = f.contentType || guessContentTypeFromFilename(filename) || 'application/octet-stream';
|
|
112
|
+
if (typeof File !== 'undefined') {
|
|
113
|
+
const fileObj = new File([f.buffer], filename, { type: contentType });
|
|
114
|
+
form.append('files', fileObj);
|
|
115
|
+
} else {
|
|
116
|
+
const blob = new Blob([f.buffer], { type: contentType });
|
|
117
|
+
form.append('files', blob, filename);
|
|
118
|
+
}
|
|
119
|
+
} else if (f.file) {
|
|
120
|
+
form.append('files', f.file, f.file.name || 'file');
|
|
121
|
+
} else {
|
|
122
|
+
throw new Error('each file must contain either path, buffer, or file');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const resp = await _doFetch(url, { method: 'POST', body: form }, 60000);
|
|
127
|
+
if (!resp.ok) {
|
|
128
|
+
const body = await safeParseJSON(resp);
|
|
129
|
+
const reason = body?.reason || `${resp.status} ${resp.statusText}`;
|
|
130
|
+
const e = new Error(`Upload failed: ${reason}`);
|
|
131
|
+
e.status = resp.status;
|
|
132
|
+
e.body = body;
|
|
133
|
+
throw e;
|
|
134
|
+
}
|
|
135
|
+
const json = await resp.json();
|
|
136
|
+
if (!json.success) {
|
|
137
|
+
const e = new Error('upload endpoint returned success=false');
|
|
138
|
+
e.body = json;
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
return json.uploads || [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function getUploadStatus(uploadId) {
|
|
145
|
+
const url = new URL(`${config.uploadPath}/${encodeURIComponent(uploadId)}`, config.baseUrl).toString();
|
|
146
|
+
const resp = await _doFetch(url, { method: 'GET' }, 15000);
|
|
147
|
+
if (resp.status === 404) return { missing: true };
|
|
148
|
+
if (!resp.ok) {
|
|
149
|
+
const body = await safeParseJSON(resp);
|
|
150
|
+
const e = new Error('Failed to fetch upload status');
|
|
151
|
+
e.status = resp.status;
|
|
152
|
+
e.body = body;
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
return resp.json();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function pollUntilProcessed(uploadId, options = {}) {
|
|
159
|
+
const interval = options.intervalMs ?? config.pollIntervalMs;
|
|
160
|
+
const timeoutMs = options.timeoutMs ?? config.pollTimeoutMs;
|
|
161
|
+
const start = Date.now();
|
|
162
|
+
|
|
163
|
+
while (true) {
|
|
164
|
+
const statusObj = await getUploadStatus(uploadId);
|
|
165
|
+
if (statusObj.status === 'PROCESSED') return statusObj;
|
|
166
|
+
if (statusObj.status === 'DELETED' || statusObj.status === 'ATTACHED') return statusObj;
|
|
167
|
+
if (Date.now() - start > timeoutMs) {
|
|
168
|
+
const e = new Error('polling timeout waiting for processed');
|
|
169
|
+
e.uploadStatus = statusObj;
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
await wait(interval);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function pollManyUntilProcessed(uploadIds = [], options = {}) {
|
|
177
|
+
if (!Array.isArray(uploadIds) || uploadIds.length === 0) return {};
|
|
178
|
+
const tasks = uploadIds.map(id => pollUntilProcessed(id, options).then(s => ({ id, s })));
|
|
179
|
+
const results = await Promise.all(tasks);
|
|
180
|
+
const map = {};
|
|
181
|
+
for (const r of results) map[r.id] = r.s;
|
|
182
|
+
return map;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* submitPost
|
|
187
|
+
* replyPermission: if undefined -> defaults to PUBLIC
|
|
188
|
+
* if explicitly provided but empty/falsey -> throws
|
|
189
|
+
*/
|
|
190
|
+
async function submitPost({ body = '', medias = [], replyPermission = undefined, quotePostID = null, userIP = null }) {
|
|
191
|
+
// Handle replyPermission defaulting and validation
|
|
192
|
+
let rp;
|
|
193
|
+
if (replyPermission === undefined) {
|
|
194
|
+
rp = 'PUBLIC';
|
|
195
|
+
} else {
|
|
196
|
+
// explicit value provided -> validate it (reject empty)
|
|
197
|
+
rp = validateReplyPermission(replyPermission);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const qid = quotePostID == null ? null : validateQuotePostID(quotePostID);
|
|
201
|
+
|
|
202
|
+
const url = new URL(config.postsPath, config.baseUrl).toString();
|
|
203
|
+
const payload = { body, medias, replyPermission: rp, quotePostID: qid };
|
|
204
|
+
if (userIP) payload.userIP = userIP;
|
|
205
|
+
const resp = await _doFetch(url, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { 'Content-Type': 'application/json' },
|
|
208
|
+
body: JSON.stringify(payload)
|
|
209
|
+
}, 20000);
|
|
210
|
+
|
|
211
|
+
if (!resp.ok) {
|
|
212
|
+
const parsed = await safeParseJSON(resp);
|
|
213
|
+
const e = new Error('submit post failed');
|
|
214
|
+
e.status = resp.status;
|
|
215
|
+
e.body = parsed;
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
218
|
+
return resp.json();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* submitPostWithFiles
|
|
223
|
+
* - If replyPermission undefined -> defaults to PUBLIC
|
|
224
|
+
* - If replyPermission explicitly provided but empty -> throw
|
|
225
|
+
*/
|
|
226
|
+
async function submitPostWithFiles({ body = '', files = [], replyPermission = undefined, quotePostID = null, pollOptions = {}, timeoutMs = 120000 }) {
|
|
227
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
228
|
+
// still require replyPermission defaulting behavior
|
|
229
|
+
return submitPost({ body, medias: [], replyPermission, quotePostID });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// validate upfront: default or validate
|
|
233
|
+
if (replyPermission === undefined) {
|
|
234
|
+
// allow defaulting later in submitPost, but keep same normalization here
|
|
235
|
+
} else {
|
|
236
|
+
validateReplyPermission(replyPermission);
|
|
237
|
+
}
|
|
238
|
+
if (quotePostID != null) validateQuotePostID(quotePostID);
|
|
239
|
+
|
|
240
|
+
const uploads = await retryAsync(() => uploadFiles(files), config.maxRetries, 300).catch(err => {
|
|
241
|
+
throw new Error(`Uploading files failed: ${err.message}`);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const start = Date.now();
|
|
245
|
+
const finalSrcs = [];
|
|
246
|
+
|
|
247
|
+
for (const uploadId of uploads) {
|
|
248
|
+
const remainingTime = timeoutMs - (Date.now() - start);
|
|
249
|
+
if (remainingTime <= 0) throw new Error('Timeout while waiting for files to be processed');
|
|
250
|
+
|
|
251
|
+
const statusObj = await pollUntilProcessed(uploadId, {
|
|
252
|
+
intervalMs: pollOptions.intervalMs,
|
|
253
|
+
timeoutMs: Math.min(remainingTime, pollOptions.timeoutMs ?? config.pollTimeoutMs)
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (statusObj.status !== 'PROCESSED') {
|
|
257
|
+
const e = new Error(`Upload ${uploadId} terminal state: ${statusObj.status}`);
|
|
258
|
+
e.statusObj = statusObj;
|
|
259
|
+
throw e;
|
|
260
|
+
}
|
|
261
|
+
if (!statusObj.src) {
|
|
262
|
+
const e = new Error(`Upload ${uploadId} processed but no src returned`);
|
|
263
|
+
e.statusObj = statusObj;
|
|
264
|
+
throw e;
|
|
265
|
+
}
|
|
266
|
+
finalSrcs.push(statusObj.src);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const submitResult = await submitPost({ body, medias: finalSrcs, replyPermission, quotePostID });
|
|
270
|
+
return submitResult;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
uploadFiles,
|
|
275
|
+
getUploadStatus,
|
|
276
|
+
pollUntilProcessed,
|
|
277
|
+
pollManyUntilProcessed,
|
|
278
|
+
submitPost,
|
|
279
|
+
submitPostWithFiles,
|
|
280
|
+
_raw: { config }
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// helpers
|
|
285
|
+
function wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
|
|
286
|
+
|
|
287
|
+
async function safeParseJSON(resp) {
|
|
288
|
+
try { return await resp.json(); } catch (e) { return null; }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function retryAsync(fn, retries = 3, backoffMs = 200) {
|
|
292
|
+
let i = 0;
|
|
293
|
+
while (true) {
|
|
294
|
+
try { return await fn(); } catch (e) {
|
|
295
|
+
i++;
|
|
296
|
+
if (i > retries) throw e;
|
|
297
|
+
await wait(backoffMs * i);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function guessContentTypeFromFilename(name) {
|
|
303
|
+
if (!name) return null;
|
|
304
|
+
const ext = name.split('.').pop().toLowerCase();
|
|
305
|
+
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
|
|
306
|
+
if (ext === 'png') return 'image/png';
|
|
307
|
+
if (ext === 'webp') return 'image/webp';
|
|
308
|
+
if (ext === 'gif') return 'image/gif';
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = createClient;
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// lib/http.js
|
|
2
|
+
async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
const id = setTimeout(() => controller.abort(), timeoutMs);
|
|
5
|
+
try {
|
|
6
|
+
const resp = await fetch(url, { ...options, signal: controller.signal });
|
|
7
|
+
clearTimeout(id);
|
|
8
|
+
return resp;
|
|
9
|
+
} catch (err) {
|
|
10
|
+
clearTimeout(id);
|
|
11
|
+
if (err.name === 'AbortError') {
|
|
12
|
+
const e = new Error(`Request timed out after ${timeoutMs}ms`);
|
|
13
|
+
e.code = 'ETIMEDOUT';
|
|
14
|
+
throw e;
|
|
15
|
+
}
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
module.exports = { fetchWithTimeout };
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@boodibox/api-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal client for BoodiBox API - submit posts - automate - upload media.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["boodibox","api","uploads","images","posts","automate","bot"],
|
|
8
|
+
"author": "SC0d3r",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "bun test"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
Binary file
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// test/e2e.bun.test.js
|
|
2
|
+
import { describe, test, expect } from 'bun:test';
|
|
3
|
+
import createClient from '../index.js';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
const API_KEY = process.env.API_KEY;
|
|
8
|
+
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
|
9
|
+
|
|
10
|
+
describe('BoodiBox client integration (conditional)', () => {
|
|
11
|
+
if (!API_KEY || process.env.TEST_INTEGRATION !== 'true') {
|
|
12
|
+
test('skipped', () => {
|
|
13
|
+
console.log('Integration tests skipped. Set API_KEY and TEST_INTEGRATION=true to run.');
|
|
14
|
+
expect(true).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const client = createClient({ baseUrl: BASE, apiKey: API_KEY });
|
|
20
|
+
|
|
21
|
+
test('upload -> poll -> submit post (integration)', async () => {
|
|
22
|
+
const tiny = path.join(process.cwd(), 'test', 'assets', 'tiny.jpg');
|
|
23
|
+
if (!fs.existsSync(tiny)) throw new Error('test/assets/tiny.jpg missing');
|
|
24
|
+
|
|
25
|
+
// Submit with default PUBLIC replyPermission
|
|
26
|
+
const result = await client.submitPostWithFiles({
|
|
27
|
+
body: 'bun integration test ' + Date.now(),
|
|
28
|
+
files: [{ path: tiny }],
|
|
29
|
+
pollOptions: { intervalMs: 1000, timeoutMs: 60000 },
|
|
30
|
+
timeoutMs: 90000
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result).toBeDefined();
|
|
34
|
+
if (result.success !== undefined) expect(result.success).toBeTruthy();
|
|
35
|
+
}, { timeout: 120000 });
|
|
36
|
+
});
|