backend-manager 5.2.18 → 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 +35 -0
- package/CLAUDE.md +6 -4
- package/bin/backend-manager +10 -1
- package/docs/admin-post-route.md +2 -0
- package/docs/ai-library.md +29 -2
- package/docs/cli-output.md +122 -0
- 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 +64 -17
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +4 -0
- package/src/cli/commands/install.js +2 -2
- package/src/cli/commands/setup-tests/bem-config.js +22 -9
- package/src/cli/commands/setup-tests/helpers/merge-line-files.js +22 -168
- package/src/cli/commands/setup.js +65 -33
- package/src/cli/commands/test.js +1 -1
- package/src/cli/index.js +72 -39
- package/src/cli/utils/ui.js +270 -0
- 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 +24 -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/src/utils/merge-line-files.js +16 -5
- package/templates/_.env +1 -0
- 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/helpers/merge-line-files.js +271 -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
|
@@ -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 };
|
|
@@ -602,6 +602,7 @@
|
|
|
602
602
|
"beaconmessenger.com",
|
|
603
603
|
"bearsarefuzzy.com",
|
|
604
604
|
"beastmail.space",
|
|
605
|
+
"beatbok.online",
|
|
605
606
|
"beddly.com",
|
|
606
607
|
"beefmilk.com",
|
|
607
608
|
"beelsil.com",
|
|
@@ -1277,6 +1278,7 @@
|
|
|
1277
1278
|
"doobb.com",
|
|
1278
1279
|
"doojazz.com",
|
|
1279
1280
|
"doquier.tk",
|
|
1281
|
+
"doreact.com",
|
|
1280
1282
|
"dotapodemail.com",
|
|
1281
1283
|
"dotman.de",
|
|
1282
1284
|
"dotmsg.com",
|
|
@@ -1832,6 +1834,7 @@
|
|
|
1832
1834
|
"freeteenbums.com",
|
|
1833
1835
|
"freetimer.online",
|
|
1834
1836
|
"freundin.ru",
|
|
1837
|
+
"friday12.store",
|
|
1835
1838
|
"friendlymail.co.uk",
|
|
1836
1839
|
"frm.ovh",
|
|
1837
1840
|
"front14.org",
|
|
@@ -2057,6 +2060,7 @@
|
|
|
2057
2060
|
"gowikitv.com",
|
|
2058
2061
|
"grandmamail.com",
|
|
2059
2062
|
"grandmasmail.com",
|
|
2063
|
+
"graphicbulk.site",
|
|
2060
2064
|
"graphicenda.site",
|
|
2061
2065
|
"grassdev.com",
|
|
2062
2066
|
"gravityengine.cc",
|
|
@@ -2127,6 +2131,7 @@
|
|
|
2127
2131
|
"haltospam.com",
|
|
2128
2132
|
"hamham.uk",
|
|
2129
2133
|
"handrik.com",
|
|
2134
|
+
"hanghai232.shop",
|
|
2130
2135
|
"hangxomcuatoilatotoro.ml",
|
|
2131
2136
|
"happiseektest.com",
|
|
2132
2137
|
"happy-new-year.top",
|
|
@@ -2237,6 +2242,7 @@
|
|
|
2237
2242
|
"housereformas.es",
|
|
2238
2243
|
"hpari.com",
|
|
2239
2244
|
"hpc.tw",
|
|
2245
|
+
"hqpdf.com",
|
|
2240
2246
|
"hs.vc",
|
|
2241
2247
|
"hsmw.net",
|
|
2242
2248
|
"ht.cx",
|
|
@@ -2256,6 +2262,7 @@
|
|
|
2256
2262
|
"hushmail.cf",
|
|
2257
2263
|
"huskion.net",
|
|
2258
2264
|
"hutudns.com",
|
|
2265
|
+
"huytuyen.click",
|
|
2259
2266
|
"hvastudiesucces.nl",
|
|
2260
2267
|
"hwsye.net",
|
|
2261
2268
|
"hxopi.ru",
|
|
@@ -2488,6 +2495,7 @@
|
|
|
2488
2495
|
"jdmadventures.com",
|
|
2489
2496
|
"jdz.ro",
|
|
2490
2497
|
"je-recycle.info",
|
|
2498
|
+
"jejes.de",
|
|
2491
2499
|
"jellow.ml",
|
|
2492
2500
|
"jellyrolls.com",
|
|
2493
2501
|
"jeoce.com",
|
|
@@ -2713,6 +2721,7 @@
|
|
|
2713
2721
|
"laste.ml",
|
|
2714
2722
|
"lastmail.co",
|
|
2715
2723
|
"lastmail.com",
|
|
2724
|
+
"laulund.info",
|
|
2716
2725
|
"launders.money",
|
|
2717
2726
|
"lawlita.com",
|
|
2718
2727
|
"laxex.ru",
|
|
@@ -3162,6 +3171,7 @@
|
|
|
3162
3171
|
"midlertidig.com",
|
|
3163
3172
|
"midlertidig.net",
|
|
3164
3173
|
"midlertidig.org",
|
|
3174
|
+
"midwestpride.org",
|
|
3165
3175
|
"mierdamail.com",
|
|
3166
3176
|
"migmail.net",
|
|
3167
3177
|
"migmail.pl",
|
|
@@ -3375,6 +3385,7 @@
|
|
|
3375
3385
|
"nanonym.ch",
|
|
3376
3386
|
"nanopools.info",
|
|
3377
3387
|
"naobk.com",
|
|
3388
|
+
"narrowment.com",
|
|
3378
3389
|
"narsub.online",
|
|
3379
3390
|
"narsub.shop",
|
|
3380
3391
|
"naslazhdai.ru",
|
|
@@ -3541,6 +3552,7 @@
|
|
|
3541
3552
|
"nutpa.net",
|
|
3542
3553
|
"nuts2trade.com",
|
|
3543
3554
|
"nvhrw.com",
|
|
3555
|
+
"nvqt304.xyz",
|
|
3544
3556
|
"nwldx.com",
|
|
3545
3557
|
"nwytg.com",
|
|
3546
3558
|
"nwytg.net",
|
|
@@ -3583,6 +3595,7 @@
|
|
|
3583
3595
|
"oku.ovh",
|
|
3584
3596
|
"oky.ovh",
|
|
3585
3597
|
"okzk.com",
|
|
3598
|
+
"oldao.com",
|
|
3586
3599
|
"olimp-case.ru",
|
|
3587
3600
|
"oliviadiffuser.store",
|
|
3588
3601
|
"oloh.ru",
|
|
@@ -3593,6 +3606,7 @@
|
|
|
3593
3606
|
"omarnasrrr.com",
|
|
3594
3607
|
"omfg.run",
|
|
3595
3608
|
"omnievents.org",
|
|
3609
|
+
"omnilog.space",
|
|
3596
3610
|
"omtecha.com",
|
|
3597
3611
|
"onbap.com",
|
|
3598
3612
|
"one-mail.top",
|
|
@@ -3821,6 +3835,7 @@
|
|
|
3821
3835
|
"powerscrews.com",
|
|
3822
3836
|
"powlearn.com",
|
|
3823
3837
|
"pox2.com",
|
|
3838
|
+
"pozima.rest",
|
|
3824
3839
|
"pp7rvv.com",
|
|
3825
3840
|
"ppetw.com",
|
|
3826
3841
|
"pptrvv.com",
|
|
@@ -4059,6 +4074,7 @@
|
|
|
4059
4074
|
"royaldoodles.org",
|
|
4060
4075
|
"royalmarket.life",
|
|
4061
4076
|
"royandk.com",
|
|
4077
|
+
"roybl.com",
|
|
4062
4078
|
"rppkn.com",
|
|
4063
4079
|
"rr.nu",
|
|
4064
4080
|
"rsvhr.com",
|
|
@@ -4068,6 +4084,7 @@
|
|
|
4068
4084
|
"rudymail.ml",
|
|
4069
4085
|
"rulersonline.com",
|
|
4070
4086
|
"rumgel.com",
|
|
4087
|
+
"run.place",
|
|
4071
4088
|
"runi.ca",
|
|
4072
4089
|
"rupayamail.com",
|
|
4073
4090
|
"ruru.be",
|
|
@@ -4096,6 +4113,7 @@
|
|
|
4096
4113
|
"sahildash.dev",
|
|
4097
4114
|
"sailmail.io",
|
|
4098
4115
|
"sajutadollars.com",
|
|
4116
|
+
"salakoais.shop",
|
|
4099
4117
|
"saleis.live",
|
|
4100
4118
|
"salmeow.tk",
|
|
4101
4119
|
"sam1.eu.org",
|
|
@@ -4418,6 +4436,7 @@
|
|
|
4418
4436
|
"spymail.one",
|
|
4419
4437
|
"squizzy.de",
|
|
4420
4438
|
"squizzy.net",
|
|
4439
|
+
"srame.com",
|
|
4421
4440
|
"sroff.com",
|
|
4422
4441
|
"sry.li",
|
|
4423
4442
|
"ssi-bsn.infos.st",
|
|
@@ -4815,6 +4834,7 @@
|
|
|
4815
4834
|
"trackden.com",
|
|
4816
4835
|
"tradermail.info",
|
|
4817
4836
|
"tranceversal.com",
|
|
4837
|
+
"trandigabapoe.shop",
|
|
4818
4838
|
"trangiabao.click",
|
|
4819
4839
|
"trangzim.uk",
|
|
4820
4840
|
"translateid.com",
|
|
@@ -5286,6 +5306,7 @@
|
|
|
5286
5306
|
"wyoxafp.com",
|
|
5287
5307
|
"wywnxa.com",
|
|
5288
5308
|
"wzofit.com",
|
|
5309
|
+
"x-clone.asia",
|
|
5289
5310
|
"x-lab.net",
|
|
5290
5311
|
"x0q.net",
|
|
5291
5312
|
"x1ix.com",
|
|
@@ -5294,6 +5315,8 @@
|
|
|
5294
5315
|
"xagloo.com",
|
|
5295
5316
|
"xapimail.top",
|
|
5296
5317
|
"xbaby69.top",
|
|
5318
|
+
"xbrok.com",
|
|
5319
|
+
"xclone.asia",
|
|
5297
5320
|
"xcode.ro",
|
|
5298
5321
|
"xcodes.net",
|
|
5299
5322
|
"xcompress.com",
|
|
@@ -5364,6 +5387,7 @@
|
|
|
5364
5387
|
"yeezus.ru",
|
|
5365
5388
|
"yep.it",
|
|
5366
5389
|
"yermail.net",
|
|
5390
|
+
"yeummo.io.vn",
|
|
5367
5391
|
"yhg.biz",
|
|
5368
5392
|
"ynmrealty.com",
|
|
5369
5393
|
"yodx.ro",
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image illustrator — AI generates a flat-vector PNG illustration per section
|
|
3
|
+
* directly via OpenAI's image model (gpt-image-2), no SVG-author-then-rasterize step.
|
|
4
|
+
*
|
|
5
|
+
* This is the DEFAULT newsletter illustration method. It replaces the older
|
|
6
|
+
* svg-illustrator.js approach (AI writes an <svg>, resvg rasterizes it) which is
|
|
7
|
+
* still available as a fallback via newsletterConfig.method.image = 'svg'.
|
|
8
|
+
*
|
|
9
|
+
* Output matches svg-illustrator's contract: { png: Buffer, fallback, meta }.
|
|
10
|
+
* The newsletter pipeline (image-host.js) only requires `img.png` to be a PNG Buffer.
|
|
11
|
+
*
|
|
12
|
+
* Style: clean flat 2D vector illustration (Stripe / Linear / undraw.co aesthetic),
|
|
13
|
+
* built from the brand palette, on a white background, no text. The prompt mirrors
|
|
14
|
+
* the validated spike in scripts/.temp/ai-image-test/.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_MODEL = 'gpt-image-2';
|
|
17
|
+
const DEFAULT_SIZE = '1024x1024'; // square reads best for flat section art
|
|
18
|
+
const DEFAULT_QUALITY = 'medium'; // medium is plenty for flat vector; ~40-50s/image
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate one illustration for a section.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} args
|
|
24
|
+
* @param {string} args.imagePrompt - Visual description from the structure
|
|
25
|
+
* @param {object} args.brand - { name, tagline, color: { primary, secondary, ... } }
|
|
26
|
+
* @param {object} args.newsletterConfig - marketing.beehiiv.content
|
|
27
|
+
* @param {object} args.ai - Manager.AI() instance
|
|
28
|
+
* @param {object} args.assistant - BEM assistant
|
|
29
|
+
* @returns {Promise<{png: Buffer, fallback: boolean, meta: object}>}
|
|
30
|
+
*/
|
|
31
|
+
async function generateSectionImage({ imagePrompt, brand, newsletterConfig, ai, assistant }) {
|
|
32
|
+
const startTime = Date.now();
|
|
33
|
+
const model = newsletterConfig?.model?.image || DEFAULT_MODEL;
|
|
34
|
+
const size = newsletterConfig?.image?.size || DEFAULT_SIZE;
|
|
35
|
+
const quality = newsletterConfig?.image?.quality || DEFAULT_QUALITY;
|
|
36
|
+
|
|
37
|
+
const palette = resolvePalette(brand, newsletterConfig);
|
|
38
|
+
const subject = imagePrompt || 'An abstract geometric shape representing the topic.';
|
|
39
|
+
const prompt = buildImagePrompt({ brand, palette, subject });
|
|
40
|
+
|
|
41
|
+
let png = null;
|
|
42
|
+
let fallback = false;
|
|
43
|
+
let revisedPrompt = null;
|
|
44
|
+
let attempts = 0;
|
|
45
|
+
|
|
46
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
47
|
+
attempts++;
|
|
48
|
+
try {
|
|
49
|
+
const result = await ai.image({
|
|
50
|
+
provider: 'openai',
|
|
51
|
+
model,
|
|
52
|
+
prompt,
|
|
53
|
+
size,
|
|
54
|
+
quality,
|
|
55
|
+
background: 'opaque',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (result?.png || result?.buffer) {
|
|
59
|
+
png = result.png || result.buffer;
|
|
60
|
+
revisedPrompt = result.revisedPrompt || null;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
assistant.log(`Image generation attempt ${attempt + 1} returned no buffer`);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
assistant.error(`Image generation attempt ${attempt + 1} failed: ${e.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// No transparent-PNG placeholder fallback that's valid as an image — if both
|
|
71
|
+
// attempts fail, return a 1x1 transparent PNG so the pipeline doesn't crash on
|
|
72
|
+
// a non-Buffer. The caller logs and proceeds.
|
|
73
|
+
if (!png) {
|
|
74
|
+
png = TRANSPARENT_PNG;
|
|
75
|
+
fallback = true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
png,
|
|
80
|
+
fallback,
|
|
81
|
+
meta: {
|
|
82
|
+
method: 'image',
|
|
83
|
+
provider: 'openai',
|
|
84
|
+
model,
|
|
85
|
+
size,
|
|
86
|
+
quality,
|
|
87
|
+
durationMs: Date.now() - startTime,
|
|
88
|
+
attempts,
|
|
89
|
+
fallback,
|
|
90
|
+
revisedPrompt,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve brand palette — same precedence as svg-illustrator.js#resolvePalette:
|
|
97
|
+
* newsletter theme colors → brand.color.* → sensible defaults.
|
|
98
|
+
*/
|
|
99
|
+
function resolvePalette(brand, newsletterConfig) {
|
|
100
|
+
const theme = newsletterConfig?.theme || {};
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
primary: theme.primaryColor || brand?.color?.primary || '#4B5BFF',
|
|
104
|
+
secondary: theme.secondaryColor || brand?.color?.secondary || '#1E1E2A',
|
|
105
|
+
accent: theme.accentColor || brand?.color?.accent || '#F4F6FA',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the flat-vector art-direction prompt with brand identity + palette baked in.
|
|
111
|
+
* Mirrors the validated prompt from scripts/.temp/ai-image-test/generate.js.
|
|
112
|
+
*/
|
|
113
|
+
function buildImagePrompt({ brand, palette, subject }) {
|
|
114
|
+
const { primary, secondary, accent } = palette;
|
|
115
|
+
const name = brand?.name || 'the brand';
|
|
116
|
+
const tagline = brand?.tagline ? ` — ${brand.tagline}` : '';
|
|
117
|
+
|
|
118
|
+
return [
|
|
119
|
+
`Minimal flat vector illustration for the newsletter of "${name}"${tagline}.`,
|
|
120
|
+
'',
|
|
121
|
+
'STYLE: modern flat 2D vector illustration, like the marketing graphics on',
|
|
122
|
+
'Stripe, Linear, Vercel, Notion, or undraw.co. Clean geometric shapes with',
|
|
123
|
+
'flat solid color fills and crisp edges. Simple, friendly, and minimal.',
|
|
124
|
+
'',
|
|
125
|
+
'ABSOLUTELY NOT: no photorealism, no 3D renders, no realistic textures, no',
|
|
126
|
+
'photography, no depth of field, no dramatic lighting, no film grain, no',
|
|
127
|
+
'shading or gradients beyond a single subtle flat tone shift, no glossy',
|
|
128
|
+
'reflections, no metallic surfaces. This must look hand-designed in a vector',
|
|
129
|
+
'tool (Figma/Illustrator), NOT rendered or photographed.',
|
|
130
|
+
'',
|
|
131
|
+
'PALETTE (use ONLY these flat colors, plus white):',
|
|
132
|
+
`- Primary: ${primary}`,
|
|
133
|
+
`- Secondary: ${secondary}`,
|
|
134
|
+
`- Accent: ${accent}`,
|
|
135
|
+
'Flat solid fills only. A clean white or very-light background.',
|
|
136
|
+
'',
|
|
137
|
+
'COMPOSITION: single clear simple subject, lots of clean negative space,',
|
|
138
|
+
'centered or rule-of-thirds, uncluttered, plenty of breathing room.',
|
|
139
|
+
'',
|
|
140
|
+
'HARD CONSTRAINTS: absolutely NO text, NO words, NO letters, NO numbers,',
|
|
141
|
+
'NO logos, NO watermarks, NO UI mockups, NO borders or frames.',
|
|
142
|
+
'',
|
|
143
|
+
`SUBJECT (the focal scene to illustrate): ${subject}`,
|
|
144
|
+
].join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 1x1 transparent PNG — last-resort fallback so a failed generation still yields a
|
|
148
|
+
// valid PNG Buffer instead of crashing image-host.js's Buffer check.
|
|
149
|
+
const TRANSPARENT_PNG = Buffer.from(
|
|
150
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
|
151
|
+
'base64'
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
module.exports = { generateSectionImage };
|