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.
Files changed (65) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/CLAUDE.md +6 -4
  3. package/bin/backend-manager +10 -1
  4. package/docs/admin-post-route.md +2 -0
  5. package/docs/ai-library.md +29 -2
  6. package/docs/cli-output.md +122 -0
  7. package/docs/common-mistakes.md +1 -1
  8. package/docs/environment-detection.md +87 -3
  9. package/docs/marketing-campaigns.md +1 -1
  10. package/docs/payment-system.md +1 -1
  11. package/docs/testing.md +64 -17
  12. package/package.json +1 -1
  13. package/src/cli/commands/base-command.js +4 -0
  14. package/src/cli/commands/install.js +2 -2
  15. package/src/cli/commands/setup-tests/bem-config.js +22 -9
  16. package/src/cli/commands/setup-tests/helpers/merge-line-files.js +22 -168
  17. package/src/cli/commands/setup.js +65 -33
  18. package/src/cli/commands/test.js +1 -1
  19. package/src/cli/index.js +72 -39
  20. package/src/cli/utils/ui.js +270 -0
  21. package/src/defaults/CLAUDE.md +2 -2
  22. package/src/defaults/test/_init.js +14 -0
  23. package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
  24. package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
  25. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  26. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  27. package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
  28. package/src/manager/helpers/analytics.js +3 -1
  29. package/src/manager/helpers/assistant.js +43 -38
  30. package/src/manager/index.js +76 -12
  31. package/src/manager/libraries/ai/index.js +33 -0
  32. package/src/manager/libraries/ai/providers/openai.js +105 -8
  33. package/src/manager/libraries/content/ghostii.js +76 -16
  34. package/src/manager/libraries/email/data/disposable-domains.json +24 -0
  35. package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
  36. package/src/manager/libraries/email/generators/newsletter.js +16 -2
  37. package/src/manager/libraries/payment/discount-codes.js +1 -0
  38. package/src/manager/routes/admin/post/put.js +1 -1
  39. package/src/manager/routes/handler/post/post.js +1 -1
  40. package/src/manager/routes/payments/intent/processors/test.js +1 -1
  41. package/src/manager/routes/user/delete.js +1 -1
  42. package/src/manager/routes/user/signup/post.js +4 -2
  43. package/src/test/runner.js +142 -20
  44. package/src/test/test-accounts.js +159 -102
  45. package/src/utils/merge-line-files.js +16 -5
  46. package/templates/_.env +1 -0
  47. package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
  48. package/test/events/payments/journey-payments-cancel.js +6 -3
  49. package/test/events/payments/journey-payments-failure.js +6 -3
  50. package/test/events/payments/journey-payments-plan-change.js +6 -3
  51. package/test/events/payments/journey-payments-refund-webhook.js +6 -2
  52. package/test/events/payments/journey-payments-suspend.js +6 -3
  53. package/test/events/payments/journey-payments-trial-cancel.js +6 -3
  54. package/test/events/payments/journey-payments-trial.js +10 -4
  55. package/test/events/payments/journey-payments-uid-resolution.js +6 -2
  56. package/test/events/payments/journey-payments-upgrade.js +6 -3
  57. package/test/helpers/content/ghostii-blocks.js +134 -0
  58. package/test/helpers/environment.js +230 -0
  59. package/test/helpers/merge-line-files.js +271 -0
  60. package/test/routes/marketing/webhook-forward.js +14 -7
  61. package/test/routes/payments/cancel.js +4 -1
  62. package/test/routes/payments/dispute-alert.js +9 -6
  63. package/test/routes/payments/intent.js +55 -14
  64. package/test/routes/payments/portal.js +4 -1
  65. 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 (e.g. built-in web_search). Opt-in when omitted, no tools are sent
399
- // and behavior is identical to a plain request.
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 (e.g. web_search) if provided. When present, the
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.toolChoice) {
994
- request.body.tool_choice = options.toolChoice;
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) writes a full blog article (title, body, header image,
5
- * categories, keywords) from a free-form description. We then publish it to the
6
- * brand's website repo via the internal `admin/post` route (commits markdown +
7
- * images to GitHub).
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>} { title, description, body, headerImageUrl, categories, keywords }
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: 90000,
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 `![alt](url)`.
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
- const post = await fetch(`${apiUrl}/backend-manager/admin/post`, {
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: article.title,
72
- url: article.title, // Slugified on the admin/post endpoint
131
+ title: title,
132
+ url: title, // Slugified on the admin/post endpoint
73
133
  description: article.description,
74
- headerImageURL: article.headerImageUrl,
75
- body: article.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 = post?.url || '';
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: post?.path || null,
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 };