backend-manager 5.2.19 → 5.3.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/CHANGELOG.md +19 -0
- package/CLAUDE.md +5 -4
- package/docs/admin-post-route.md +2 -0
- package/docs/ai-library.md +29 -2
- package/docs/common-mistakes.md +1 -1
- package/docs/environment-detection.md +87 -3
- package/docs/marketing-campaigns.md +1 -1
- package/docs/payment-system.md +1 -1
- package/docs/testing.md +60 -15
- package/package.json +1 -1
- package/src/cli/commands/install.js +2 -2
- package/src/cli/commands/setup.js +12 -4
- package/src/cli/commands/test.js +1 -1
- package/src/cli/index.js +6 -2
- package/src/defaults/CLAUDE.md +2 -2
- package/src/defaults/test/_init.js +14 -0
- package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
- package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
- package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/user/delete.js +1 -1
- package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
- package/src/manager/helpers/analytics.js +3 -1
- package/src/manager/helpers/assistant.js +43 -38
- package/src/manager/index.js +76 -12
- package/src/manager/libraries/ai/index.js +33 -0
- package/src/manager/libraries/ai/providers/openai.js +105 -8
- package/src/manager/libraries/content/ghostii.js +76 -16
- package/src/manager/libraries/email/data/disposable-domains.json +23 -0
- package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
- package/src/manager/libraries/email/generators/newsletter.js +16 -2
- package/src/manager/libraries/payment/discount-codes.js +1 -0
- package/src/manager/routes/admin/post/put.js +1 -1
- package/src/manager/routes/handler/post/post.js +1 -1
- package/src/manager/routes/payments/intent/processors/test.js +1 -1
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/signup/post.js +4 -2
- package/src/test/runner.js +142 -20
- package/src/test/test-accounts.js +159 -102
- package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
- package/test/events/payments/journey-payments-cancel.js +6 -3
- package/test/events/payments/journey-payments-failure.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +6 -3
- package/test/events/payments/journey-payments-refund-webhook.js +6 -2
- package/test/events/payments/journey-payments-suspend.js +6 -3
- package/test/events/payments/journey-payments-trial-cancel.js +6 -3
- package/test/events/payments/journey-payments-trial.js +10 -4
- package/test/events/payments/journey-payments-uid-resolution.js +6 -2
- package/test/events/payments/journey-payments-upgrade.js +6 -3
- package/test/helpers/content/ghostii-blocks.js +134 -0
- package/test/helpers/environment.js +230 -0
- package/test/routes/marketing/webhook-forward.js +14 -7
- package/test/routes/payments/cancel.js +4 -1
- package/test/routes/payments/dispute-alert.js +9 -6
- package/test/routes/payments/intent.js +55 -14
- package/test/routes/payments/portal.js +4 -1
- package/test/routes/payments/refund.js +4 -1
|
@@ -56,11 +56,13 @@ function tryUrl(self) {
|
|
|
56
56
|
const host = req.get('host');
|
|
57
57
|
const forwardedHost = req.get('x-forwarded-host');
|
|
58
58
|
const path = req.path;
|
|
59
|
-
const functionsUrl = Manager.
|
|
59
|
+
const functionsUrl = Manager.getFunctionsUrl();
|
|
60
60
|
|
|
61
61
|
// Like this becuse "req.originalUrl" does NOT have path for all cases (like when calling https://us-central1-{id}.cloudfunctions.net/giftImport)
|
|
62
62
|
if (projectType === 'firebase') {
|
|
63
|
-
|
|
63
|
+
// Non-production (development OR testing) reconstructs the URL from the local
|
|
64
|
+
// functions URL; production uses the request host.
|
|
65
|
+
if (!self.isProduction()) {
|
|
64
66
|
return forwardedHost
|
|
65
67
|
? `${protocol}://${forwardedHost}${path}`
|
|
66
68
|
: `${functionsUrl}/${self.meta.name}`;
|
|
@@ -116,6 +118,26 @@ BackendAssistant.prototype.init = function (ref, options) {
|
|
|
116
118
|
self.settings = null;
|
|
117
119
|
self.schema = null;
|
|
118
120
|
|
|
121
|
+
// Set ref FIRST so self.Manager is available below — meta.environment forwards to the
|
|
122
|
+
// Manager's canonical getEnvironment() (the Manager is the SSOT), so the Manager ref must
|
|
123
|
+
// be wired before we resolve the environment.
|
|
124
|
+
ref = ref || {};
|
|
125
|
+
|
|
126
|
+
// An assistant has no independent identity — it's a request-scoped face for its Manager,
|
|
127
|
+
// and forwards environment/url resolution to it. A Manager ref is REQUIRED; constructing
|
|
128
|
+
// one without it is a programming error (use Manager.Assistant(), which injects it).
|
|
129
|
+
if (!ref.Manager || typeof ref.Manager.getEnvironment !== 'function') {
|
|
130
|
+
throw new Error('BackendAssistant.init(): a Manager reference is required (ref.Manager). Construct via Manager.Assistant() so it is injected automatically.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
self.ref = {};
|
|
134
|
+
self.ref.req = ref.req || {};
|
|
135
|
+
self.ref.res = ref.res || {};
|
|
136
|
+
self.ref.admin = ref.admin || {};
|
|
137
|
+
self.ref.functions = ref.functions || {};
|
|
138
|
+
self.ref.Manager = ref.Manager;
|
|
139
|
+
self.Manager = self.ref.Manager;
|
|
140
|
+
|
|
119
141
|
// Set meta
|
|
120
142
|
self.meta = {};
|
|
121
143
|
|
|
@@ -127,16 +149,6 @@ BackendAssistant.prototype.init = function (ref, options) {
|
|
|
127
149
|
self.meta.environment = options.environment || self.getEnvironment();
|
|
128
150
|
self.meta.type = options.functionType || process.env.FUNCTION_SIGNATURE_TYPE || 'unknown';
|
|
129
151
|
|
|
130
|
-
// Set ref
|
|
131
|
-
self.ref = {};
|
|
132
|
-
ref = ref || {};
|
|
133
|
-
self.ref.req = ref.req || {};
|
|
134
|
-
self.ref.res = ref.res || {};
|
|
135
|
-
self.ref.admin = ref.admin || {};
|
|
136
|
-
self.ref.functions = ref.functions || {};
|
|
137
|
-
self.ref.Manager = ref.Manager || {};
|
|
138
|
-
self.Manager = self.ref.Manager;
|
|
139
|
-
|
|
140
152
|
// Set ID
|
|
141
153
|
try {
|
|
142
154
|
const headers = self?.ref?.req.headers || {};
|
|
@@ -274,39 +286,31 @@ BackendAssistant.prototype.init = function (ref, options) {
|
|
|
274
286
|
return self;
|
|
275
287
|
};
|
|
276
288
|
|
|
289
|
+
// Environment helpers — the Manager is the SINGLE SOURCE OF TRUTH (see index.js). The
|
|
290
|
+
// assistant is a request-scoped face for its Manager and FORWARDS these straight through,
|
|
291
|
+
// so request handlers can call `assistant.getEnvironment()` / `assistant.isTesting()` and
|
|
292
|
+
// get exactly the same answer as `Manager.getEnvironment()`. No duplicated env-var logic
|
|
293
|
+
// lives here — there is one implementation, on the Manager. A Manager ref is guaranteed by
|
|
294
|
+
// init() (it throws otherwise), so these never need a fallback.
|
|
295
|
+
//
|
|
296
|
+
// Returns exactly ONE of 'development' | 'testing' | 'production' (mutually exclusive,
|
|
297
|
+
// testing wins). isDevelopment() is NOT true in testing; isProduction() is a real positive
|
|
298
|
+
// check (never `!isDevelopment()`). Gate "anything non-production" with `!isProduction()`
|
|
299
|
+
// or `isDevelopment() || isTesting()` intentionally.
|
|
277
300
|
BackendAssistant.prototype.getEnvironment = function () {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
return 'production';
|
|
281
|
-
} else if (
|
|
282
|
-
process.env.ENVIRONMENT === 'development'
|
|
283
|
-
|| process.env.FUNCTIONS_EMULATOR === true
|
|
284
|
-
|| process.env.FUNCTIONS_EMULATOR === 'true'
|
|
285
|
-
|| process.env.TERM_PROGRAM === 'Apple_Terminal'
|
|
286
|
-
|| process.env.TERM_PROGRAM === 'vscode'
|
|
287
|
-
) {
|
|
288
|
-
return 'development';
|
|
289
|
-
} else {
|
|
290
|
-
return 'production'
|
|
291
|
-
}
|
|
292
|
-
};
|
|
301
|
+
return this.Manager.getEnvironment();
|
|
302
|
+
}
|
|
293
303
|
|
|
294
304
|
BackendAssistant.prototype.isDevelopment = function () {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return self.meta.environment === 'development';
|
|
305
|
+
return this.Manager.isDevelopment();
|
|
298
306
|
}
|
|
299
307
|
|
|
300
308
|
BackendAssistant.prototype.isProduction = function () {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return self.meta.environment === 'production';
|
|
309
|
+
return this.Manager.isProduction();
|
|
304
310
|
}
|
|
305
311
|
|
|
306
312
|
BackendAssistant.prototype.isTesting = function () {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
return process.env.BEM_TESTING === 'true';
|
|
313
|
+
return this.Manager.isTesting();
|
|
310
314
|
}
|
|
311
315
|
|
|
312
316
|
BackendAssistant.prototype.logProd = function () {
|
|
@@ -330,10 +334,11 @@ BackendAssistant.prototype._log = function () {
|
|
|
330
334
|
if (LOG_LEVELS[level]) {
|
|
331
335
|
logs.splice(1, 1);
|
|
332
336
|
|
|
333
|
-
// Determine how to log
|
|
337
|
+
// Determine how to log. Console for any non-production environment (development OR
|
|
338
|
+
// testing); the Firebase Cloud logger only in production.
|
|
334
339
|
if (level in console) {
|
|
335
340
|
console[level].apply(console, logs);
|
|
336
|
-
} else if (self.
|
|
341
|
+
} else if (!self.isProduction()) {
|
|
337
342
|
console.log.apply(console, logs);
|
|
338
343
|
} else {
|
|
339
344
|
self.ref.functions.logger.write({
|
package/src/manager/index.js
CHANGED
|
@@ -179,6 +179,54 @@ Manager.prototype.init = function (exporter, options) {
|
|
|
179
179
|
});
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
// Environment helpers — the Manager is the SINGLE SOURCE OF TRUTH (mirrors EM/UJM/BXM,
|
|
183
|
+
// where the Manager owns these). getEnvironment() is the ONLY place that reads the raw
|
|
184
|
+
// env vars (BEM_TESTING / ENVIRONMENT / FUNCTIONS_EMULATOR / TERM_PROGRAM); the three
|
|
185
|
+
// is*() checks derive from it live on every call. They return exactly ONE of three
|
|
186
|
+
// mutually-exclusive values — testing wins, then production, else development.
|
|
187
|
+
// The assistant exposes the same methods but FORWARDS to these (assistant.isTesting()
|
|
188
|
+
// → Manager.isTesting()), so request handlers can keep calling `assistant.*`.
|
|
189
|
+
// Defined BEFORE the assistant is constructed so the assistant's own init() can call back.
|
|
190
|
+
self.getEnvironment = function() {
|
|
191
|
+
// Testing takes precedence — set by the test runner / emulator (BEM_TESTING=true).
|
|
192
|
+
if (process.env.BEM_TESTING === 'true') {
|
|
193
|
+
return 'testing';
|
|
194
|
+
}
|
|
195
|
+
if (process.env.ENVIRONMENT === 'production') {
|
|
196
|
+
return 'production';
|
|
197
|
+
} else if (
|
|
198
|
+
process.env.ENVIRONMENT === 'development'
|
|
199
|
+
|| process.env.FUNCTIONS_EMULATOR === true
|
|
200
|
+
|| process.env.FUNCTIONS_EMULATOR === 'true'
|
|
201
|
+
|| process.env.TERM_PROGRAM === 'Apple_Terminal'
|
|
202
|
+
|| process.env.TERM_PROGRAM === 'vscode'
|
|
203
|
+
) {
|
|
204
|
+
return 'development';
|
|
205
|
+
} else {
|
|
206
|
+
// Default: production. BEM's deployed RUNTIME can legitimately lack a dev signal — a
|
|
207
|
+
// live Cloud Function has no FUNCTIONS_EMULATOR and often no ENVIRONMENT var, so
|
|
208
|
+
// "no signal" IS the normal production state. Defaulting to development here would make
|
|
209
|
+
// every deployed function skip real side effects (emails/analytics/webhooks).
|
|
210
|
+
// (Contrast UJM/BXM, whose deployed artifacts always carry their signal, so they default
|
|
211
|
+
// to development — a bare context there is just build tooling.)
|
|
212
|
+
return 'production';
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// The three checks are mutually exclusive — each true for ONLY its own environment.
|
|
217
|
+
// isDevelopment() is NOT true in testing; isProduction() is a real positive check
|
|
218
|
+
// (never `!isDevelopment()`). Gate "anything non-production" with `!isProduction()`
|
|
219
|
+
// or `isDevelopment() || isTesting()` intentionally.
|
|
220
|
+
self.isDevelopment = function() {
|
|
221
|
+
return self.getEnvironment() === 'development';
|
|
222
|
+
};
|
|
223
|
+
self.isProduction = function() {
|
|
224
|
+
return self.getEnvironment() === 'production';
|
|
225
|
+
};
|
|
226
|
+
self.isTesting = function() {
|
|
227
|
+
return self.getEnvironment() === 'testing';
|
|
228
|
+
};
|
|
229
|
+
|
|
182
230
|
// Init assistant
|
|
183
231
|
self.assistant = self.Assistant().init({
|
|
184
232
|
req: null,
|
|
@@ -190,21 +238,33 @@ Manager.prototype.init = function (exporter, options) {
|
|
|
190
238
|
|
|
191
239
|
// Helper functions for URLs based on environment
|
|
192
240
|
self.getFunctionsUrl = function(env) {
|
|
193
|
-
|
|
241
|
+
// Auto-detect (no explicit env): development OR testing → local URLs. Test runs
|
|
242
|
+
// hit the local emulator, so getApiUrl/getFunctionsUrl/getWebsiteUrl must resolve
|
|
243
|
+
// to localhost. NOTE: getParentApiUrl/getParentUrl are intentionally NOT changed —
|
|
244
|
+
// the parent is a real remote server with no localhost equivalent.
|
|
245
|
+
const isDev = env === 'development' || (!env && (self.isDevelopment() || self.isTesting()));
|
|
194
246
|
return isDev
|
|
195
247
|
? `http://localhost:5001/${self.project.projectId}/${self.project.resourceZone}`
|
|
196
248
|
: `https://${self.project.resourceZone}-${self.project.projectId}.cloudfunctions.net`;
|
|
197
249
|
};
|
|
198
250
|
|
|
199
251
|
self.getApiUrl = function(env) {
|
|
200
|
-
|
|
252
|
+
// Auto-detect (no explicit env): development OR testing → local URLs. Test runs
|
|
253
|
+
// hit the local emulator, so getApiUrl/getFunctionsUrl/getWebsiteUrl must resolve
|
|
254
|
+
// to localhost. NOTE: getParentApiUrl/getParentUrl are intentionally NOT changed —
|
|
255
|
+
// the parent is a real remote server with no localhost equivalent.
|
|
256
|
+
const isDev = env === 'development' || (!env && (self.isDevelopment() || self.isTesting()));
|
|
201
257
|
return isDev
|
|
202
258
|
? 'http://localhost:5002'
|
|
203
259
|
: `https://api.${(self.config.brand?.url || '').replace(/^https?:\/\//, '')}`;
|
|
204
260
|
};
|
|
205
261
|
|
|
206
262
|
self.getWebsiteUrl = function(env) {
|
|
207
|
-
|
|
263
|
+
// Auto-detect (no explicit env): development OR testing → local URLs. Test runs
|
|
264
|
+
// hit the local emulator, so getApiUrl/getFunctionsUrl/getWebsiteUrl must resolve
|
|
265
|
+
// to localhost. NOTE: getParentApiUrl/getParentUrl are intentionally NOT changed —
|
|
266
|
+
// the parent is a real remote server with no localhost equivalent.
|
|
267
|
+
const isDev = env === 'development' || (!env && (self.isDevelopment() || self.isTesting()));
|
|
208
268
|
return isDev
|
|
209
269
|
? 'https://localhost:4000'
|
|
210
270
|
: self.config.brand?.url || '';
|
|
@@ -254,7 +314,7 @@ Manager.prototype.init = function (exporter, options) {
|
|
|
254
314
|
self.project.websiteUrl = self.getWebsiteUrl();
|
|
255
315
|
|
|
256
316
|
// Set environment
|
|
257
|
-
process.env.ENVIRONMENT = process.env.ENVIRONMENT || self.
|
|
317
|
+
process.env.ENVIRONMENT = process.env.ENVIRONMENT || self.getEnvironment();
|
|
258
318
|
|
|
259
319
|
// Set BEM env variables
|
|
260
320
|
process.env.BEM_FUNCTIONS_URL = self.project.functionsUrl;
|
|
@@ -262,9 +322,10 @@ Manager.prototype.init = function (exporter, options) {
|
|
|
262
322
|
process.env.BEM_WEBSITE_URL = self.project.websiteUrl;
|
|
263
323
|
|
|
264
324
|
// Use the working Firebase logger that they disabled for whatever reason
|
|
325
|
+
// Load the Firebase logger compat shim only in production (NOT development or testing).
|
|
265
326
|
if (
|
|
266
327
|
process.env.GCLOUD_PROJECT
|
|
267
|
-
&& self.
|
|
328
|
+
&& self.isProduction()
|
|
268
329
|
&& options.useFirebaseLogger
|
|
269
330
|
) {
|
|
270
331
|
// require('firebase-functions/lib/logger/compat'); // Old way
|
|
@@ -272,7 +333,7 @@ Manager.prototype.init = function (exporter, options) {
|
|
|
272
333
|
}
|
|
273
334
|
|
|
274
335
|
// Handle test environment
|
|
275
|
-
if (self.
|
|
336
|
+
if (self.isTesting()) {
|
|
276
337
|
self.assistant.log('⚠️⚠️⚠️ Running in TEST environment, some features may be disabled ⚠️⚠️⚠️');
|
|
277
338
|
|
|
278
339
|
// Install the test-mode-file watcher exactly once. Lets the test command
|
|
@@ -283,7 +344,7 @@ Manager.prototype.init = function (exporter, options) {
|
|
|
283
344
|
}
|
|
284
345
|
|
|
285
346
|
// Handle dev environments
|
|
286
|
-
if (self.
|
|
347
|
+
if (self.isDevelopment()) {
|
|
287
348
|
const version = require('wonderful-version');
|
|
288
349
|
const nodeUsing = version.major(process.versions.node);
|
|
289
350
|
const nodeRequired = version.major(self.package?.engines?.node || '0.0.0');
|
|
@@ -331,14 +392,17 @@ Manager.prototype.init = function (exporter, options) {
|
|
|
331
392
|
dsn: sentryDSN,
|
|
332
393
|
release: sentryRelease,
|
|
333
394
|
beforeSend(event, hint) {
|
|
334
|
-
|
|
335
|
-
|
|
395
|
+
// Skip Sentry in any non-production environment (development OR testing) unless
|
|
396
|
+
// explicitly opted in. Intentional positive check — we never want test-run errors
|
|
397
|
+
// polluting production Sentry.
|
|
398
|
+
if (!self.isProduction() && !self.options.reportErrorsInDev) {
|
|
399
|
+
self.assistant.error(new Error('[Sentry] Skipping Sentry because we\'re not in production'), hint)
|
|
336
400
|
return null;
|
|
337
401
|
}
|
|
338
402
|
event.tags = event.tags || {};
|
|
339
403
|
event.tags['function.name'] = self.assistant.meta.name;
|
|
340
404
|
event.tags['function.type'] = self.assistant.meta.type;
|
|
341
|
-
event.tags['environment'] = self.
|
|
405
|
+
event.tags['environment'] = self.getEnvironment();
|
|
342
406
|
return event;
|
|
343
407
|
},
|
|
344
408
|
});
|
|
@@ -381,7 +445,7 @@ Manager.prototype.init = function (exporter, options) {
|
|
|
381
445
|
}
|
|
382
446
|
|
|
383
447
|
// Fetch stats
|
|
384
|
-
if (self.
|
|
448
|
+
if (self.isDevelopment() && options.fetchStats) {
|
|
385
449
|
setTimeout(function () {
|
|
386
450
|
self.assistant.log('Fetching meta/stats...');
|
|
387
451
|
self.libraries.admin
|
|
@@ -677,7 +741,7 @@ Manager.prototype.storage = function (options) {
|
|
|
677
741
|
// Clear temporary storage
|
|
678
742
|
if (
|
|
679
743
|
options.temporary
|
|
680
|
-
&& self.
|
|
744
|
+
&& self.isDevelopment()
|
|
681
745
|
&& options.clear
|
|
682
746
|
) {
|
|
683
747
|
self.assistant.log('Removed temporary file @', location);
|
|
@@ -92,6 +92,39 @@ AI.prototype.request = async function (options) {
|
|
|
92
92
|
return result;
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Generate an image. Dispatches to the provider's image() method.
|
|
97
|
+
*
|
|
98
|
+
* Currently only OpenAI (gpt-image-2) implements image generation.
|
|
99
|
+
*
|
|
100
|
+
* const ai = Manager.AI(assistant);
|
|
101
|
+
* const { buffer } = await ai.image({ prompt: 'a flat vector rocket', size: '1024x1024' });
|
|
102
|
+
*
|
|
103
|
+
* @param {object} options
|
|
104
|
+
* @param {string} options.prompt - Image description (required)
|
|
105
|
+
* @param {'openai'} [options.provider='openai']
|
|
106
|
+
* @param {string} [options.model] - default gpt-image-2
|
|
107
|
+
* @param {string} [options.size] - 1024x1024 | 1536x1024 | 1024x1536 | auto
|
|
108
|
+
* @param {string} [options.quality] - low | medium | high | auto
|
|
109
|
+
* @param {string} [options.background] - transparent | opaque | auto
|
|
110
|
+
* @param {number} [options.n] - how many images (default 1)
|
|
111
|
+
* @param {string} [options.apiKey]
|
|
112
|
+
* @returns {Promise<{buffer, b64, mime, revisedPrompt, model, size, quality, raw}>}
|
|
113
|
+
*/
|
|
114
|
+
AI.prototype.image = async function (options) {
|
|
115
|
+
const self = this;
|
|
116
|
+
const opts = options || {};
|
|
117
|
+
const provider = opts.provider || DEFAULT_PROVIDER;
|
|
118
|
+
|
|
119
|
+
const client = self._getProvider(provider, opts.apiKey);
|
|
120
|
+
|
|
121
|
+
if (typeof client.image !== 'function') {
|
|
122
|
+
throw new Error(`AI provider "${provider}" does not support image generation`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return client.image(opts);
|
|
126
|
+
};
|
|
127
|
+
|
|
95
128
|
AI.prototype._getProvider = function (provider, apiKey) {
|
|
96
129
|
const self = this;
|
|
97
130
|
|
|
@@ -9,6 +9,7 @@ const mimeTypes = require('mime-types');
|
|
|
9
9
|
// Constants
|
|
10
10
|
const DEFAULT_MODEL = 'gpt-5.4-mini';
|
|
11
11
|
const MODERATION_MODEL = 'omni-moderation-latest';
|
|
12
|
+
const IMAGE_DEFAULT_MODEL = 'gpt-image-2';
|
|
12
13
|
|
|
13
14
|
// OpenAI model pricing table (per 1M tokens)
|
|
14
15
|
// https://platform.openai.com/docs/pricing
|
|
@@ -395,10 +396,12 @@ OpenAI.prototype.request = function (options) {
|
|
|
395
396
|
// Reasons
|
|
396
397
|
options.reasoning = options.reasoning || undefined;
|
|
397
398
|
|
|
398
|
-
// Tools
|
|
399
|
-
//
|
|
399
|
+
// Tools — nested, opt-in. `tools.list` is an array of tool definitions (built-in
|
|
400
|
+
// hosted tools like `{ type: 'web_search' }`, OR custom function tools like
|
|
401
|
+
// `{ type: 'function', name, parameters }`); `tools.choice` maps to `tool_choice`
|
|
402
|
+
// (`auto` | `required` | `none` | a specific tool). When omitted/empty, no tools
|
|
403
|
+
// are sent and behavior is identical to a plain request.
|
|
400
404
|
options.tools = options.tools || undefined;
|
|
401
|
-
options.toolChoice = options.toolChoice || undefined;
|
|
402
405
|
|
|
403
406
|
// Format prompt
|
|
404
407
|
//
|
|
@@ -512,6 +515,100 @@ OpenAI.prototype.request = function (options) {
|
|
|
512
515
|
});
|
|
513
516
|
}
|
|
514
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Generate an image via OpenAI's image API (gpt-image-2 by default).
|
|
520
|
+
*
|
|
521
|
+
* Returns the raw bytes — caller decides what to do with them (upload to a CDN,
|
|
522
|
+
* embed as a data URL, write to disk, etc.). gpt-image-* always returns base64,
|
|
523
|
+
* so there's no URL-fetch round trip.
|
|
524
|
+
*
|
|
525
|
+
* @param {object} options
|
|
526
|
+
* @param {string} options.prompt - The image description (required)
|
|
527
|
+
* @param {string} [options.model] - Image model (default gpt-image-2)
|
|
528
|
+
* @param {string} [options.size] - 1024x1024 | 1536x1024 | 1024x1536 | auto (default 1024x1024)
|
|
529
|
+
* @param {string} [options.quality] - low | medium | high | auto (default medium)
|
|
530
|
+
* @param {string} [options.background] - transparent | opaque | auto
|
|
531
|
+
* @param {number} [options.n] - How many images (default 1)
|
|
532
|
+
* @param {number} [options.timeout] - ms (default 300000 — image gen is slow)
|
|
533
|
+
* @param {boolean}[options.log]
|
|
534
|
+
* @returns {Promise<{buffer: Buffer, b64: string, mime: string, revisedPrompt: string|null, model: string, size: string, quality: string, raw: object}>}
|
|
535
|
+
* (or an array of those when n > 1)
|
|
536
|
+
*/
|
|
537
|
+
OpenAI.prototype.image = function (options) {
|
|
538
|
+
const self = this;
|
|
539
|
+
const assistant = self.assistant;
|
|
540
|
+
|
|
541
|
+
return new Promise(async function (resolve, reject) {
|
|
542
|
+
options = _.merge({}, options);
|
|
543
|
+
|
|
544
|
+
if (!options.prompt) {
|
|
545
|
+
return reject(assistant.errorify(`image(): {prompt} is required`, { code: 400 }));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
options.model = options.model || IMAGE_DEFAULT_MODEL;
|
|
549
|
+
options.size = options.size || '1024x1024';
|
|
550
|
+
options.quality = options.quality || 'medium';
|
|
551
|
+
options.n = options.n || 1;
|
|
552
|
+
options.timeout = typeof options.timeout === 'undefined' ? 300000 : options.timeout;
|
|
553
|
+
options.log = typeof options.log === 'undefined' ? false : options.log;
|
|
554
|
+
|
|
555
|
+
const body = {
|
|
556
|
+
model: options.model,
|
|
557
|
+
prompt: options.prompt,
|
|
558
|
+
size: options.size,
|
|
559
|
+
quality: options.quality,
|
|
560
|
+
n: options.n,
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// background is opt-in (transparent/opaque/auto)
|
|
564
|
+
if (options.background) {
|
|
565
|
+
body.background = options.background;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (options.log) {
|
|
569
|
+
assistant.log(`OpenAI.image(): model=${options.model} size=${options.size} quality=${options.quality} n=${options.n}`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let data;
|
|
573
|
+
try {
|
|
574
|
+
data = await fetch('https://api.openai.com/v1/images/generations', {
|
|
575
|
+
method: 'post',
|
|
576
|
+
response: 'json',
|
|
577
|
+
timeout: options.timeout,
|
|
578
|
+
tries: options.tries || 1,
|
|
579
|
+
headers: {
|
|
580
|
+
'Authorization': `Bearer ${self.key}`,
|
|
581
|
+
'Content-Type': 'application/json',
|
|
582
|
+
},
|
|
583
|
+
body: body,
|
|
584
|
+
});
|
|
585
|
+
} catch (e) {
|
|
586
|
+
return reject(assistant.errorify(`OpenAI.image() request failed: ${e.message}`, { code: e.code || 500 }));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (!data?.data?.length) {
|
|
590
|
+
return reject(assistant.errorify(`OpenAI.image() returned no image data: ${JSON.stringify(data).slice(0, 300)}`, { code: 500 }));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const mapItem = (item) => {
|
|
594
|
+
const b64 = item.b64_json;
|
|
595
|
+
return {
|
|
596
|
+
buffer: b64 ? Buffer.from(b64, 'base64') : null,
|
|
597
|
+
b64: b64 || null,
|
|
598
|
+
mime: 'image/png',
|
|
599
|
+
revisedPrompt: item.revised_prompt || null,
|
|
600
|
+
model: options.model,
|
|
601
|
+
size: options.size,
|
|
602
|
+
quality: options.quality,
|
|
603
|
+
raw: data,
|
|
604
|
+
};
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// Single image (the common case) returns one object; n > 1 returns an array.
|
|
608
|
+
return resolve(options.n === 1 ? mapItem(data.data[0]) : data.data.map(mapItem));
|
|
609
|
+
});
|
|
610
|
+
};
|
|
611
|
+
|
|
515
612
|
function tryParse(content) {
|
|
516
613
|
try {
|
|
517
614
|
return JSON5.parse(content);
|
|
@@ -983,15 +1080,15 @@ function makeRequest(mode, options, self, promptSegments, message, user, _log) {
|
|
|
983
1080
|
request.body.reasoning = reasoning;
|
|
984
1081
|
}
|
|
985
1082
|
|
|
986
|
-
// Only include tools
|
|
1083
|
+
// Only include tools if `tools.list` is a non-empty array. When present, the
|
|
987
1084
|
// response output may contain tool-call items (e.g. web_search_call)
|
|
988
1085
|
// alongside the message — the message extractor below already ignores
|
|
989
1086
|
// non-message items, so this is purely additive.
|
|
990
|
-
if (Array.isArray(options.tools) && options.tools.length) {
|
|
991
|
-
request.body.tools = options.tools;
|
|
1087
|
+
if (Array.isArray(options.tools?.list) && options.tools.list.length) {
|
|
1088
|
+
request.body.tools = options.tools.list;
|
|
992
1089
|
|
|
993
|
-
if (options.
|
|
994
|
-
request.body.tool_choice = options.
|
|
1090
|
+
if (options.tools.choice) {
|
|
1091
|
+
request.body.tool_choice = options.tools.choice;
|
|
995
1092
|
}
|
|
996
1093
|
}
|
|
997
1094
|
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ghostii article engine — shared helpers for AI article generation + publishing.
|
|
3
3
|
*
|
|
4
|
-
* Ghostii (api.ghostii.ai)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Ghostii (api.ghostii.ai) is UNOPINIONATED about BEM. It returns a generic,
|
|
5
|
+
* structured article: a `json` block array ([{ name, content }]) plus `title`,
|
|
6
|
+
* `description`, `headerImageUrl`, `categories`, `keywords`. It is NOT shaped to
|
|
7
|
+
* BEM's post format — BEM is responsible for transforming Ghostii's output into
|
|
8
|
+
* what its `admin/post` route expects.
|
|
9
|
+
*
|
|
10
|
+
* `blocksToPost()` is that transform: it digests the JSON blocks and pulls out
|
|
11
|
+
* the title (heading-1), the header image (first image block), and the clean
|
|
12
|
+
* body (every remaining block — sections, paragraphs, section images, quotes —
|
|
13
|
+
* joined as markdown, with NO title or header image embedded, since admin/post
|
|
14
|
+
* adds those itself from the separate `title`/`headerImageURL` fields).
|
|
8
15
|
*
|
|
9
16
|
* Two consumers:
|
|
10
17
|
* - events/cron/daily/ghostii-auto-publisher.js — standalone daily article job
|
|
@@ -23,12 +30,15 @@ const powertools = require('node-powertools');
|
|
|
23
30
|
* @param {object} args.brand - Public brand config ({ brand: { url, ... }, github: { ... } })
|
|
24
31
|
* @param {string} args.description - The article brief / prompt content
|
|
25
32
|
* @param {string[]} [args.links] - Optional links to inject into the article body
|
|
26
|
-
* @returns {Promise<object>}
|
|
33
|
+
* @returns {Promise<object>} Ghostii's generic article response:
|
|
34
|
+
* { title, description, body, json, headerImageUrl, images, categories, keywords, links, outline }
|
|
35
|
+
* where `json` is the structured block array ([{ name, content }]) that
|
|
36
|
+
* blocksToPost() consumes to build BEM's post shape.
|
|
27
37
|
*/
|
|
28
38
|
function writeArticle({ brand, description, links }) {
|
|
29
39
|
return fetch('https://api.ghostii.ai/write/article', {
|
|
30
40
|
method: 'post',
|
|
31
|
-
timeout:
|
|
41
|
+
timeout: 180000,
|
|
32
42
|
tries: 1,
|
|
33
43
|
response: 'json',
|
|
34
44
|
body: {
|
|
@@ -36,6 +46,10 @@ function writeArticle({ brand, description, links }) {
|
|
|
36
46
|
keywords: [''],
|
|
37
47
|
description: description,
|
|
38
48
|
insertLinks: true,
|
|
49
|
+
research: true,
|
|
50
|
+
insertImages: true,
|
|
51
|
+
length: 'long',
|
|
52
|
+
maxLinks: 6,
|
|
39
53
|
headerImageUrl: 'unsplash',
|
|
40
54
|
url: brand.brand.url,
|
|
41
55
|
sectionQuantity: powertools.random(3, 6, { mode: 'gaussian' }),
|
|
@@ -45,13 +59,51 @@ function writeArticle({ brand, description, links }) {
|
|
|
45
59
|
});
|
|
46
60
|
}
|
|
47
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Transform Ghostii's generic JSON block array into BEM's post shape.
|
|
64
|
+
*
|
|
65
|
+
* Ghostii returns `json: [{ name, content }]` where name ∈ heading-1..6, image,
|
|
66
|
+
* paragraph, blockquote, list. BEM's admin/post wants the title + header image as
|
|
67
|
+
* SEPARATE fields and a body that is ONLY the content below them. So we extract:
|
|
68
|
+
* - title ← first heading-1 block (markdown `#` stripped)
|
|
69
|
+
* - headerImageUrl ← first image block's URL
|
|
70
|
+
* - body ← every remaining block, joined as markdown
|
|
71
|
+
*
|
|
72
|
+
* @param {Array} json - Ghostii's block array.
|
|
73
|
+
* @returns {{ title: string, headerImageUrl: string, body: string }}
|
|
74
|
+
*/
|
|
75
|
+
function blocksToPost(json) {
|
|
76
|
+
const blocks = Array.isArray(json) ? json : [];
|
|
77
|
+
|
|
78
|
+
const titleBlock = blocks.find((b) => b.name === 'heading-1');
|
|
79
|
+
const imageBlock = blocks.find((b) => b.name === 'image');
|
|
80
|
+
|
|
81
|
+
// Title: strip the leading markdown heading marker.
|
|
82
|
+
const title = (titleBlock?.content || '').replace(/^#+\s*/, '').trim();
|
|
83
|
+
|
|
84
|
+
// Header image: pull the URL out of ``.
|
|
85
|
+
const imageMatch = (imageBlock?.content || '').match(/\((.*?)\)\s*$/);
|
|
86
|
+
const headerImageUrl = imageMatch ? imageMatch[1] : '';
|
|
87
|
+
|
|
88
|
+
// Body: everything except the title block and the header-image block.
|
|
89
|
+
const body = blocks
|
|
90
|
+
.filter((b) => b !== titleBlock && b !== imageBlock)
|
|
91
|
+
.map((b) => b.content)
|
|
92
|
+
.join('\n\n')
|
|
93
|
+
.trim();
|
|
94
|
+
|
|
95
|
+
return { title, headerImageUrl, body };
|
|
96
|
+
}
|
|
97
|
+
|
|
48
98
|
/**
|
|
49
99
|
* Publish a Ghostii article to the brand's website repo via the admin/post route.
|
|
50
100
|
*
|
|
51
101
|
* @param {object} assistant - BEM assistant instance
|
|
52
102
|
* @param {object} args
|
|
53
103
|
* @param {object} args.brand - Public brand config ({ brand: { url, ... }, github: { user, repo } })
|
|
54
|
-
* @param {object} args.article - The article from writeArticle()
|
|
104
|
+
* @param {object} args.article - The article from writeArticle(). When it carries a
|
|
105
|
+
* `json` block array, the post is reconstructed via blocksToPost() (title + header
|
|
106
|
+
* image extracted, body = content only). Falls back to article.{title,body,headerImageUrl}.
|
|
55
107
|
* @param {number} args.id - Post ID (unix timestamp)
|
|
56
108
|
* @param {string} [args.author] - Author slug (admin/post picks a default if unset)
|
|
57
109
|
* @param {string} [args.postPath='ghostii'] - Sub-folder under _posts/{year}/
|
|
@@ -61,18 +113,26 @@ async function publishArticle(assistant, { brand, article, id, author, postPath
|
|
|
61
113
|
const baseUrl = (brand.brand.url || '').replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
62
114
|
const apiUrl = `https://api.${baseUrl}`;
|
|
63
115
|
|
|
64
|
-
|
|
116
|
+
// Transform Ghostii's generic JSON into BEM's post shape (title + header image
|
|
117
|
+
// as separate fields, body = content only). Fall back to the legacy flat fields
|
|
118
|
+
// for older Ghostii responses that don't include `json`.
|
|
119
|
+
const post = blocksToPost(article.json);
|
|
120
|
+
const title = post.title || article.title;
|
|
121
|
+
const headerImageUrl = post.headerImageUrl || article.headerImageUrl;
|
|
122
|
+
const body = article.json ? post.body : article.body;
|
|
123
|
+
|
|
124
|
+
const result = await fetch(`${apiUrl}/backend-manager/admin/post`, {
|
|
65
125
|
method: 'POST',
|
|
66
126
|
timeout: 90000,
|
|
67
127
|
tries: 1,
|
|
68
128
|
response: 'json',
|
|
69
129
|
body: {
|
|
70
130
|
backendManagerKey: process.env.BACKEND_MANAGER_KEY,
|
|
71
|
-
title:
|
|
72
|
-
url:
|
|
131
|
+
title: title,
|
|
132
|
+
url: title, // Slugified on the admin/post endpoint
|
|
73
133
|
description: article.description,
|
|
74
|
-
headerImageURL:
|
|
75
|
-
body:
|
|
134
|
+
headerImageURL: headerImageUrl,
|
|
135
|
+
body: body,
|
|
76
136
|
id: id,
|
|
77
137
|
author: author,
|
|
78
138
|
categories: article.categories,
|
|
@@ -86,15 +146,15 @@ async function publishArticle(assistant, { brand, article, id, author, postPath
|
|
|
86
146
|
// admin/post returns the resolved `settings` (incl. the slugified `url` and repo `path`).
|
|
87
147
|
// The post template sets no permalink, so the public URL follows the Jekyll/UJM
|
|
88
148
|
// blog convention: {brand.url}/blog/{slug}.
|
|
89
|
-
const slug =
|
|
149
|
+
const slug = result?.url || '';
|
|
90
150
|
const publicUrl = `${(brand.brand.url || '').replace(/\/$/, '')}/blog/${slug}`;
|
|
91
151
|
|
|
92
152
|
return {
|
|
93
|
-
post,
|
|
153
|
+
post: result,
|
|
94
154
|
url: slug ? publicUrl : null,
|
|
95
155
|
slug,
|
|
96
|
-
path:
|
|
156
|
+
path: result?.path || null,
|
|
97
157
|
};
|
|
98
158
|
}
|
|
99
159
|
|
|
100
|
-
module.exports = { writeArticle, publishArticle };
|
|
160
|
+
module.exports = { writeArticle, publishArticle, blocksToPost };
|