planvortex 0.0.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 +283 -0
- package/dist/chunk-B4DEHU6Q.js +140 -0
- package/dist/chunk-B4DEHU6Q.js.map +1 -0
- package/dist/index-CUrq0B7g.d.cts +11074 -0
- package/dist/index-CUrq0B7g.d.ts +11074 -0
- package/dist/index.cjs +1594 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +851 -0
- package/dist/index.d.ts +851 -0
- package/dist/index.js +1414 -0
- package/dist/index.js.map +1 -0
- package/dist/webhooks/index.cjs +255 -0
- package/dist/webhooks/index.cjs.map +1 -0
- package/dist/webhooks/index.d.cts +1 -0
- package/dist/webhooks/index.d.ts +1 -0
- package/dist/webhooks/index.js +188 -0
- package/dist/webhooks/index.js.map +1 -0
- package/package.json +99 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1594 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var path = require('path');
|
|
4
|
+
|
|
5
|
+
// src/version.ts
|
|
6
|
+
var PLANVORTEX_API_URL = "https://api.planvortex.com/v1.0.0";
|
|
7
|
+
var VERSION = "0.0.1";
|
|
8
|
+
|
|
9
|
+
// src/core/errors.ts
|
|
10
|
+
var PLANVORTEX_ERROR_RANGES = [
|
|
11
|
+
{ from: 500, to: 541, family: "auth" },
|
|
12
|
+
{ from: 601, to: 612, family: "user" },
|
|
13
|
+
{ from: 700, to: 715, family: "account" },
|
|
14
|
+
{ from: 800, to: 810, family: "file" },
|
|
15
|
+
{ from: 900, to: 960, family: "publication" },
|
|
16
|
+
{ from: 1e3, to: 1003, family: "general" },
|
|
17
|
+
{ from: 1100, to: 1111, family: "organization" },
|
|
18
|
+
{ from: 1200, to: 1207, family: "role" },
|
|
19
|
+
{ from: 1300, to: 1307, family: "plan_limit" },
|
|
20
|
+
{ from: 1400, to: 1408, family: "plan_limit" },
|
|
21
|
+
{ from: 1500, to: 1512, family: "messaging" },
|
|
22
|
+
{ from: 1600, to: 1601, family: "contact" },
|
|
23
|
+
{ from: 1900, to: 1906, family: "payment" },
|
|
24
|
+
{ from: 2e3, to: 2099, family: "product" },
|
|
25
|
+
{ from: 2100, to: 2199, family: "ai_plan" },
|
|
26
|
+
{ from: 2200, to: 2299, family: "integration" }
|
|
27
|
+
];
|
|
28
|
+
var NO_ERROR_CODE = 0;
|
|
29
|
+
var TOKEN_ERROR_CODES = [501, 522];
|
|
30
|
+
var PlanVortexError = class extends Error {
|
|
31
|
+
/** El `code` del cuerpo, tal cual. {@link NO_ERROR_CODE} si el error no viene del catálogo. */
|
|
32
|
+
code;
|
|
33
|
+
/** La familia del rango: `auth`, `publication`, `plan_limit`... Ver {@link PLANVORTEX_ERROR_RANGES}. */
|
|
34
|
+
family;
|
|
35
|
+
/** El `data` del cuerpo. */
|
|
36
|
+
data;
|
|
37
|
+
/** Status HTTP, o `undefined` si nunca hubo respuesta. */
|
|
38
|
+
status;
|
|
39
|
+
/** `x-request-id`, si el despliegue lo pone. Hoy el servidor no lo emite; un proxy delante sí. */
|
|
40
|
+
requestId;
|
|
41
|
+
/** Segundos que pidió esperar la cabecera `Retry-After`, si llegó. */
|
|
42
|
+
retryAfter;
|
|
43
|
+
constructor(code, message, options = {}) {
|
|
44
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
45
|
+
this.name = new.target.name;
|
|
46
|
+
this.code = code;
|
|
47
|
+
this.family = options.family ?? errorFamilyForCode(code) ?? "unknown";
|
|
48
|
+
this.data = options.data ?? {};
|
|
49
|
+
this.status = options.status;
|
|
50
|
+
this.requestId = options.requestId;
|
|
51
|
+
this.retryAfter = options.retryAfter;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
var AuthError = class extends PlanVortexError {
|
|
55
|
+
};
|
|
56
|
+
var UserError = class extends PlanVortexError {
|
|
57
|
+
};
|
|
58
|
+
var AccountError = class extends PlanVortexError {
|
|
59
|
+
};
|
|
60
|
+
var FileError = class extends PlanVortexError {
|
|
61
|
+
};
|
|
62
|
+
var PublicationError = class extends PlanVortexError {
|
|
63
|
+
};
|
|
64
|
+
var OrganizationError = class extends PlanVortexError {
|
|
65
|
+
};
|
|
66
|
+
var PlanLimitError = class extends PlanVortexError {
|
|
67
|
+
};
|
|
68
|
+
var MessagingError = class extends PlanVortexError {
|
|
69
|
+
};
|
|
70
|
+
var ContactError = class extends PlanVortexError {
|
|
71
|
+
};
|
|
72
|
+
var ProductError = class extends PlanVortexError {
|
|
73
|
+
};
|
|
74
|
+
var AiPlanError = class extends PlanVortexError {
|
|
75
|
+
};
|
|
76
|
+
var IntegrationError = class extends PlanVortexError {
|
|
77
|
+
};
|
|
78
|
+
var PlanVortexConnectionError = class extends PlanVortexError {
|
|
79
|
+
/** `true` si lo que se agotó fue nuestro propio timeout, no la red. */
|
|
80
|
+
timeout;
|
|
81
|
+
constructor(message, options = {}) {
|
|
82
|
+
super(NO_ERROR_CODE, message, { ...options, family: "connection" });
|
|
83
|
+
this.timeout = options.timeout ?? false;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var PlanVortexAuthenticationError = class extends PlanVortexError {
|
|
87
|
+
/** `invalid_client`, `invalid_request`, `unsupported_grant_type`, `slow_down` o `server_error`. */
|
|
88
|
+
oauthError;
|
|
89
|
+
constructor(oauthError, description, options = {}) {
|
|
90
|
+
super(NO_ERROR_CODE, description, { ...options, family: "oauth" });
|
|
91
|
+
this.oauthError = oauthError;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
var PlanVortexConfigError = class extends PlanVortexError {
|
|
95
|
+
constructor(message) {
|
|
96
|
+
super(NO_ERROR_CODE, message, { family: "config" });
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
function errorFamilyForCode(code) {
|
|
100
|
+
return PLANVORTEX_ERROR_RANGES.find((range) => code >= range.from && code <= range.to)?.family;
|
|
101
|
+
}
|
|
102
|
+
var FAMILY_CLASSES = {
|
|
103
|
+
auth: AuthError,
|
|
104
|
+
user: UserError,
|
|
105
|
+
account: AccountError,
|
|
106
|
+
file: FileError,
|
|
107
|
+
publication: PublicationError,
|
|
108
|
+
organization: OrganizationError,
|
|
109
|
+
plan_limit: PlanLimitError,
|
|
110
|
+
messaging: MessagingError,
|
|
111
|
+
contact: ContactError,
|
|
112
|
+
product: ProductError,
|
|
113
|
+
ai_plan: AiPlanError,
|
|
114
|
+
integration: IntegrationError
|
|
115
|
+
};
|
|
116
|
+
function isApiErrorBody(body) {
|
|
117
|
+
return typeof body === "object" && body !== null && typeof body.code === "number";
|
|
118
|
+
}
|
|
119
|
+
function createErrorFromResponse(input) {
|
|
120
|
+
const options = {
|
|
121
|
+
status: input.status,
|
|
122
|
+
...input.requestId === void 0 ? {} : { requestId: input.requestId },
|
|
123
|
+
...input.retryAfter === void 0 ? {} : { retryAfter: input.retryAfter }
|
|
124
|
+
};
|
|
125
|
+
if (isApiErrorBody(input.body)) {
|
|
126
|
+
const family = errorFamilyForCode(input.body.code);
|
|
127
|
+
const ErrorClass = (family === void 0 ? void 0 : FAMILY_CLASSES[family]) ?? PlanVortexError;
|
|
128
|
+
return new ErrorClass(input.body.code, input.body.message ?? "PlanVortex error", {
|
|
129
|
+
...options,
|
|
130
|
+
data: input.body.data ?? {}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return new PlanVortexError(NO_ERROR_CODE, `HTTP ${input.status}`, {
|
|
134
|
+
...options,
|
|
135
|
+
family: "http",
|
|
136
|
+
data: typeof input.body === "object" && input.body !== null ? input.body : { body: input.body }
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function isPlanVortexError(error) {
|
|
140
|
+
return error instanceof PlanVortexError;
|
|
141
|
+
}
|
|
142
|
+
function isTokenError(error) {
|
|
143
|
+
return isPlanVortexError(error) && TOKEN_ERROR_CODES.includes(error.code);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/core/auth.ts
|
|
147
|
+
var TOKEN_REFRESH_MARGIN_MS = 6e4;
|
|
148
|
+
var TOKEN_PATH = "/oauth/token";
|
|
149
|
+
var StaticTokenAuth = class {
|
|
150
|
+
constructor(token) {
|
|
151
|
+
this.token = token;
|
|
152
|
+
}
|
|
153
|
+
token;
|
|
154
|
+
getToken() {
|
|
155
|
+
return Promise.resolve(this.token);
|
|
156
|
+
}
|
|
157
|
+
invalidate() {
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
var ClientCredentialsAuth = class {
|
|
161
|
+
constructor(http, options) {
|
|
162
|
+
this.http = http;
|
|
163
|
+
this.options = options;
|
|
164
|
+
this.now = options.now ?? Date.now;
|
|
165
|
+
}
|
|
166
|
+
http;
|
|
167
|
+
options;
|
|
168
|
+
now;
|
|
169
|
+
cached;
|
|
170
|
+
/** La petición de token en vuelo. Es el cerrojo: mientras exista, todos esperan a ésta. */
|
|
171
|
+
pending;
|
|
172
|
+
async getToken() {
|
|
173
|
+
const cached = this.cached;
|
|
174
|
+
if (cached && cached.expiresAt - this.now() > TOKEN_REFRESH_MARGIN_MS) {
|
|
175
|
+
return cached.token;
|
|
176
|
+
}
|
|
177
|
+
this.pending ??= this.fetchToken().finally(() => {
|
|
178
|
+
this.pending = void 0;
|
|
179
|
+
});
|
|
180
|
+
return this.pending;
|
|
181
|
+
}
|
|
182
|
+
invalidate() {
|
|
183
|
+
this.cached = void 0;
|
|
184
|
+
}
|
|
185
|
+
async fetchToken() {
|
|
186
|
+
const body = new URLSearchParams({
|
|
187
|
+
grant_type: "client_credentials",
|
|
188
|
+
client_id: this.options.clientId,
|
|
189
|
+
client_secret: this.options.clientSecret
|
|
190
|
+
});
|
|
191
|
+
if (this.options.scope) {
|
|
192
|
+
body.set("scope", this.options.scope);
|
|
193
|
+
}
|
|
194
|
+
let token;
|
|
195
|
+
try {
|
|
196
|
+
const response = await this.http.request({
|
|
197
|
+
method: "POST",
|
|
198
|
+
path: TOKEN_PATH,
|
|
199
|
+
body,
|
|
200
|
+
//Es el único POST reintentable de la librería: pedir un token no crea nada.
|
|
201
|
+
idempotent: true
|
|
202
|
+
});
|
|
203
|
+
token = response.data;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw toAuthenticationError(error);
|
|
206
|
+
}
|
|
207
|
+
if (!token?.access_token) {
|
|
208
|
+
throw new PlanVortexAuthenticationError(
|
|
209
|
+
"server_error",
|
|
210
|
+
"El endpoint de token respondi\xF3 sin access_token"
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const lifetimeMs = (Number(token.expires_in) > 0 ? Number(token.expires_in) : 3600) * 1e3;
|
|
214
|
+
this.cached = { token: token.access_token, expiresAt: this.now() + lifetimeMs };
|
|
215
|
+
return token.access_token;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
function toAuthenticationError(error) {
|
|
219
|
+
if (!isPlanVortexError(error) || error.status === void 0) {
|
|
220
|
+
return error;
|
|
221
|
+
}
|
|
222
|
+
const body = error.data;
|
|
223
|
+
const oauthError = typeof body.error === "string" ? body.error : "invalid_client";
|
|
224
|
+
const description = typeof body.error_description === "string" ? body.error_description : error.message;
|
|
225
|
+
return new PlanVortexAuthenticationError(oauthError, description, {
|
|
226
|
+
status: error.status,
|
|
227
|
+
...error.requestId === void 0 ? {} : { requestId: error.requestId },
|
|
228
|
+
...error.retryAfter === void 0 ? {} : { retryAfter: error.retryAfter },
|
|
229
|
+
cause: error
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/core/http.ts
|
|
234
|
+
var DEFAULT_RETRY = {
|
|
235
|
+
maxRetries: 2,
|
|
236
|
+
baseDelayMs: 500,
|
|
237
|
+
maxDelayMs: 8e3
|
|
238
|
+
};
|
|
239
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
240
|
+
var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 429, 502, 503, 504]);
|
|
241
|
+
var PRE_FLIGHT_NETWORK_CODES = /* @__PURE__ */ new Set([
|
|
242
|
+
"ECONNREFUSED",
|
|
243
|
+
"ENOTFOUND",
|
|
244
|
+
"EAI_AGAIN",
|
|
245
|
+
"EHOSTUNREACH",
|
|
246
|
+
"ENETUNREACH"
|
|
247
|
+
]);
|
|
248
|
+
function isIdempotent(request) {
|
|
249
|
+
return request.idempotent ?? request.method !== "POST";
|
|
250
|
+
}
|
|
251
|
+
function networkErrorCode(error) {
|
|
252
|
+
const cause = error?.cause;
|
|
253
|
+
const code = cause?.code ?? error?.code;
|
|
254
|
+
return typeof code === "string" ? code : void 0;
|
|
255
|
+
}
|
|
256
|
+
function parseRetryAfter(header, now = Date.now()) {
|
|
257
|
+
if (!header) {
|
|
258
|
+
return void 0;
|
|
259
|
+
}
|
|
260
|
+
const seconds = Number(header);
|
|
261
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
262
|
+
return seconds;
|
|
263
|
+
}
|
|
264
|
+
const date = Date.parse(header);
|
|
265
|
+
if (Number.isNaN(date)) {
|
|
266
|
+
return void 0;
|
|
267
|
+
}
|
|
268
|
+
return Math.max(0, Math.round((date - now) / 1e3));
|
|
269
|
+
}
|
|
270
|
+
function buildQuery(query) {
|
|
271
|
+
if (!query) {
|
|
272
|
+
return "";
|
|
273
|
+
}
|
|
274
|
+
const params = new URLSearchParams();
|
|
275
|
+
for (const [key, value] of Object.entries(query)) {
|
|
276
|
+
if (value === void 0 || value === null) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (Array.isArray(value)) {
|
|
280
|
+
for (const item of value) {
|
|
281
|
+
params.append(key, String(item));
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
params.append(key, value instanceof Date ? value.toISOString() : String(value));
|
|
286
|
+
}
|
|
287
|
+
const serialized = params.toString();
|
|
288
|
+
return serialized ? `?${serialized}` : "";
|
|
289
|
+
}
|
|
290
|
+
function sleep(ms) {
|
|
291
|
+
return new Promise((resolve) => {
|
|
292
|
+
setTimeout(resolve, ms);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
var HttpClient = class {
|
|
296
|
+
baseUrl;
|
|
297
|
+
timeoutMs;
|
|
298
|
+
retry;
|
|
299
|
+
hooks;
|
|
300
|
+
fetchImpl;
|
|
301
|
+
baseHeaders;
|
|
302
|
+
constructor(config) {
|
|
303
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
304
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
305
|
+
this.retry = { ...DEFAULT_RETRY, ...config.retry };
|
|
306
|
+
this.hooks = config.hooks ?? {};
|
|
307
|
+
this.fetchImpl = config.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
308
|
+
this.baseHeaders = Object.fromEntries(
|
|
309
|
+
Object.entries(config.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value])
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
async request(request) {
|
|
313
|
+
const url = `${this.baseUrl}${request.path}${buildQuery(request.query)}`;
|
|
314
|
+
const { body, headers } = this.prepareBody(request);
|
|
315
|
+
const timeoutMs = request.timeoutMs ?? this.timeoutMs;
|
|
316
|
+
const retryable = isIdempotent(request);
|
|
317
|
+
let attempt = 0;
|
|
318
|
+
for (; ; ) {
|
|
319
|
+
attempt++;
|
|
320
|
+
this.hooks.onRequest?.({ method: request.method, url, attempt });
|
|
321
|
+
const startedAt = Date.now();
|
|
322
|
+
let response;
|
|
323
|
+
try {
|
|
324
|
+
response = await this.send(url, request, body, headers, timeoutMs);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
const failure = this.toConnectionError(error, request, timeoutMs);
|
|
327
|
+
const canRetry = retryable || PRE_FLIGHT_NETWORK_CODES.has(networkErrorCode(error) ?? "");
|
|
328
|
+
if (!canRetry || attempt > this.retry.maxRetries) {
|
|
329
|
+
throw failure;
|
|
330
|
+
}
|
|
331
|
+
const delayMs = this.backoff(attempt);
|
|
332
|
+
this.hooks.onRetry?.({
|
|
333
|
+
method: request.method,
|
|
334
|
+
url,
|
|
335
|
+
attempt,
|
|
336
|
+
delayMs,
|
|
337
|
+
status: void 0,
|
|
338
|
+
error
|
|
339
|
+
});
|
|
340
|
+
await sleep(delayMs);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
this.hooks.onResponse?.({
|
|
344
|
+
method: request.method,
|
|
345
|
+
url,
|
|
346
|
+
attempt,
|
|
347
|
+
status: response.status,
|
|
348
|
+
durationMs: Date.now() - startedAt
|
|
349
|
+
});
|
|
350
|
+
const requestId = response.headers.get("x-request-id") ?? void 0;
|
|
351
|
+
if (response.ok) {
|
|
352
|
+
return {
|
|
353
|
+
data: await this.parseBody(response, request),
|
|
354
|
+
status: response.status,
|
|
355
|
+
headers: response.headers,
|
|
356
|
+
requestId
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
360
|
+
const shouldRetry = retryable && RETRYABLE_STATUS.has(response.status) && attempt <= this.retry.maxRetries;
|
|
361
|
+
const waitMs = retryAfter === void 0 ? this.backoff(attempt) : retryAfter * 1e3;
|
|
362
|
+
if (!shouldRetry || waitMs > this.retry.maxDelayMs) {
|
|
363
|
+
throw await this.toApiError(response, requestId, retryAfter);
|
|
364
|
+
}
|
|
365
|
+
await response.arrayBuffer().catch(() => void 0);
|
|
366
|
+
this.hooks.onRetry?.({
|
|
367
|
+
method: request.method,
|
|
368
|
+
url,
|
|
369
|
+
attempt,
|
|
370
|
+
delayMs: waitMs,
|
|
371
|
+
status: response.status,
|
|
372
|
+
error: void 0
|
|
373
|
+
});
|
|
374
|
+
await sleep(waitMs);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Backoff exponencial con jitter completo: aleatorio entre 0 y el tope de ese intento. */
|
|
378
|
+
backoff(attempt) {
|
|
379
|
+
const ceiling = Math.min(this.retry.maxDelayMs, this.retry.baseDelayMs * 2 ** (attempt - 1));
|
|
380
|
+
return Math.round(Math.random() * ceiling);
|
|
381
|
+
}
|
|
382
|
+
prepareBody(request) {
|
|
383
|
+
const headers = { accept: "application/json", ...this.baseHeaders };
|
|
384
|
+
for (const [key, value] of Object.entries(request.headers ?? {})) {
|
|
385
|
+
headers[key.toLowerCase()] = value;
|
|
386
|
+
}
|
|
387
|
+
if (request.body === void 0) {
|
|
388
|
+
return { body: void 0, headers };
|
|
389
|
+
}
|
|
390
|
+
if (request.body instanceof FormData || request.body instanceof URLSearchParams) {
|
|
391
|
+
return { body: request.body, headers };
|
|
392
|
+
}
|
|
393
|
+
if (typeof request.body === "string" || request.body instanceof Uint8Array) {
|
|
394
|
+
return { body: request.body, headers };
|
|
395
|
+
}
|
|
396
|
+
headers["content-type"] = "application/json";
|
|
397
|
+
return { body: JSON.stringify(request.body), headers };
|
|
398
|
+
}
|
|
399
|
+
async send(url, request, body, headers, timeoutMs) {
|
|
400
|
+
const controller = new AbortController();
|
|
401
|
+
const timer = setTimeout(() => controller.abort(new PlanVortexTimeout()), timeoutMs);
|
|
402
|
+
const external = request.signal;
|
|
403
|
+
const forward = () => controller.abort(external?.reason);
|
|
404
|
+
if (external) {
|
|
405
|
+
if (external.aborted) {
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
throw external.reason;
|
|
408
|
+
}
|
|
409
|
+
external.addEventListener("abort", forward, { once: true });
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
return await this.fetchImpl(url, {
|
|
413
|
+
method: request.method,
|
|
414
|
+
...body === void 0 ? {} : { body },
|
|
415
|
+
headers,
|
|
416
|
+
signal: controller.signal
|
|
417
|
+
});
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (controller.signal.reason instanceof PlanVortexTimeout && !external?.aborted) {
|
|
420
|
+
throw controller.signal.reason;
|
|
421
|
+
}
|
|
422
|
+
throw error;
|
|
423
|
+
} finally {
|
|
424
|
+
clearTimeout(timer);
|
|
425
|
+
external?.removeEventListener("abort", forward);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Un abort tiene dos orígenes y sólo uno es nuestro: si canceló el integrador, su error sale tal
|
|
430
|
+
* cual —envolverlo le rompería el `if (e.name === "AbortError")` que tenga escrito.
|
|
431
|
+
*/
|
|
432
|
+
toConnectionError(error, request, timeoutMs) {
|
|
433
|
+
if (error instanceof PlanVortexTimeout || error?.name === "TimeoutError") {
|
|
434
|
+
return new PlanVortexConnectionError(
|
|
435
|
+
`${request.method} ${request.path} agot\xF3 el timeout de ${timeoutMs} ms`,
|
|
436
|
+
{ timeout: true, cause: error }
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
if (request.signal?.aborted) {
|
|
440
|
+
return error;
|
|
441
|
+
}
|
|
442
|
+
const code = networkErrorCode(error);
|
|
443
|
+
return new PlanVortexConnectionError(
|
|
444
|
+
`${request.method} ${request.path} no pudo conectar${code ? ` (${code})` : ""}`,
|
|
445
|
+
{ timeout: false, cause: error }
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
async toApiError(response, requestId, retryAfter) {
|
|
449
|
+
const body = await this.readBody(response);
|
|
450
|
+
return createErrorFromResponse({ body, status: response.status, requestId, retryAfter });
|
|
451
|
+
}
|
|
452
|
+
async parseBody(response, request) {
|
|
453
|
+
if (request.parse === "none" || response.status === 204) {
|
|
454
|
+
return void 0;
|
|
455
|
+
}
|
|
456
|
+
return this.readBody(response);
|
|
457
|
+
}
|
|
458
|
+
/** JSON si se puede; si no, el texto crudo. Un cuerpo vacío es `undefined`, nunca un throw. */
|
|
459
|
+
async readBody(response) {
|
|
460
|
+
const text = await response.text().catch(() => "");
|
|
461
|
+
if (!text) {
|
|
462
|
+
return void 0;
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
return JSON.parse(text);
|
|
466
|
+
} catch {
|
|
467
|
+
return text;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
var PlanVortexTimeout = class extends Error {
|
|
472
|
+
constructor() {
|
|
473
|
+
super("PlanVortex request timeout");
|
|
474
|
+
this.name = "TimeoutError";
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
// src/core/pagination.ts
|
|
479
|
+
var DEFAULT_PAGE_SIZE = 50;
|
|
480
|
+
var MAX_PAGES = 1e4;
|
|
481
|
+
function unwrapList(body, key) {
|
|
482
|
+
const envelope = body;
|
|
483
|
+
const data = envelope?.[key];
|
|
484
|
+
if (!Array.isArray(data)) {
|
|
485
|
+
throw new PlanVortexError(
|
|
486
|
+
NO_ERROR_CODE,
|
|
487
|
+
`La respuesta no trae "${key}": el sobre de la lista no es el esperado.`,
|
|
488
|
+
{
|
|
489
|
+
family: "http",
|
|
490
|
+
data: { expected: key, received: envelope ? Object.keys(envelope) : envelope }
|
|
491
|
+
}
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
const total = envelope?.total;
|
|
495
|
+
return {
|
|
496
|
+
data,
|
|
497
|
+
//`total` viaja siempre, pero si un despliegue viejo no lo mandara, la longitud de la
|
|
498
|
+
//página es mejor respuesta que un `undefined` colándose como número.
|
|
499
|
+
total: typeof total === "number" ? total : data.length
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function unwrapOne(body, key) {
|
|
503
|
+
const envelope = body;
|
|
504
|
+
const value = envelope?.[key];
|
|
505
|
+
if (value === void 0 || value === null) {
|
|
506
|
+
throw new PlanVortexError(NO_ERROR_CODE, `La respuesta no trae "${key}".`, {
|
|
507
|
+
family: "http",
|
|
508
|
+
data: { expected: key, received: envelope ? Object.keys(envelope) : envelope }
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return value;
|
|
512
|
+
}
|
|
513
|
+
async function* iteratePages(fetchPage, options = {}) {
|
|
514
|
+
const limit = options.limit ?? DEFAULT_PAGE_SIZE;
|
|
515
|
+
let offset = options.offset ?? 0;
|
|
516
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
517
|
+
const { data } = await fetchPage({ limit, offset });
|
|
518
|
+
if (!data.length) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
for (const item of data) {
|
|
522
|
+
yield item;
|
|
523
|
+
}
|
|
524
|
+
if (data.length < limit) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
offset += data.length;
|
|
528
|
+
}
|
|
529
|
+
throw new PlanVortexError(
|
|
530
|
+
NO_ERROR_CODE,
|
|
531
|
+
`La paginaci\xF3n pas\xF3 de ${MAX_PAGES} p\xE1ginas: el servidor no est\xE1 avanzando con el offset.`,
|
|
532
|
+
{ family: "http" }
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// src/resources/base.ts
|
|
537
|
+
var Resource = class {
|
|
538
|
+
constructor(client) {
|
|
539
|
+
this.client = client;
|
|
540
|
+
}
|
|
541
|
+
client;
|
|
542
|
+
async send(request, options = {}) {
|
|
543
|
+
const response = await this.client.request({
|
|
544
|
+
...request,
|
|
545
|
+
...options.signal === void 0 ? {} : { signal: options.signal },
|
|
546
|
+
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
|
|
547
|
+
});
|
|
548
|
+
return response.data;
|
|
549
|
+
}
|
|
550
|
+
/*
|
|
551
|
+
* Los cuatro verbos se llaman `httpGet`/`httpPost`/... y no `get`/`post`/... porque los
|
|
552
|
+
* recursos que heredan de aquí SÍ tienen métodos públicos con esos nombres —`uploads.get(org,
|
|
553
|
+
* id)` es lo que un integrador escribe— y en TypeScript una subclase no puede declarar un
|
|
554
|
+
* miembro con la firma de uno heredado. Sin el prefijo, `this.get(...)` dentro de un recurso
|
|
555
|
+
* resolvía a su propio método público y compilaba llamando a otra cosa.
|
|
556
|
+
*/
|
|
557
|
+
httpGet(path, query, options) {
|
|
558
|
+
return this.send({ method: "GET", path, ...query ? { query } : {} }, options);
|
|
559
|
+
}
|
|
560
|
+
httpPost(path, body, options, query) {
|
|
561
|
+
return this.send(
|
|
562
|
+
{ method: "POST", path, ...body === void 0 ? {} : { body }, ...query ? { query } : {} },
|
|
563
|
+
options
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
httpPut(path, body, options) {
|
|
567
|
+
return this.send({ method: "PUT", path, ...body === void 0 ? {} : { body } }, options);
|
|
568
|
+
}
|
|
569
|
+
httpDelete(path, query, options) {
|
|
570
|
+
return this.send({ method: "DELETE", path, ...query ? { query } : {} }, options);
|
|
571
|
+
}
|
|
572
|
+
/** `GET` de una lista, ya desenvuelta a `{data, total}`. */
|
|
573
|
+
async getList(path, key, query, options) {
|
|
574
|
+
return unwrapList(await this.httpGet(path, query, options), key);
|
|
575
|
+
}
|
|
576
|
+
/** `GET` de un recurso suelto, ya sacado de su sobre. */
|
|
577
|
+
async getOne(path, key, query, options) {
|
|
578
|
+
return unwrapOne(await this.httpGet(path, query, options), key);
|
|
579
|
+
}
|
|
580
|
+
/** `POST` que devuelve un recurso envuelto. */
|
|
581
|
+
async postOne(path, key, body, options, query) {
|
|
582
|
+
return unwrapOne(await this.httpPost(path, body, options, query), key);
|
|
583
|
+
}
|
|
584
|
+
/** `PUT` que devuelve un recurso envuelto. */
|
|
585
|
+
async putOne(path, key, body, options) {
|
|
586
|
+
return unwrapOne(await this.httpPut(path, body, options), key);
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
function requireId(value, name) {
|
|
590
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
591
|
+
throw new TypeError(`Falta ${name}.`);
|
|
592
|
+
}
|
|
593
|
+
return value;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/resources/accounts.ts
|
|
597
|
+
var AccountsResource = class extends Resource {
|
|
598
|
+
/**
|
|
599
|
+
* Los enlaces de autorización de cada red conectable, para mandar a la persona a la suya.
|
|
600
|
+
*
|
|
601
|
+
* **Con credenciales de app contesta 519.** Se llama con un cliente autenticado con el token
|
|
602
|
+
* temporal: `pv.asTemporalToken(token).accounts.connectLinks(orgId)`.
|
|
603
|
+
*
|
|
604
|
+
* **Una red que no puede dar enlace simplemente no aparece**, y eso es una respuesta legítima y
|
|
605
|
+
* no un fallo: es lo que pasa con Discord en una organización que todavía no ha guardado sus
|
|
606
|
+
* propias credenciales de bot.
|
|
607
|
+
*
|
|
608
|
+
* OJO: la red devuelve al usuario a un front de PlanVortex, no a una URL tuya — ver
|
|
609
|
+
* `redirect_uri` en {@link ConnectLinksOptions}.
|
|
610
|
+
*/
|
|
611
|
+
async connectLinks(idOrganization, options = {}) {
|
|
612
|
+
return unwrapOne(
|
|
613
|
+
await this.httpGet(
|
|
614
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/connect_links`,
|
|
615
|
+
{ social_network: options.social_network, redirect_uri: options.redirect_uri },
|
|
616
|
+
options
|
|
617
|
+
),
|
|
618
|
+
"links"
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Completa la conexión con lo que la red social pegó a la URL de vuelta.
|
|
623
|
+
*
|
|
624
|
+
* **Esta llamada no la necesita la mayoría.** La URL de vuelta la construye la red a partir del
|
|
625
|
+
* enlace de {@link connectLinks}, y apunta a un front de PlanVortex: es ese front el que llama
|
|
626
|
+
* aquí. El método existe para quien sirve su propia interfaz en uno de los dominios registrados
|
|
627
|
+
* en el servidor. En la integración normal —la del ejemplo `connect-flow`— basta con mandar al
|
|
628
|
+
* usuario a la `url` del token temporal y esperarlo de vuelta.
|
|
629
|
+
*
|
|
630
|
+
* **El endpoint contesta 200 aunque haya fallado**, con el error dentro del cuerpo, porque el
|
|
631
|
+
* navegador aterriza aquí desde una redirección y un 400 crudo sería una página rota. La
|
|
632
|
+
* librería deshace ese apaño: si viene `errorCode`, **lanza** el error que le toca, igual que
|
|
633
|
+
* cualquier otro método. Lo que devuelve son sólo cuentas buenas.
|
|
634
|
+
*
|
|
635
|
+
* **Y vuelven SIN habilitar**: no ocupan plaza del plan ni publican hasta que se llama a
|
|
636
|
+
* {@link enable}. Una sola autorización puede dejar varias — un usuario de Facebook con cuatro
|
|
637
|
+
* páginas son cuatro—, y por eso hay un paso de elección en medio.
|
|
638
|
+
*/
|
|
639
|
+
async connect(idOrganization, socialNetwork, params = {}, options = {}) {
|
|
640
|
+
const path = `/organizations/${requireId(idOrganization, "idOrganization")}/account-connect/${requireId(socialNetwork, "socialNetwork")}`;
|
|
641
|
+
const body = await this.httpGet(path, params, options);
|
|
642
|
+
if (body.errorCode) {
|
|
643
|
+
const code = Number(body.errorCode);
|
|
644
|
+
throw createErrorFromResponse({
|
|
645
|
+
body: {
|
|
646
|
+
code: Number.isFinite(code) ? code : NO_ERROR_CODE,
|
|
647
|
+
message: body.errorMsg || `La conexi\xF3n no se complet\xF3 (${body.errorCode}).`
|
|
648
|
+
},
|
|
649
|
+
status: 200
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
accounts: body.accounts ?? [],
|
|
654
|
+
...body.redirect_uri === void 0 ? {} : { redirect_uri: body.redirect_uri }
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Da de alta una de las cuentas que dejó {@link connect}, o recupera una que se desconectó
|
|
659
|
+
* mientras su token guardado siga sirviendo (si no, error 700 y hay que autorizar otra vez).
|
|
660
|
+
*
|
|
661
|
+
* **Es el paso que ocupa plaza del plan**: con el cupo lleno contesta 706, así que se llama una
|
|
662
|
+
* a una y se mira el hueco antes (`organizations.limits`). Y es también el que enciende los
|
|
663
|
+
* webhooks de la red, en cualquier plan que no sea el gratuito.
|
|
664
|
+
*/
|
|
665
|
+
async enable(idOrganization, idAccount, options = {}) {
|
|
666
|
+
const body = await this.httpPost(
|
|
667
|
+
`${this.path(idOrganization, idAccount)}/enable`,
|
|
668
|
+
void 0,
|
|
669
|
+
options
|
|
670
|
+
);
|
|
671
|
+
return body.redirect_uri === void 0 ? {} : { redirect_uri: body.redirect_uri };
|
|
672
|
+
}
|
|
673
|
+
/** Las cuentas de una organización. */
|
|
674
|
+
async list(idOrganization, options = {}) {
|
|
675
|
+
return this.getList(
|
|
676
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/accounts`,
|
|
677
|
+
"accounts",
|
|
678
|
+
{
|
|
679
|
+
offset: options.offset,
|
|
680
|
+
limit: options.limit,
|
|
681
|
+
name: options.name,
|
|
682
|
+
social_network: options.social_network,
|
|
683
|
+
accounts: options.accounts,
|
|
684
|
+
capability: options.capability
|
|
685
|
+
},
|
|
686
|
+
options
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
/** Las cuentas de una organización, encadenando páginas. */
|
|
690
|
+
iterate(idOrganization, options = {}) {
|
|
691
|
+
return iteratePages((page) => this.list(idOrganization, { ...options, ...page }), options);
|
|
692
|
+
}
|
|
693
|
+
/** La ficha de una cuenta. */
|
|
694
|
+
async get(idOrganization, idAccount, options = {}) {
|
|
695
|
+
return this.getOne(this.path(idOrganization, idAccount), "account", void 0, options);
|
|
696
|
+
}
|
|
697
|
+
/** Cambia el nombre con el que la cuenta se ve en PlanVortex. Es lo único editable. */
|
|
698
|
+
async update(idOrganization, idAccount, body, options = {}) {
|
|
699
|
+
return this.putOne(this.path(idOrganization, idAccount), "account", body, options);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Desconecta la cuenta y **borra sus publicaciones**. Lo ya publicado en la red se queda donde
|
|
703
|
+
* está: esto no la toca.
|
|
704
|
+
*/
|
|
705
|
+
async remove(idOrganization, idAccount, options = {}) {
|
|
706
|
+
await this.httpDelete(this.path(idOrganization, idAccount), void 0, options);
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* La serie de métricas ya medidas de una cuenta.
|
|
710
|
+
*
|
|
711
|
+
* Es lectura de lo guardado, no una llamada a la red: mirar la gráfica no cuesta créditos. El
|
|
712
|
+
* agrupado lo decide el rango — hasta 31 días por día, hasta 720 por mes, y de ahí por año— y
|
|
713
|
+
* viene dicho en `group`.
|
|
714
|
+
*/
|
|
715
|
+
async metrics(idOrganization, idAccount, options = {}) {
|
|
716
|
+
return this.httpGet(
|
|
717
|
+
`${this.path(idOrganization, idAccount)}/metrics`,
|
|
718
|
+
{
|
|
719
|
+
from_date: options.from_date,
|
|
720
|
+
to_date: options.to_date,
|
|
721
|
+
names: options.names
|
|
722
|
+
},
|
|
723
|
+
options
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Los nombres CRUDOS de las métricas que publica la red de esta cuenta.
|
|
728
|
+
*
|
|
729
|
+
* Son los que se pasan a {@link metrics} y los que vuelven en cada fila. No es el vocabulario
|
|
730
|
+
* común —eso es `metrics` de una publicación—: aquí cada red habla su idioma
|
|
731
|
+
* (`page_impressions`, `total_interactions`, `allPageViews`).
|
|
732
|
+
*/
|
|
733
|
+
async metricList(idOrganization, idAccount, options = {}) {
|
|
734
|
+
return this.httpGet(
|
|
735
|
+
`${this.path(idOrganization, idAccount)}/metric_list`,
|
|
736
|
+
void 0,
|
|
737
|
+
options
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* El menú fijo del chat, una entrada por idioma.
|
|
742
|
+
*
|
|
743
|
+
* Sólo las redes con mensajería lo tienen: en las demás la llamada devuelve el error 710. Se
|
|
744
|
+
* comprueba con `persistent_menu` de `catalog.socialCapabilities()`.
|
|
745
|
+
*/
|
|
746
|
+
async getPersistentMenu(idOrganization, idAccount, options = {}) {
|
|
747
|
+
return unwrapOne(
|
|
748
|
+
await this.httpGet(
|
|
749
|
+
`${this.path(idOrganization, idAccount)}/persistent_menu`,
|
|
750
|
+
void 0,
|
|
751
|
+
options
|
|
752
|
+
),
|
|
753
|
+
"persistent_menu"
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Reemplaza el menú fijo del chat. Es un REEMPLAZO: lo que no vaya en el array desaparece.
|
|
758
|
+
*
|
|
759
|
+
* La entrada con `locale: "default"` es obligatoria — es la que se enseña cuando ninguna otra
|
|
760
|
+
* encaja.
|
|
761
|
+
*/
|
|
762
|
+
async setPersistentMenu(idOrganization, idAccount, menu, options = {}) {
|
|
763
|
+
return unwrapOne(
|
|
764
|
+
await this.httpPost(
|
|
765
|
+
`${this.path(idOrganization, idAccount)}/persistent_menu`,
|
|
766
|
+
{ persistent_menu: menu },
|
|
767
|
+
options
|
|
768
|
+
),
|
|
769
|
+
"persistent_menu"
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
path(idOrganization, idAccount) {
|
|
773
|
+
return `/organizations/${requireId(idOrganization, "idOrganization")}/accounts/${requireId(idAccount, "idAccount")}`;
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
// src/resources/catalog.ts
|
|
778
|
+
var CatalogResource = class extends Resource {
|
|
779
|
+
cache = /* @__PURE__ */ new Map();
|
|
780
|
+
/**
|
|
781
|
+
* Las redes soportadas.
|
|
782
|
+
*
|
|
783
|
+
* **La lista crece varias veces al año.** No la copies a una constante tuya: pídela.
|
|
784
|
+
*/
|
|
785
|
+
async socialNetworks(options) {
|
|
786
|
+
return this.cached(
|
|
787
|
+
"/social_networks",
|
|
788
|
+
() => this.httpGet("/social_networks", void 0, options)
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
/** Las redes que aceptan publicaciones. Ni WhatsApp ni Google Business están. */
|
|
792
|
+
async allowedSocialPublications(options) {
|
|
793
|
+
return this.cached(
|
|
794
|
+
"/allowed_social_publications",
|
|
795
|
+
() => this.httpGet("/allowed_social_publications", void 0, options)
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Las redes con conversaciones.
|
|
800
|
+
*
|
|
801
|
+
* Va en `POST` y no en `GET`, que es raro y es así: es la ruta que hay. No manda cuerpo.
|
|
802
|
+
*/
|
|
803
|
+
async allowedSocialMessages(options) {
|
|
804
|
+
return this.cached(
|
|
805
|
+
"/allowed_social_messages",
|
|
806
|
+
() => this.httpPost("/allowed_social_messages", void 0, options)
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* La matriz red → qué sabe hacer: publicar, mensajes, productos, webhooks, menú persistente y
|
|
811
|
+
* comentarios.
|
|
812
|
+
*
|
|
813
|
+
* Es lo que evita ofrecer una cuenta en una pantalla que su red no soporta — WhatsApp en el
|
|
814
|
+
* compositor, LinkedIn en el chat.
|
|
815
|
+
*/
|
|
816
|
+
async socialCapabilities(options) {
|
|
817
|
+
return this.cached(
|
|
818
|
+
"/social_capabilities",
|
|
819
|
+
() => this.httpGet("/social_capabilities", void 0, options)
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* La matriz red → qué se puede hacer con un comentario: responder, ocultar, borrar el propio y
|
|
824
|
+
* borrar el de otro.
|
|
825
|
+
*
|
|
826
|
+
* Va **aparte** de {@link socialCapabilities} porque aquélla es `{[capacidad]: boolean}` y esto
|
|
827
|
+
* es un objeto por red: meterlo dentro rompería su forma. Que la red tenga comentarios no dice
|
|
828
|
+
* lo suficiente — Instagram, X y Bluesky no dejan borrar el de otro, LinkedIn no tiene
|
|
829
|
+
* "ocultar", y Google Business sólo deja borrar **nuestra propia respuesta**.
|
|
830
|
+
*/
|
|
831
|
+
async socialCommentActions(options) {
|
|
832
|
+
return this.cached(
|
|
833
|
+
"/social_comment_actions",
|
|
834
|
+
() => this.httpGet("/social_comment_actions", void 0, options)
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Los topes de cada red, por los que el servidor valida.
|
|
839
|
+
*
|
|
840
|
+
* Bluesky lleva **dos** cuentas del mismo texto y en unidades distintas: 300 grafemas en
|
|
841
|
+
* `characters` y 3.000 bytes en `max_post_bytes`. `.length` miente en las dos direcciones —un
|
|
842
|
+
* emoji de familia es UN grafema y 25 bytes—, así que un contador que use `.length` da por
|
|
843
|
+
* bueno lo que la API rechaza y al revés.
|
|
844
|
+
*/
|
|
845
|
+
async socialLimits(options) {
|
|
846
|
+
return this.cached(
|
|
847
|
+
"/social_limits",
|
|
848
|
+
() => this.httpGet("/social_limits", void 0, options)
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
/** Los topes de una publicación que no dependen de la red: hoy, cuántos reintentos manuales admite. */
|
|
852
|
+
async publicationLimits(options) {
|
|
853
|
+
return this.cached(
|
|
854
|
+
"/publication_limits",
|
|
855
|
+
() => this.httpGet("/publication_limits", void 0, options)
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Los recortes que acepta cada red.
|
|
860
|
+
*
|
|
861
|
+
* Se indexa por red **y por formato** (`facebook`, `facebook_reels`, `facebook_stories`), así
|
|
862
|
+
* que no todas las claves son una red. `values` y `text` son arrays paralelos: mismo índice,
|
|
863
|
+
* mismo recorte.
|
|
864
|
+
*/
|
|
865
|
+
async allowedAspectRatios(options) {
|
|
866
|
+
return this.cached(
|
|
867
|
+
"/allowed_aspect_ratios",
|
|
868
|
+
() => this.httpGet("/allowed_aspect_ratios", void 0, options)
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
/** Tira la caché. Para un proceso largo que quiera enterarse de una red nueva sin reiniciar. */
|
|
872
|
+
clearCache() {
|
|
873
|
+
this.cache.clear();
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Guarda la PROMESA, no el resultado: dos llamadas a la vez comparten una petición en vez de
|
|
877
|
+
* lanzar dos. Si falla, la entrada se retira para que el siguiente intento vuelva a pedirla —
|
|
878
|
+
* cachear un fallo de red deja la instancia rota para siempre.
|
|
879
|
+
*/
|
|
880
|
+
cached(key, fetch) {
|
|
881
|
+
const hit = this.cache.get(key);
|
|
882
|
+
if (hit) {
|
|
883
|
+
return hit;
|
|
884
|
+
}
|
|
885
|
+
const pending = fetch().catch((error) => {
|
|
886
|
+
this.cache.delete(key);
|
|
887
|
+
throw error;
|
|
888
|
+
});
|
|
889
|
+
this.cache.set(key, pending);
|
|
890
|
+
return pending;
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
// src/resources/clients.ts
|
|
895
|
+
var ClientsResource = class extends Resource {
|
|
896
|
+
/** Los clientes que puede ver quien llama. Con credenciales de app, el suyo. */
|
|
897
|
+
async list(options = {}) {
|
|
898
|
+
return this.getList("/clients", "clients", listQuery(options), options);
|
|
899
|
+
}
|
|
900
|
+
/** Los clientes, página a página, sin tener que llevar el `offset` a mano. */
|
|
901
|
+
iterate(options = {}) {
|
|
902
|
+
return iteratePages((page) => this.list({ ...options, ...page }), options);
|
|
903
|
+
}
|
|
904
|
+
/** La ficha de un cliente. */
|
|
905
|
+
async get(idClient, options = {}) {
|
|
906
|
+
return this.getOne(
|
|
907
|
+
`/clients/${requireId(idClient, "idClient")}`,
|
|
908
|
+
"client",
|
|
909
|
+
options.getUse ? { getUse: true } : void 0,
|
|
910
|
+
options
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
/** Cambia lo poco que de un cliente se puede cambiar: hoy, su nombre. */
|
|
914
|
+
async update(idClient, body, options = {}) {
|
|
915
|
+
return this.putOne(`/clients/${requireId(idClient, "idClient")}`, "client", body, options);
|
|
916
|
+
}
|
|
917
|
+
/** Las organizaciones RAÍZ de un cliente. Las hijas cuelgan de cada una. */
|
|
918
|
+
async organizations(idClient, options = {}) {
|
|
919
|
+
return this.getList(
|
|
920
|
+
`/clients/${requireId(idClient, "idClient")}/organizations`,
|
|
921
|
+
"organizations",
|
|
922
|
+
{ ...listQuery(options), name: options.name },
|
|
923
|
+
options
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
/** Las organizaciones raíz de un cliente, encadenando páginas. */
|
|
927
|
+
iterateOrganizations(idClient, options = {}) {
|
|
928
|
+
return iteratePages(
|
|
929
|
+
(page) => this.organizations(idClient, { ...options, ...page }),
|
|
930
|
+
options
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
/** Crea una organización raíz. Lo que se le asigne se descuenta de lo que el cliente tiene. */
|
|
934
|
+
async createOrganization(idClient, body, options = {}) {
|
|
935
|
+
return this.postOne(
|
|
936
|
+
`/clients/${requireId(idClient, "idClient")}/organizations`,
|
|
937
|
+
"organization",
|
|
938
|
+
body,
|
|
939
|
+
options
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
/** Cambia una organización raíz: su nombre o el cupo que tiene asignado. */
|
|
943
|
+
async updateOrganization(idClient, idOrganization, body, options = {}) {
|
|
944
|
+
return this.putOne(
|
|
945
|
+
`/clients/${requireId(idClient, "idClient")}/organizations/${requireId(idOrganization, "idOrganization")}`,
|
|
946
|
+
"organization",
|
|
947
|
+
body,
|
|
948
|
+
options
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Borra una organización raíz **con todo lo que tiene dentro**: sus organizaciones hijas, sus
|
|
953
|
+
* cuentas, sus publicaciones, sus ficheros y sus comentarios. No se deshace.
|
|
954
|
+
*/
|
|
955
|
+
async deleteOrganization(idClient, idOrganization, options = {}) {
|
|
956
|
+
await this.httpDelete(
|
|
957
|
+
`/clients/${requireId(idClient, "idClient")}/organizations/${requireId(idOrganization, "idOrganization")}`,
|
|
958
|
+
void 0,
|
|
959
|
+
options
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
function listQuery(options) {
|
|
964
|
+
return {
|
|
965
|
+
offset: options.offset,
|
|
966
|
+
limit: options.limit,
|
|
967
|
+
//`getUse` sólo se manda cuando se pide: un `getUse=false` en la query es ruido en el log.
|
|
968
|
+
getUse: options.getUse ? true : void 0
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// src/resources/organizations.ts
|
|
973
|
+
var OrganizationsResource = class extends Resource {
|
|
974
|
+
/** La ficha de una organización. */
|
|
975
|
+
async get(idOrganization, options = {}) {
|
|
976
|
+
return this.getOne(
|
|
977
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}`,
|
|
978
|
+
"organization",
|
|
979
|
+
options.getUse ? { getUse: true } : void 0,
|
|
980
|
+
options
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
/** Cambia el nombre de una organización o el cupo que tiene asignado. */
|
|
984
|
+
async update(idOrganization, body, options = {}) {
|
|
985
|
+
return this.putOne(
|
|
986
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}`,
|
|
987
|
+
"organization",
|
|
988
|
+
body,
|
|
989
|
+
options
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Borra una organización **con todo lo que tiene dentro**: sus hijas, sus cuentas, sus
|
|
994
|
+
* publicaciones, sus ficheros y sus comentarios. No se deshace.
|
|
995
|
+
*/
|
|
996
|
+
async remove(idOrganization, options = {}) {
|
|
997
|
+
await this.httpDelete(
|
|
998
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}`,
|
|
999
|
+
void 0,
|
|
1000
|
+
options
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
/** Las organizaciones que cuelgan de ésta. */
|
|
1004
|
+
async children(idOrganization, options = {}) {
|
|
1005
|
+
return this.getList(
|
|
1006
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/organizations`,
|
|
1007
|
+
"organizations",
|
|
1008
|
+
{
|
|
1009
|
+
offset: options.offset,
|
|
1010
|
+
limit: options.limit,
|
|
1011
|
+
name: options.name,
|
|
1012
|
+
getUse: options.getUse ? true : void 0
|
|
1013
|
+
},
|
|
1014
|
+
options
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
/** Las organizaciones hijas, encadenando páginas. */
|
|
1018
|
+
iterateChildren(idOrganization, options = {}) {
|
|
1019
|
+
return iteratePages(
|
|
1020
|
+
(page) => this.children(idOrganization, { ...options, ...page }),
|
|
1021
|
+
options
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
/** Crea una organización hija con el cupo que se le reparta del plan de ésta. */
|
|
1025
|
+
async createChild(idOrganization, body, options = {}) {
|
|
1026
|
+
return this.postOne(
|
|
1027
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/organizations`,
|
|
1028
|
+
"organization",
|
|
1029
|
+
body,
|
|
1030
|
+
options
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Lo que esta organización puede usar de verdad, con la cascada ya resuelta: su plan propio, o
|
|
1035
|
+
* el del primer padre que tenga uno, o el resto sin repartir del cliente.
|
|
1036
|
+
*
|
|
1037
|
+
* Es lo que hay que mirar antes de conectar una cuenta o programar una publicación, no
|
|
1038
|
+
* `organization.actual_plan`.
|
|
1039
|
+
*/
|
|
1040
|
+
async limits(idOrganization, options = {}) {
|
|
1041
|
+
return this.httpGet(
|
|
1042
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/limits`,
|
|
1043
|
+
void 0,
|
|
1044
|
+
options
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* El consumo de esta organización y lo que ya tiene repartido a sus hijas.
|
|
1049
|
+
*
|
|
1050
|
+
* Es un atajo de `get(id, {getUse: true})` que devuelve sólo las dos cifras, que es lo que se
|
|
1051
|
+
* quiere cuando se está pintando una barra de "3 de 5 cuentas".
|
|
1052
|
+
*/
|
|
1053
|
+
async use(idOrganization, options = {}) {
|
|
1054
|
+
const organization = await this.get(idOrganization, { ...options, getUse: true });
|
|
1055
|
+
return { actual_use: organization.actual_use, actual_asigned: organization.actual_asigned };
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Emite el token temporal con el que **una persona** conecta una cuenta social a esta
|
|
1059
|
+
* organización. Es la única forma que tiene una app de que se le conecte una cuenta.
|
|
1060
|
+
*
|
|
1061
|
+
* Y es el reverso exacto del resto del flujo: éste es el endpoint que **exige credenciales de
|
|
1062
|
+
* app** —con un token de usuario contesta 514—, mientras que los tres que vienen después
|
|
1063
|
+
* (`accounts.connectLinks`, `accounts.connect`, `accounts.enable`) las rechazan con un 519.
|
|
1064
|
+
*
|
|
1065
|
+
* Vuelven las dos formas del mismo credencial, y las dos sirven:
|
|
1066
|
+
*
|
|
1067
|
+
* - **`url`** — el camino alojado. Se redirige al usuario ahí y PlanVortex se encarga de la
|
|
1068
|
+
* elección de red, del OAuth y de la pantalla donde elige qué cuentas dar de alta. Es lo que
|
|
1069
|
+
* hace el ejemplo `examples/connect-flow`, y lo que casi todo el mundo quiere.
|
|
1070
|
+
* - **`token`** — el credencial suelto, para `pv.asTemporalToken(token)` cuando la interfaz la
|
|
1071
|
+
* pone el integrador.
|
|
1072
|
+
*
|
|
1073
|
+
* Caduca en una hora y **sólo vale para esta organización**: usarlo contra otra contesta 1101.
|
|
1074
|
+
*/
|
|
1075
|
+
async createConnectToken(idOrganization, options = {}) {
|
|
1076
|
+
return this.httpGet(
|
|
1077
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/temporal_connect_token`,
|
|
1078
|
+
{ social_network: options.social_network, redirect_uri: options.redirect_uri },
|
|
1079
|
+
options
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
// src/resources/publications.ts
|
|
1085
|
+
var PublicationsResource = class extends Resource {
|
|
1086
|
+
/**
|
|
1087
|
+
* Crea una publicación en una cuenta.
|
|
1088
|
+
*
|
|
1089
|
+
* Sin `publish_date` se envía en esta misma petición y la respuesta ya dice si salió
|
|
1090
|
+
* (`state: "sended"`) o si falló y por qué. Con fecha futura queda `ready`.
|
|
1091
|
+
*
|
|
1092
|
+
* ```ts
|
|
1093
|
+
* const publication = await pv.publications.create(orgId, accountId, {
|
|
1094
|
+
* social_network: "instagram",
|
|
1095
|
+
* text: "Nuevo horno, nuevas hogazas",
|
|
1096
|
+
* files: [upload._id],
|
|
1097
|
+
* publish_date: new Date("2026-09-01T10:00:00Z"),
|
|
1098
|
+
* });
|
|
1099
|
+
* ```
|
|
1100
|
+
*/
|
|
1101
|
+
async create(idOrganization, idAccount, body, options = {}) {
|
|
1102
|
+
return this.postOne(
|
|
1103
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/accounts/${requireId(idAccount, "idAccount")}/publish`,
|
|
1104
|
+
"publication",
|
|
1105
|
+
serializeInput(body),
|
|
1106
|
+
options
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
/** Una publicación, con sus ficheros y su cuenta ya resueltos. */
|
|
1110
|
+
async get(idOrganization, idPublication, options = {}) {
|
|
1111
|
+
return this.getOne(
|
|
1112
|
+
this.path(idOrganization, idPublication),
|
|
1113
|
+
"publication",
|
|
1114
|
+
void 0,
|
|
1115
|
+
options
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
/** Las publicaciones de una organización. */
|
|
1119
|
+
async list(idOrganization, options = {}) {
|
|
1120
|
+
return this.getList(
|
|
1121
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/publish`,
|
|
1122
|
+
"publications",
|
|
1123
|
+
listQuery2(options),
|
|
1124
|
+
options
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
/** Las publicaciones de una organización, encadenando páginas. */
|
|
1128
|
+
iterate(idOrganization, options = {}) {
|
|
1129
|
+
return iteratePages(
|
|
1130
|
+
(page) => this.list(idOrganization, { ...options, ...page }),
|
|
1131
|
+
options
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
/** Las publicaciones de UNA cuenta. Mismos filtros que {@link list}. */
|
|
1135
|
+
async listByAccount(idOrganization, idAccount, options = {}) {
|
|
1136
|
+
return this.getList(
|
|
1137
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/accounts/${requireId(idAccount, "idAccount")}/publish`,
|
|
1138
|
+
"publications",
|
|
1139
|
+
listQuery2(options),
|
|
1140
|
+
options
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Cambia una publicación que todavía no ha salido. Una `sended` devuelve el error 921.
|
|
1145
|
+
*
|
|
1146
|
+
* Editar PONE EL CONTADOR DE REINTENTOS A CERO: el contador cuenta intentos de publicar *ese*
|
|
1147
|
+
* contenido, y acabas de cambiarlo. Es además la salida cuando se agotan los tres.
|
|
1148
|
+
*/
|
|
1149
|
+
async update(idOrganization, idPublication, body, options = {}) {
|
|
1150
|
+
return this.putOne(
|
|
1151
|
+
this.path(idOrganization, idPublication),
|
|
1152
|
+
"publication",
|
|
1153
|
+
serializeInput(body),
|
|
1154
|
+
options
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Borra una publicación **y también el post en la red social**.
|
|
1159
|
+
*
|
|
1160
|
+
* En X borrar cuesta créditos: sin ellos devuelve un 940 en vez de un error genérico.
|
|
1161
|
+
*/
|
|
1162
|
+
async remove(idOrganization, idPublication, options = {}) {
|
|
1163
|
+
await this.httpDelete(this.path(idOrganization, idPublication), void 0, options);
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Vuelve a intentar una publicación que falló, sin tocar su contenido.
|
|
1167
|
+
*
|
|
1168
|
+
* Se reintenta EN LA PETICIÓN, así que la respuesta ya dice si esta vez salió. Cada llamada
|
|
1169
|
+
* gasta un reintento aunque vuelva a fallar por el contenido; sólo los créditos de X cortan
|
|
1170
|
+
* antes de gastarlo. Una publicación que no está en `withErrors` devuelve un 949, y agotar el
|
|
1171
|
+
* tope, un 950.
|
|
1172
|
+
*/
|
|
1173
|
+
async retry(idOrganization, idPublication, options = {}) {
|
|
1174
|
+
return this.httpPost(
|
|
1175
|
+
`${this.path(idOrganization, idPublication)}/retry`,
|
|
1176
|
+
void 0,
|
|
1177
|
+
options
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Pide las métricas A LA RED, en vivo, y devuelve su desglose crudo.
|
|
1182
|
+
*
|
|
1183
|
+
* **En X esto cuesta un crédito por lectura.** Para pintar una gráfica usa {@link stats}, que
|
|
1184
|
+
* lee lo ya medido y no cuesta nada.
|
|
1185
|
+
*/
|
|
1186
|
+
async metrics(idOrganization, idPublication, options = {}) {
|
|
1187
|
+
return this.httpGet(
|
|
1188
|
+
`${this.path(idOrganization, idPublication)}/metrics`,
|
|
1189
|
+
void 0,
|
|
1190
|
+
options
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* La evolución medida de una publicación: una fila por día, más su última medición.
|
|
1195
|
+
*
|
|
1196
|
+
* Es lectura pura de lo guardado: mirar la gráfica no llama a la red y no cuesta créditos. Una
|
|
1197
|
+
* `series` vacía es una respuesta válida —recién enviada, o una red sin estadísticas—, no un
|
|
1198
|
+
* error. Cada `metrics` es el ACUMULADO a esa fecha, no el incremento del día.
|
|
1199
|
+
*/
|
|
1200
|
+
async stats(idOrganization, idPublication, options = {}) {
|
|
1201
|
+
return this.httpGet(
|
|
1202
|
+
`${this.path(idOrganization, idPublication)}/stats`,
|
|
1203
|
+
void 0,
|
|
1204
|
+
options
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Lo que hay publicado en el muro de la cuenta **según la red**, no según PlanVortex: incluye
|
|
1209
|
+
* lo que se publicó por fuera.
|
|
1210
|
+
*
|
|
1211
|
+
* **En X cuesta un crédito por elemento leído**, así que el `limit` es dinero.
|
|
1212
|
+
*/
|
|
1213
|
+
async listOnNetwork(idOrganization, idAccount, options = {}) {
|
|
1214
|
+
return this.getList(
|
|
1215
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/accounts/${requireId(idAccount, "idAccount")}/social_publications`,
|
|
1216
|
+
"publications",
|
|
1217
|
+
{ offset: options.offset, limit: options.limit },
|
|
1218
|
+
options
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
path(idOrganization, idPublication) {
|
|
1222
|
+
return `/organizations/${requireId(idOrganization, "idOrganization")}/publish/${requireId(idPublication, "idPublication")}`;
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
function listQuery2(options) {
|
|
1226
|
+
return {
|
|
1227
|
+
offset: options.offset,
|
|
1228
|
+
limit: options.limit,
|
|
1229
|
+
from_date: options.from_date,
|
|
1230
|
+
to_date: options.to_date,
|
|
1231
|
+
search: options.search,
|
|
1232
|
+
//El servidor lo lee como el literal "true": un `false` no cambia nada, así que se omite.
|
|
1233
|
+
orderByPublish: options.orderByPublish ? true : void 0,
|
|
1234
|
+
state: options.state,
|
|
1235
|
+
accounts: options.accounts,
|
|
1236
|
+
social_network: options.social_network
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
function serializeInput(body) {
|
|
1240
|
+
const { publish_date, ...rest } = body;
|
|
1241
|
+
return {
|
|
1242
|
+
...rest,
|
|
1243
|
+
...publish_date === void 0 ? {} : { publish_date: typeof publish_date === "string" ? publish_date : publish_date.toISOString() }
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
var MIME_BY_EXTENSION = {
|
|
1247
|
+
jpg: "image/jpeg",
|
|
1248
|
+
jpeg: "image/jpeg",
|
|
1249
|
+
png: "image/png",
|
|
1250
|
+
gif: "image/gif",
|
|
1251
|
+
heic: "image/heic",
|
|
1252
|
+
heif: "image/heif",
|
|
1253
|
+
mp4: "video/mp4"
|
|
1254
|
+
};
|
|
1255
|
+
function guessContentType(filename) {
|
|
1256
|
+
const extension = filename.split(".").pop()?.toLowerCase();
|
|
1257
|
+
return extension ? MIME_BY_EXTENSION[extension] : void 0;
|
|
1258
|
+
}
|
|
1259
|
+
function isBinary(value) {
|
|
1260
|
+
return value instanceof Uint8Array;
|
|
1261
|
+
}
|
|
1262
|
+
async function toFilePart(input) {
|
|
1263
|
+
const { file } = input;
|
|
1264
|
+
if (typeof file === "string") {
|
|
1265
|
+
const filename = input.filename ?? path.basename(file);
|
|
1266
|
+
const type = input.contentType ?? guessContentType(filename);
|
|
1267
|
+
if (!type) {
|
|
1268
|
+
throw new PlanVortexConfigError(
|
|
1269
|
+
`No se puede deducir el tipo de "${filename}": pasa contentType. El servidor decide si es imagen o v\xEDdeo por el tipo que mandes, no por el contenido.`
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
return { blob: await openFileAsBlob(file, type), filename };
|
|
1273
|
+
}
|
|
1274
|
+
if (isBinary(file)) {
|
|
1275
|
+
const filename = input.filename;
|
|
1276
|
+
if (!filename) {
|
|
1277
|
+
throw new PlanVortexConfigError("Un Buffer no lleva nombre: pasa filename junto al fichero.");
|
|
1278
|
+
}
|
|
1279
|
+
const type = input.contentType ?? guessContentType(filename);
|
|
1280
|
+
if (!type) {
|
|
1281
|
+
throw new PlanVortexConfigError(
|
|
1282
|
+
`No se puede deducir el tipo de "${filename}": pasa contentType.`
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
return { blob: new Blob([file.slice()], { type }), filename };
|
|
1286
|
+
}
|
|
1287
|
+
if (file instanceof Blob) {
|
|
1288
|
+
const filename = input.filename ?? (file instanceof File ? file.name : void 0);
|
|
1289
|
+
if (!filename) {
|
|
1290
|
+
throw new PlanVortexConfigError("Un Blob sin nombre: pasa filename junto al fichero.");
|
|
1291
|
+
}
|
|
1292
|
+
const type = input.contentType ?? (file.type || guessContentType(filename));
|
|
1293
|
+
if (!type) {
|
|
1294
|
+
throw new PlanVortexConfigError(
|
|
1295
|
+
`No se puede deducir el tipo de "${filename}": pasa contentType.`
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
return { blob: file.type === type ? file : file.slice(0, file.size, type), filename };
|
|
1299
|
+
}
|
|
1300
|
+
throw new PlanVortexConfigError("El fichero tiene que ser una ruta, un Buffer o un Blob.");
|
|
1301
|
+
}
|
|
1302
|
+
async function buildUploadForm(input) {
|
|
1303
|
+
const { blob, filename } = await toFilePart(input);
|
|
1304
|
+
const form = new FormData();
|
|
1305
|
+
form.append("file", blob, filename);
|
|
1306
|
+
return form;
|
|
1307
|
+
}
|
|
1308
|
+
async function openFileAsBlob(path, type) {
|
|
1309
|
+
const fs = await import('fs');
|
|
1310
|
+
const openAsBlob = fs.openAsBlob;
|
|
1311
|
+
if (typeof openAsBlob === "function") {
|
|
1312
|
+
return openAsBlob(path, { type });
|
|
1313
|
+
}
|
|
1314
|
+
const { readFile } = await import('fs/promises');
|
|
1315
|
+
return new Blob([await readFile(path)], { type });
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// src/resources/uploads.ts
|
|
1319
|
+
var UploadsResource = class extends Resource {
|
|
1320
|
+
/**
|
|
1321
|
+
* Sube un fichero. Admite una ruta en disco, un `Buffer` o un `Blob`.
|
|
1322
|
+
*
|
|
1323
|
+
* La ruta es la buena para lo grande: es la única forma que no pasa el fichero por memoria.
|
|
1324
|
+
*
|
|
1325
|
+
* ```ts
|
|
1326
|
+
* await pv.uploads.create(orgId, { file: "./hogaza.jpg" });
|
|
1327
|
+
* await pv.uploads.create(orgId, { file: bytes, filename: "hogaza.jpg" });
|
|
1328
|
+
* ```
|
|
1329
|
+
*/
|
|
1330
|
+
async create(idOrganization, input, options = {}) {
|
|
1331
|
+
const form = await buildUploadForm(input);
|
|
1332
|
+
return this.postOne(
|
|
1333
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/uploads`,
|
|
1334
|
+
"upload",
|
|
1335
|
+
form,
|
|
1336
|
+
options
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Los ficheros de la biblioteca.
|
|
1341
|
+
*
|
|
1342
|
+
* No salen aquí los recortes que la plataforma se hace para sí misma (`is_temporal`) ni las
|
|
1343
|
+
* portadas de vídeo, que son otro upload y viajan dentro del suyo.
|
|
1344
|
+
*/
|
|
1345
|
+
async list(idOrganization, options = {}) {
|
|
1346
|
+
return this.getList(
|
|
1347
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/uploads`,
|
|
1348
|
+
"uploads",
|
|
1349
|
+
{ offset: options.offset, limit: options.limit },
|
|
1350
|
+
options
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
/** Los ficheros de la biblioteca, encadenando páginas. */
|
|
1354
|
+
iterate(idOrganization, options = {}) {
|
|
1355
|
+
return iteratePages((page) => this.list(idOrganization, { ...options, ...page }), options);
|
|
1356
|
+
}
|
|
1357
|
+
/** Un fichero. Pídelo de nuevo cuando necesites un `public_path` vigente. */
|
|
1358
|
+
async get(idOrganization, idUpload, options = {}) {
|
|
1359
|
+
return this.getOne(this.path(idOrganization, idUpload), "upload", void 0, options);
|
|
1360
|
+
}
|
|
1361
|
+
/** Cambia la portada de un vídeo. Ojo con `cover_offset`: se escribe siempre (ver arriba). */
|
|
1362
|
+
async update(idOrganization, idUpload, body, options = {}) {
|
|
1363
|
+
return this.putOne(this.path(idOrganization, idUpload), "upload", body, options);
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Borra un fichero.
|
|
1367
|
+
*
|
|
1368
|
+
* Con `force` se borra aunque una publicación siga apuntándolo; sin él, un fichero en uso se
|
|
1369
|
+
* conserva y sólo se saca de la biblioteca.
|
|
1370
|
+
*/
|
|
1371
|
+
async remove(idOrganization, idUpload, options = {}) {
|
|
1372
|
+
await this.httpDelete(
|
|
1373
|
+
this.path(idOrganization, idUpload),
|
|
1374
|
+
options.force ? { forceDelete: true } : void 0,
|
|
1375
|
+
options
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Trae a la biblioteca ficheros elegidos en una integración (el selector de Drive).
|
|
1380
|
+
*
|
|
1381
|
+
* La respuesta es PARCIAL a propósito: mira `errors` aunque `uploads` traiga algo — de seis
|
|
1382
|
+
* ficheros pueden entrar cuatro. Un `2204` significa que el proveedor no da los bytes: un
|
|
1383
|
+
* documento nativo de Google no tiene fichero que descargar.
|
|
1384
|
+
*/
|
|
1385
|
+
async import(idOrganization, idIntegration, files, options = {}) {
|
|
1386
|
+
return this.httpPost(
|
|
1387
|
+
`/organizations/${requireId(idOrganization, "idOrganization")}/uploads/import`,
|
|
1388
|
+
{ id_integration: requireId(idIntegration, "idIntegration"), files },
|
|
1389
|
+
options
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
path(idOrganization, idUpload) {
|
|
1393
|
+
return `/organizations/${requireId(idOrganization, "idOrganization")}/uploads/${requireId(idUpload, "idUpload")}`;
|
|
1394
|
+
}
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
// src/client.ts
|
|
1398
|
+
function buildUserAgent() {
|
|
1399
|
+
const runtime = typeof process !== "undefined" && process.versions?.node ? ` node/${process.versions.node}` : "";
|
|
1400
|
+
return `planvortex-node/${VERSION}${runtime}`;
|
|
1401
|
+
}
|
|
1402
|
+
function readEnv(name) {
|
|
1403
|
+
return typeof process !== "undefined" ? process.env?.[name] : void 0;
|
|
1404
|
+
}
|
|
1405
|
+
function looksLikeBrowser() {
|
|
1406
|
+
const maybeWindow = globalThis.window;
|
|
1407
|
+
return maybeWindow !== void 0 && maybeWindow.document !== void 0;
|
|
1408
|
+
}
|
|
1409
|
+
var PlanVortex = class _PlanVortex {
|
|
1410
|
+
/** Sin barra final. Útil para componer una URL a mano cuando haga falta. */
|
|
1411
|
+
baseUrl;
|
|
1412
|
+
/** Metadatos estáticos de las redes: qué hay, qué sabe hacer cada una y sus límites. Cacheado. */
|
|
1413
|
+
catalog;
|
|
1414
|
+
/** Clientes: el plan contratado y sus organizaciones raíz. */
|
|
1415
|
+
clients;
|
|
1416
|
+
/** Organizaciones: la ficha, las hijas, y el cupo que tienen y gastan. */
|
|
1417
|
+
organizations;
|
|
1418
|
+
/** Cuentas sociales conectadas. Conectar una es otra cosa: hace falta una persona (§ fase 9). */
|
|
1419
|
+
accounts;
|
|
1420
|
+
/** La biblioteca de ficheros de una organización. */
|
|
1421
|
+
uploads;
|
|
1422
|
+
/** Publicaciones: crear, programar, reintentar y medir. */
|
|
1423
|
+
publications;
|
|
1424
|
+
http;
|
|
1425
|
+
auth;
|
|
1426
|
+
options;
|
|
1427
|
+
constructor(options = {}) {
|
|
1428
|
+
if (looksLikeBrowser() && options.dangerouslyAllowBrowser !== true) {
|
|
1429
|
+
throw new PlanVortexConfigError(
|
|
1430
|
+
"planvortex es un paquete de servidor: el client_secret no puede vivir en un navegador. Para conectar una cuenta desde el front usa el temporal_connect_token."
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
this.options = options;
|
|
1434
|
+
this.baseUrl = (options.baseUrl ?? readEnv("PLANVORTEX_BASE_URL") ?? PLANVORTEX_API_URL).replace(
|
|
1435
|
+
/\/+$/,
|
|
1436
|
+
""
|
|
1437
|
+
);
|
|
1438
|
+
this.http = new HttpClient({
|
|
1439
|
+
baseUrl: this.baseUrl,
|
|
1440
|
+
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
|
|
1441
|
+
...options.retry === void 0 ? {} : { retry: options.retry },
|
|
1442
|
+
...options.hooks === void 0 ? {} : { hooks: options.hooks },
|
|
1443
|
+
...options.fetch === void 0 ? {} : { fetch: options.fetch },
|
|
1444
|
+
headers: { "user-agent": buildUserAgent() }
|
|
1445
|
+
});
|
|
1446
|
+
const clientId = options.clientId ?? readEnv("PLANVORTEX_CLIENT_ID");
|
|
1447
|
+
const clientSecret = options.clientSecret ?? readEnv("PLANVORTEX_CLIENT_SECRET");
|
|
1448
|
+
if (options.accessToken) {
|
|
1449
|
+
this.auth = new StaticTokenAuth(options.accessToken);
|
|
1450
|
+
} else if (clientId && clientSecret) {
|
|
1451
|
+
this.auth = new ClientCredentialsAuth(this.http, {
|
|
1452
|
+
clientId,
|
|
1453
|
+
clientSecret,
|
|
1454
|
+
scope: options.scope
|
|
1455
|
+
});
|
|
1456
|
+
} else {
|
|
1457
|
+
throw new PlanVortexConfigError(
|
|
1458
|
+
"Faltan credenciales: pasa clientId y clientSecret (o PLANVORTEX_CLIENT_ID y PLANVORTEX_CLIENT_SECRET en el entorno), o un accessToken."
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
this.catalog = new CatalogResource(this);
|
|
1462
|
+
this.clients = new ClientsResource(this);
|
|
1463
|
+
this.organizations = new OrganizationsResource(this);
|
|
1464
|
+
this.accounts = new AccountsResource(this);
|
|
1465
|
+
this.uploads = new UploadsResource(this);
|
|
1466
|
+
this.publications = new PublicationsResource(this);
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Una petición autenticada. Es lo que usarán los recursos de las fases 6 y 7; un integrador no
|
|
1470
|
+
* debería necesitarla, pero está expuesta para no dejar a nadie tirado ante un endpoint que la
|
|
1471
|
+
* librería aún no cubra.
|
|
1472
|
+
*
|
|
1473
|
+
* Reintenta **una sola vez** ante los códigos de token (501 y 522), que llegan dentro de un 400.
|
|
1474
|
+
* Un token puede morir antes de su `expires_in` —un despliegue de Keycloak, la app revocada— y
|
|
1475
|
+
* ese caso se arregla pidiendo otro; si el segundo también falla, el error sale.
|
|
1476
|
+
*/
|
|
1477
|
+
async request(request) {
|
|
1478
|
+
try {
|
|
1479
|
+
return await this.send(request);
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
if (!isTokenError(error)) {
|
|
1482
|
+
throw error;
|
|
1483
|
+
}
|
|
1484
|
+
this.auth.invalidate();
|
|
1485
|
+
return this.send(request);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* El mismo cliente, con la misma forma, autenticado con un token temporal de conexión.
|
|
1490
|
+
*
|
|
1491
|
+
* Es la pieza del flujo de conexión de cuentas (§ trampa 2): una app **no** puede conectar una
|
|
1492
|
+
* cuenta de Instagram —eso es un OAuth con una persona delante—, así que emite un token
|
|
1493
|
+
* temporal, se lo pasa a su usuario y con él se piden los `connect_links`. El token va atado a
|
|
1494
|
+
* una sola organización: usarlo contra otra devuelve el error 1101.
|
|
1495
|
+
*/
|
|
1496
|
+
asTemporalToken(token) {
|
|
1497
|
+
return new _PlanVortex({
|
|
1498
|
+
...this.options,
|
|
1499
|
+
clientId: void 0,
|
|
1500
|
+
clientSecret: void 0,
|
|
1501
|
+
accessToken: token,
|
|
1502
|
+
baseUrl: this.baseUrl
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
async send(request) {
|
|
1506
|
+
const token = await this.auth.getToken();
|
|
1507
|
+
return this.http.request({
|
|
1508
|
+
...request,
|
|
1509
|
+
headers: { ...request.headers, authorization: `Bearer ${token}` }
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
// src/types.ts
|
|
1515
|
+
function accountId(publication) {
|
|
1516
|
+
const value = publication.id_account;
|
|
1517
|
+
return typeof value === "string" ? value : value._id;
|
|
1518
|
+
}
|
|
1519
|
+
function account(publication) {
|
|
1520
|
+
const value = publication.id_account;
|
|
1521
|
+
return typeof value === "string" ? void 0 : value;
|
|
1522
|
+
}
|
|
1523
|
+
function messageDirection(message) {
|
|
1524
|
+
if (message.from_contact_id) {
|
|
1525
|
+
return "incoming";
|
|
1526
|
+
}
|
|
1527
|
+
return message.contact_id ? "outgoing" : "unknown";
|
|
1528
|
+
}
|
|
1529
|
+
function messageContactId(message) {
|
|
1530
|
+
const value = message.from_contact_id ?? message.contact_id;
|
|
1531
|
+
if (value === void 0) {
|
|
1532
|
+
return void 0;
|
|
1533
|
+
}
|
|
1534
|
+
return typeof value === "string" ? value : value._id;
|
|
1535
|
+
}
|
|
1536
|
+
function messageContact(message) {
|
|
1537
|
+
const value = message.from_contact_id ?? message.contact_id;
|
|
1538
|
+
return value === void 0 || typeof value === "string" ? void 0 : value;
|
|
1539
|
+
}
|
|
1540
|
+
function messageFiles(message) {
|
|
1541
|
+
const files = message.message_options?.files ?? [];
|
|
1542
|
+
return files.filter((file) => typeof file !== "string");
|
|
1543
|
+
}
|
|
1544
|
+
function messageFileIds(message) {
|
|
1545
|
+
const files = message.message_options?.files ?? [];
|
|
1546
|
+
return files.map((file) => typeof file === "string" ? file : file._id);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
exports.AccountError = AccountError;
|
|
1550
|
+
exports.AccountsResource = AccountsResource;
|
|
1551
|
+
exports.AiPlanError = AiPlanError;
|
|
1552
|
+
exports.AuthError = AuthError;
|
|
1553
|
+
exports.CatalogResource = CatalogResource;
|
|
1554
|
+
exports.ClientsResource = ClientsResource;
|
|
1555
|
+
exports.ContactError = ContactError;
|
|
1556
|
+
exports.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
|
1557
|
+
exports.DEFAULT_RETRY = DEFAULT_RETRY;
|
|
1558
|
+
exports.DEFAULT_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
|
1559
|
+
exports.FileError = FileError;
|
|
1560
|
+
exports.IntegrationError = IntegrationError;
|
|
1561
|
+
exports.MAX_PAGES = MAX_PAGES;
|
|
1562
|
+
exports.MessagingError = MessagingError;
|
|
1563
|
+
exports.NO_ERROR_CODE = NO_ERROR_CODE;
|
|
1564
|
+
exports.OrganizationError = OrganizationError;
|
|
1565
|
+
exports.OrganizationsResource = OrganizationsResource;
|
|
1566
|
+
exports.PLANVORTEX_API_URL = PLANVORTEX_API_URL;
|
|
1567
|
+
exports.PLANVORTEX_ERROR_RANGES = PLANVORTEX_ERROR_RANGES;
|
|
1568
|
+
exports.PlanLimitError = PlanLimitError;
|
|
1569
|
+
exports.PlanVortex = PlanVortex;
|
|
1570
|
+
exports.PlanVortexAuthenticationError = PlanVortexAuthenticationError;
|
|
1571
|
+
exports.PlanVortexConfigError = PlanVortexConfigError;
|
|
1572
|
+
exports.PlanVortexConnectionError = PlanVortexConnectionError;
|
|
1573
|
+
exports.PlanVortexError = PlanVortexError;
|
|
1574
|
+
exports.ProductError = ProductError;
|
|
1575
|
+
exports.PublicationError = PublicationError;
|
|
1576
|
+
exports.PublicationsResource = PublicationsResource;
|
|
1577
|
+
exports.TOKEN_ERROR_CODES = TOKEN_ERROR_CODES;
|
|
1578
|
+
exports.TOKEN_REFRESH_MARGIN_MS = TOKEN_REFRESH_MARGIN_MS;
|
|
1579
|
+
exports.UploadsResource = UploadsResource;
|
|
1580
|
+
exports.UserError = UserError;
|
|
1581
|
+
exports.VERSION = VERSION;
|
|
1582
|
+
exports.account = account;
|
|
1583
|
+
exports.accountId = accountId;
|
|
1584
|
+
exports.errorFamilyForCode = errorFamilyForCode;
|
|
1585
|
+
exports.guessContentType = guessContentType;
|
|
1586
|
+
exports.isPlanVortexError = isPlanVortexError;
|
|
1587
|
+
exports.isTokenError = isTokenError;
|
|
1588
|
+
exports.messageContact = messageContact;
|
|
1589
|
+
exports.messageContactId = messageContactId;
|
|
1590
|
+
exports.messageDirection = messageDirection;
|
|
1591
|
+
exports.messageFileIds = messageFileIds;
|
|
1592
|
+
exports.messageFiles = messageFiles;
|
|
1593
|
+
//# sourceMappingURL=index.cjs.map
|
|
1594
|
+
//# sourceMappingURL=index.cjs.map
|